From 4577ab7bccb6e0a4e5e2a8151a5352d54b752b26 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:10:41 +0545 Subject: [PATCH 01/14] feat: add ProviderKey, Model and APIKey custom resource types --- .gitignore | 2 + api/v1alpha1/groupversion_info.go | 28 ++++ api/v1alpha1/kind.go | 107 ++++++++++++ api/v1alpha1/types.go | 113 +++++++++++++ api/v1alpha1/zz_generated.deepcopy.go | 225 ++++++++++++++++++++++++++ go.mod | 31 ++++ go.sum | 97 +++++++++++ hack/boilerplate.go.txt | 15 ++ 8 files changed, 618 insertions(+) create mode 100644 .gitignore create mode 100644 api/v1alpha1/groupversion_info.go create mode 100644 api/v1alpha1/kind.go create mode 100644 api/v1alpha1/types.go create mode 100644 api/v1alpha1/zz_generated.deepcopy.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hack/boilerplate.go.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f60f0e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/cover.out diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..b0e77cd --- /dev/null +++ b/api/v1alpha1/groupversion_info.go @@ -0,0 +1,28 @@ +// Package v1alpha1 holds the aisix.api7.ai/v1alpha1 custom resource types. +// +kubebuilder:object:generate=true +// +groupName=aisix.api7.ai +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + GroupVersion = schema.GroupVersion{Group: "aisix.api7.ai", Version: "v1alpha1"} + + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + + AddToScheme = SchemeBuilder.AddToScheme +) + +func addKnownTypes(s *runtime.Scheme) error { + s.AddKnownTypes(GroupVersion, + &ProviderKey{}, &ProviderKeyList{}, + &Model{}, &ModelList{}, + &APIKey{}, &APIKeyList{}, + ) + metav1.AddToGroupVersion(s, GroupVersion) + return nil +} diff --git a/api/v1alpha1/kind.go b/api/v1alpha1/kind.go new file mode 100644 index 0000000..3a2822f --- /dev/null +++ b/api/v1alpha1/kind.go @@ -0,0 +1,107 @@ +package v1alpha1 + +import ( + "bytes" + "io" +) + +func bytesReader(b []byte) io.Reader { return bytes.NewReader(b) } + +// Kind is one of the resource kinds the controller reconciles. +type Kind string + +const ( + KindProviderKey Kind = "ProviderKey" + KindModel Kind = "Model" + KindAPIKey Kind = "APIKey" +) + +// Kinds in resources-file order. +var Kinds = []Kind{KindProviderKey, KindModel, KindAPIKey} + +// Collection is the kind's key in the resources file. +func (k Kind) Collection() string { + switch k { + case KindProviderKey: + return "provider_keys" + case KindModel: + return "models" + case KindAPIKey: + return "api_keys" + } + return "" +} + +// SchemaName is the kind's file name stem in AISIX's schemas/resources. +func (k Kind) SchemaName() string { + switch k { + case KindProviderKey: + return "provider_key" + case KindModel: + return "model" + case KindAPIKey: + return "api_key" + } + return "" +} + +// Plural is the CRD resource name. +func (k Kind) Plural() string { + switch k { + case KindProviderKey: + return "providerkeys" + case KindModel: + return "models" + case KindAPIKey: + return "apikeys" + } + return "" +} + +// ShortName is the CRD short name. +func (k Kind) ShortName() string { + switch k { + case KindProviderKey: + return "aipk" + case KindModel: + return "aimodel" + case KindAPIKey: + return "aikey" + } + return "" +} + +// Order is the kind's position in the resources file. +func (k Kind) Order() int { + for i, kk := range Kinds { + if kk == k { + return i + } + } + return len(Kinds) +} + +// KindForCollection maps a resources-file collection back to its kind. +func KindForCollection(c string) (Kind, bool) { + for _, k := range Kinds { + if k.Collection() == c { + return k, true + } + } + return "", false +} + +const ( + // IdentityField names an entry in the resources file; it defaults to + // metadata.name. + IdentityField = "display_name" + // APIKeySecretRef holds a ProviderKey's upstream credential. + APIKeySecretRef = "api_key_secret_ref" + // ProviderKeyRef names the ProviderKey a Model authenticates with. + ProviderKeyRef = "provider_key_ref" + // KeySecretRef holds an APIKey's plaintext caller key. + KeySecretRef = "key_secret_ref" + + // HashAnnotation carries the served resources-file hash. + HashAnnotation = "aisix.api7.ai/resources-hash" +) diff --git a/api/v1alpha1/types.go b/api/v1alpha1/types.go new file mode 100644 index 0000000..fb96c07 --- /dev/null +++ b/api/v1alpha1/types.go @@ -0,0 +1,113 @@ +package v1alpha1 + +import ( + "encoding/json" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// The spec of every kind is the AISIX resource document with its own +// snake_case field names, plus reference fields the controller resolves. +// Its schema is generated from AISIX (cmd/crdgen), so Go keeps it as raw +// JSON. + +// ResourceStatus is shared by every kind. +type ResourceStatus struct { + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ProviderKey is an upstream provider endpoint and its credential. +type ProviderKey struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec apiextensionsv1.JSON `json:"spec"` + Status ResourceStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +type ProviderKeyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ProviderKey `json:"items"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// Model is a model clients call by name. +type Model struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec apiextensionsv1.JSON `json:"spec"` + Status ResourceStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +type ModelList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Model `json:"items"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// APIKey is a caller credential. +type APIKey struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec apiextensionsv1.JSON `json:"spec"` + Status ResourceStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +type APIKeyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []APIKey `json:"items"` +} + +// Object is implemented by every kind. +// +kubebuilder:object:generate=false +type Object interface { + metav1.Object + SpecRaw() []byte + ResourceStatus() *ResourceStatus +} + +func (o *ProviderKey) SpecRaw() []byte { return o.Spec.Raw } +func (o *ProviderKey) ResourceStatus() *ResourceStatus { return &o.Status } +func (o *Model) SpecRaw() []byte { return o.Spec.Raw } +func (o *Model) ResourceStatus() *ResourceStatus { return &o.Status } +func (o *APIKey) SpecRaw() []byte { return o.Spec.Raw } +func (o *APIKey) ResourceStatus() *ResourceStatus { return &o.Status } + +// SpecFields decodes a raw spec. Numbers stay json.Number so they round-trip +// exactly. +func SpecFields(raw []byte) (map[string]any, error) { + out := map[string]any{} + if len(raw) == 0 { + return out, nil + } + dec := json.NewDecoder(bytesReader(raw)) + dec.UseNumber() + if err := dec.Decode(&out); err != nil { + return nil, err + } + return out, nil +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..819453a --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,225 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2026 API7.ai, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIKey) DeepCopyInto(out *APIKey) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIKey. +func (in *APIKey) DeepCopy() *APIKey { + if in == nil { + return nil + } + out := new(APIKey) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *APIKey) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIKeyList) DeepCopyInto(out *APIKeyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]APIKey, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIKeyList. +func (in *APIKeyList) DeepCopy() *APIKeyList { + if in == nil { + return nil + } + out := new(APIKeyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *APIKeyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Model) DeepCopyInto(out *Model) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Model. +func (in *Model) DeepCopy() *Model { + if in == nil { + return nil + } + out := new(Model) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Model) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModelList) DeepCopyInto(out *ModelList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Model, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelList. +func (in *ModelList) DeepCopy() *ModelList { + if in == nil { + return nil + } + out := new(ModelList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ModelList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderKey) DeepCopyInto(out *ProviderKey) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderKey. +func (in *ProviderKey) DeepCopy() *ProviderKey { + if in == nil { + return nil + } + out := new(ProviderKey) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProviderKey) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderKeyList) DeepCopyInto(out *ProviderKeyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ProviderKey, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderKeyList. +func (in *ProviderKeyList) DeepCopy() *ProviderKeyList { + if in == nil { + return nil + } + out := new(ProviderKeyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProviderKeyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceStatus) DeepCopyInto(out *ResourceStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceStatus. +func (in *ResourceStatus) DeepCopy() *ResourceStatus { + if in == nil { + return nil + } + out := new(ResourceStatus) + in.DeepCopyInto(out) + return out +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0a90fc0 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/api7/aisix-controller + +go 1.26.0 + +require ( + k8s.io/apiextensions-apiserver v0.36.1 + k8s.io/apimachinery v0.36.1 + sigs.k8s.io/controller-runtime v0.24.1 + sigs.k8s.io/yaml v1.6.0 +) + +require ( + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/text v0.33.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cbaef40 --- /dev/null +++ b/go.sum @@ -0,0 +1,97 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= +github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= +k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= +k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks= +k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8= +k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= +k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt new file mode 100644 index 0000000..60c2827 --- /dev/null +++ b/hack/boilerplate.go.txt @@ -0,0 +1,15 @@ +/* +Copyright 2026 API7.ai, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ From 662f10a1f28be5453015b31dba44d2d6ef792271 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:10:41 +0545 Subject: [PATCH 02/14] build: vendor the strict resource schemas of aisix v1.9.0 --- hack/sync-schemas.sh | 21 + third_party/aisix/VERSION | 1 + .../aisix/schemas/a2a_agent.schema.json | 188 ++ third_party/aisix/schemas/api_key.schema.json | 394 ++++ third_party/aisix/schemas/budget.schema.json | 238 ++ .../aisix/schemas/budget_policy.schema.json | 36 + .../aisix/schemas/cache_policy.schema.json | 176 ++ .../aisix/schemas/claim_mapping.schema.json | 121 + .../aisix/schemas/embedding.schema.json | 23 + .../aisix/schemas/ensemble.schema.json | 175 ++ .../aisix/schemas/guardrail.schema.json | 1989 +++++++++++++++++ .../schemas/guardrail_attachment.schema.json | 64 + .../schemas/mcp_auth_settings.schema.json | 75 + .../aisix/schemas/mcp_policy.schema.json | 149 ++ .../aisix/schemas/mcp_server.schema.json | 525 +++++ third_party/aisix/schemas/model.schema.json | 1540 +++++++++++++ .../observability_exporter.schema.json | 645 ++++++ .../aisix/schemas/oidc_provider.schema.json | 136 ++ .../schemas/passthrough_route.schema.json | 570 +++++ third_party/aisix/schemas/pricing.schema.json | 32 + .../aisix/schemas/provider_key.schema.json | 401 ++++ .../aisix/schemas/rate_limit.schema.json | 71 + .../schemas/rate_limit_policy.schema.json | 467 ++++ third_party/aisix/schemas/routing.schema.json | 288 +++ .../aisix/schemas/semantic.schema.json | 484 ++++ third_party/aisix/schemas/team.schema.json | 29 + third_party/aisix/schemas/user.schema.json | 29 + 27 files changed, 8867 insertions(+) create mode 100755 hack/sync-schemas.sh create mode 100644 third_party/aisix/VERSION create mode 100644 third_party/aisix/schemas/a2a_agent.schema.json create mode 100644 third_party/aisix/schemas/api_key.schema.json create mode 100644 third_party/aisix/schemas/budget.schema.json create mode 100644 third_party/aisix/schemas/budget_policy.schema.json create mode 100644 third_party/aisix/schemas/cache_policy.schema.json create mode 100644 third_party/aisix/schemas/claim_mapping.schema.json create mode 100644 third_party/aisix/schemas/embedding.schema.json create mode 100644 third_party/aisix/schemas/ensemble.schema.json create mode 100644 third_party/aisix/schemas/guardrail.schema.json create mode 100644 third_party/aisix/schemas/guardrail_attachment.schema.json create mode 100644 third_party/aisix/schemas/mcp_auth_settings.schema.json create mode 100644 third_party/aisix/schemas/mcp_policy.schema.json create mode 100644 third_party/aisix/schemas/mcp_server.schema.json create mode 100644 third_party/aisix/schemas/model.schema.json create mode 100644 third_party/aisix/schemas/observability_exporter.schema.json create mode 100644 third_party/aisix/schemas/oidc_provider.schema.json create mode 100644 third_party/aisix/schemas/passthrough_route.schema.json create mode 100644 third_party/aisix/schemas/pricing.schema.json create mode 100644 third_party/aisix/schemas/provider_key.schema.json create mode 100644 third_party/aisix/schemas/rate_limit.schema.json create mode 100644 third_party/aisix/schemas/rate_limit_policy.schema.json create mode 100644 third_party/aisix/schemas/routing.schema.json create mode 100644 third_party/aisix/schemas/semantic.schema.json create mode 100644 third_party/aisix/schemas/team.schema.json create mode 100644 third_party/aisix/schemas/user.schema.json diff --git a/hack/sync-schemas.sh b/hack/sync-schemas.sh new file mode 100755 index 0000000..0f998a0 --- /dev/null +++ b/hack/sync-schemas.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Vendor the strict resource schemas of one AISIX release. +# +# hack/sync-schemas.sh v1.9.0 +set -euo pipefail +cd "$(dirname "$0")/.." + +TAG=${1:?usage: hack/sync-schemas.sh } +DEST=third_party/aisix +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +curl -fsSL "https://codeload.github.com/api7/aisix/tar.gz/refs/tags/${TAG}" | tar -xz -C "$TMP" +SRC=$(find "$TMP" -maxdepth 3 -type d -path '*/schemas/resources' | head -1) +[ -n "$SRC" ] || { echo "no schemas/resources in api7/aisix@${TAG}" >&2; exit 1; } + +rm -rf "$DEST/schemas" +mkdir -p "$DEST/schemas" +cp "$SRC"/*.schema.json "$DEST/schemas/" +echo "$TAG" > "$DEST/VERSION" +echo "vendored $(ls "$DEST/schemas" | wc -l | tr -d ' ') schemas from api7/aisix@${TAG}" diff --git a/third_party/aisix/VERSION b/third_party/aisix/VERSION new file mode 100644 index 0000000..295e37c --- /dev/null +++ b/third_party/aisix/VERSION @@ -0,0 +1 @@ +v1.9.0 diff --git a/third_party/aisix/schemas/a2a_agent.schema.json b/third_party/aisix/schemas/a2a_agent.schema.json new file mode 100644 index 0000000..b05d1f3 --- /dev/null +++ b/third_party/aisix/schemas/a2a_agent.schema.json @@ -0,0 +1,188 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "auth_type": { + "const": "bearer" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "secret": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret" + ] + } + }, + { + "if": { + "properties": { + "auth_type": { + "const": "api_key" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "secret": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "name" + ], + "title": "name" + }, + { + "required": [ + "display_name" + ], + "title": "display_name" + } + ], + "definitions": { + "A2aAuthType": { + "description": "How the gateway authenticates to an upstream A2A agent.", + "oneOf": [ + { + "description": "No authentication; the agent is reached as-is.", + "enum": [ + "none" + ], + "title": "No authentication", + "type": "string" + }, + { + "description": "Bearer token authentication. The token is supplied in `secret` and sent as `Authorization: Bearer `.", + "enum": [ + "bearer" + ], + "title": "Bearer token", + "type": "string" + }, + { + "description": "API key authentication. The key is supplied in `secret` and sent as an `x-api-key: ` header on every upstream request.", + "enum": [ + "api_key" + ], + "title": "API key", + "type": "string" + } + ] + }, + "A2aProtocolVersion": { + "description": "The A2A wire-format version pinned for an upstream agent.", + "oneOf": [ + { + "description": "A2A 1.0 wire format with protobuf-JSON envelopes and PascalCase methods.", + "enum": [ + "1.0" + ], + "title": "A2A 1.0", + "type": "string" + }, + { + "description": "A2A 0.3 wire format with `kind`-discriminated JSON-RPC objects.", + "enum": [ + "0.3" + ], + "title": "A2A 0.3", + "type": "string" + } + ] + } + }, + "properties": { + "auth_type": { + "allOf": [ + { + "$ref": "#/definitions/A2aAuthType" + } + ], + "default": "none", + "description": "How the gateway authenticates to the upstream agent. The credential is held by the gateway and is not exposed to the calling client. The one case where the agent sees a caller-supplied credential instead is when `forward_client_headers` names the slot this `auth_type` fills, which substitutes the caller's own value for the gateway's rather than sending both." + }, + "display_name": { + "description": "Accepted as an alternative spelling of `name`. Provide the label under exactly one of the two names.", + "minLength": 1, + "pattern": "^[^/?#%\\s\\x00-\\x1f\\x7f€-Ÿ]+$", + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether this agent is active. When `false`, it is not served and cannot be reached.", + "type": "boolean" + }, + "forward_client_headers": { + "description": "Inbound client headers forwarded to this agent, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to the agent-card fetch at `/a2a//.well-known/agent-card.json` as well as to every JSON-RPC method served at `/a2a/`, so an agent receives them on `message/send`, `message/stream` and every task operation alike.\n\nA header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded.\n\nA header named here reaches the agent whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer`, `x-api-key` for `api_key` — hands the agent the caller's own credential in place of the gateway's, never both. That is what lets an internal agent that already authorizes on the end user's `Authorization` keep doing so unchanged. An agent that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and `a2a-version`, which is the gateway's own announcement of the wire version pinned in `protocol_version` and which a caller's value would override.", + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "description": "Operator-facing label, unique within the gateway. It is the path segment under which the agent is exposed to callers as `/a2a/`, so it must be a single non-empty URL path segment. The name is interpolated into the advertised agent-card URL without percent-encoding, so `/`, `?`, `#`, `%` and whitespace are rejected: `a?b` would advertise a URL whose path is just `/a2a/a`, and the lookup is an exact match on the stored name.", + "minLength": 1, + "pattern": "^[^/?#%\\s\\x00-\\x1f\\x7f€-Ÿ]+$", + "type": "string" + }, + "protocol_version": { + "allOf": [ + { + "$ref": "#/definitions/A2aProtocolVersion" + } + ], + "default": "1.0", + "description": "The A2A wire-format version this agent speaks. AISIX announces it to the agent in the `A2A-Version` header on every request, so it must match what the agent actually serves: an agent reads an absent or mismatched version as a protocol error and rejects the call." + }, + "secret": { + "description": "Credential AISIX uses to authenticate to the upstream agent. For `bearer`, AISIX sends it as `Authorization: Bearer `; for `api_key`, AISIX sends it as `x-api-key: `. Leave unset for `none`.", + "type": [ + "string", + "null" + ] + }, + "timeout_ms": { + "description": "Maximum time, in milliseconds, to wait for a single upstream operation, including fetching the agent card or invoking the agent. When omitted, AISIX applies a built-in default.", + "format": "uint64", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "url": { + "description": "The upstream agent's A2A service endpoint, such as `https://agents.example.com/a2a`, where AISIX sends JSON-RPC 2.0 requests over HTTP. AISIX looks for the agent card at the well-known path under this URL's own path first, then under its origin, so both an agent that owns its domain and one published under a path prefix are reachable without extra configuration.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "A2aAgent", + "type": "object" +} diff --git a/third_party/aisix/schemas/api_key.schema.json b/third_party/aisix/schemas/api_key.schema.json new file mode 100644 index 0000000..a6d8fad --- /dev/null +++ b/third_party/aisix/schemas/api_key.schema.json @@ -0,0 +1,394 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "mcp_rate_limits_by_id": { + "type": "object" + } + }, + "required": [ + "mcp_rate_limits_by_id" + ] + }, + "then": { + "required": [ + "mcp_rate_limits" + ] + } + } + ], + "definitions": { + "McpAccess": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "deny_ids": { + "type": "array" + } + }, + "required": [ + "deny_ids" + ] + }, + "then": { + "required": [ + "deny" + ] + } + } + ], + "description": "The API key's own layer of the MCP tool ACL, the same `allow`/`deny` shape an MCP access policy carries. Present means the key constrains its grant; omitted means the key adds no constraint of its own and takes whatever the environment and team layers leave.", + "properties": { + "allow": { + "default": [], + "description": "Namespaced `__` patterns this key allows, intersected with the environment and team layers. Same single-`*` glob matching a policy's `allow` uses; an empty list leaves the key no MCP access, and `[\"*\"]` narrows nothing (useful with `deny` alone).\n\nRequired on the write path and defaulted by the runtime loader, for the reason given on [`McpPolicy::allow`]. Read only when `allow_ids` is absent; ignored entirely when it is present.", + "items": { + "type": "string" + }, + "type": "array" + }, + "allow_ids": { + "description": "This key's allow side written by MCP server resource id instead of by server name, so renaming a server does not change what the key may reach. Present — including as an empty array — it is authoritative and `allow` is ignored; an empty array therefore allows nothing. Absent or `null`, the key falls back to `allow`.\n\nIt cannot express \"every server\": each entry names one server exactly and `server_id` is never a glob, so an enumeration of the servers registered today silently fails to cover one registered tomorrow. To allow (or deny) every server, present and future, leave this absent and use the name form's `\"*\"`.", + "items": { + "$ref": "#/definitions/McpToolRef" + }, + "type": [ + "array", + "null" + ] + }, + "deny": { + "description": "Namespaced `__` patterns subtracted from this key's effective grant, using the same single-`*` glob matching as `allow`. Read only when `deny_ids` is absent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "deny_ids": { + "description": "This key's deny side written by MCP server resource id instead of by server name. Present — including as an empty array — it is authoritative and `deny` is ignored; an empty array subtracts nothing. Absent or `null`, the key falls back to `deny`.\n\nIt cannot express \"every server\": each entry names one server exactly and `server_id` is never a glob, so an enumeration of the servers registered today silently fails to cover one registered tomorrow. To allow (or deny) every server, present and future, leave this absent and use the name form's `\"*\"`.", + "items": { + "$ref": "#/definitions/McpToolRef" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "allow" + ], + "type": "object" + }, + "McpRateLimit": { + "additionalProperties": false, + "description": "Limits for one API key's calls to one MCP server (`ApiKey::mcp_rate_limits`).\n\nCarries only the request-count dimensions of [`RateLimit`]: an MCP `tools/call` commits no tokens, so a token-rate cap here would never be consumed — the shape leaves `tpm`/`tpd` out rather than accepting a knob that is silently inert.", + "properties": { + "concurrency": { + "description": "Max concurrent in-flight tool calls.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpd": { + "description": "Tool calls per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rph": { + "description": "Tool calls per 3,600-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpm": { + "description": "Tool calls per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rps": { + "description": "Tool calls per 1-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "McpToolRef": { + "additionalProperties": false, + "description": "One tool on one MCP server, the server named by its resource id and the tool by name.\n\nThe tool half is matched with the same single-`*` glob rule the name form uses, so `tool: \"*\"` is the id spelling of `__*`. The server half is an exact id: it is compared against the id of the server the addressed tool actually belongs to, never glob-matched, so a server whose *name* happens to contain a `*` cannot widen a grant.\n\nBoth halves are required on the WRITE path and defaulted by the runtime loader — the strict schemas add them to this definition's `required`, the types do not. An entry these were required of at the type level would fail to deserialize, and the loader skips a row it cannot deserialize whole: one malformed entry in one `allow_ids` array would stop the entire `api_key` from authenticating any traffic at all, not merely lose it MCP access. Defaulted, the malformed entry matches nothing instead — an empty `server_id` names no registered server, and an empty `tool` glob covers no tool name the gateway exposes.", + "properties": { + "server_id": { + "description": "Resource id of the registered MCP server (`mcp_servers/`) this entry refers to. An id matching no registered server refers to nothing: the entry never matches, and the other entries are unaffected.", + "minLength": 1, + "type": "string" + }, + "tool": { + "description": "Tool on that server, matched as a single-`*` glob against the bare tool name — the part after the `__` namespace prefix. `\"*\"` covers every tool the server exposes.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "server_id", + "tool" + ], + "type": "object" + }, + "RateLimit": { + "additionalProperties": false, + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpd": { + "description": "Requests per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rph": { + "description": "Requests per 3,600-second window. There is no per-hour token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpm": { + "description": "Requests per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rps": { + "description": "Requests per 1-second window. There is no per-second token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "tpd": { + "description": "Tokens per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "tpm": { + "description": "Tokens per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "properties": { + "allowed_agents": { + "description": "A2A agents this key may reach, named by their registered names. Entries are matched as single-`*` globs, mirroring `allowed_models`: `\"*\"` grants every agent and an entry without a `*` matches one agent exactly. When omitted, set to `null`, or set to an empty list, the key has no A2A agent access — access is granted explicitly.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "allowed_model_ids": { + "description": "Models this key may use, named by resource id rather than by name, so renaming a model does not change what this key may reach. Present — including as an empty array — it is authoritative and `allowed_models` is ignored; each id is resolved against the current models in the snapshot and the resolved name is matched with the same single-`*` glob rule. An id naming a wildcard model therefore grants every name that model's pattern covers — including a name an exact-match model of its own serves, which is how the name form behaves too. An id matching no model grants nothing.\n\nSet to `null` it means the same as omitted: the key falls back to `allowed_models`. A producer must therefore write `[]`, never `null`, for a key that is meant to grant no model — the two are opposite grants, and an empty list is the one that is authoritative.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "allowed_models": { + "default": [], + "description": "Model names this key may use, matched as single-`*` globs. Read only when `allowed_model_ids` is absent; ignored entirely when it is present. When both are omitted the key may use no model — model access is granted explicitly.", + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_routes": { + "description": "Passthrough routes this key may use, named by their registered names. Entries are matched as single-`*` globs, mirroring `allowed_models`: `\"*\"` grants every route and an entry without a `*` matches one route exactly. When omitted, set to `null`, or set to an empty list, the key may use no passthrough route — access is granted explicitly.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "disabled": { + "description": "Administratively disabled. A disabled key is rejected with `401` until it is enabled again; the key itself is preserved. Treated as `false` when omitted.", + "type": "boolean" + }, + "display_name": { + "description": "Operator-facing label for this key, as shown in the dashboard. Read by the `${request.api_key.name}` header template (AISIX-Cloud#1112) and as the `api_key_name` metric label; never used for authentication or routing.", + "type": [ + "string", + "null" + ] + }, + "expires_at": { + "description": "RFC 3339 timestamp after which the key stops authenticating. Requests presenting an expired key are rejected with `401`. When omitted or set to `null`, the key never expires.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "jwt_provider": { + "description": "Name of the `oidc_providers` entry permitted to assert this key's `jwt_subject`. A subject is only ever resolved for the trust provider named here, so a second trusted provider cannot mint a token impersonating this provider's identity of the same name. Required whenever `jwt_subject` is set; ignored otherwise.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "jwt_subject": { + "description": "External identity bound to this key for JWT authentication. When a request presents a valid JWT issued by the `oidc_providers` entry named in `jwt_provider`, the value of that provider's `identity_claim` selects the key whose `jwt_subject` equals it, and the request proceeds with this key's permissions, rate limits, and budget. The `(jwt_provider, jwt_subject)` pair is unique within the environment. When omitted, the key is never selected by JWT authentication.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "key_hash": { + "description": "SHA-256 hexadecimal hash of the plaintext bearer. The proxy hashes incoming bearer tokens before lookup.", + "minLength": 1, + "type": "string" + }, + "mcp_access": { + "anyOf": [ + { + "$ref": "#/definitions/McpAccess" + }, + { + "type": "null" + } + ], + "description": "This key's own layer of the MCP tool ACL, as namespaced `__` glob patterns, or as `allow_ids` / `deny_ids` entries naming the server by resource id. It is intersected with the environment and team MCP access policies: every present layer must allow a tool and no layer may deny it. When omitted the key adds no constraint of its own — but with no layer present anywhere the grant is empty, so MCP access is always granted explicitly." + }, + "mcp_rate_limits": { + "additionalProperties": { + "$ref": "#/definitions/McpRateLimit" + }, + "description": "Per-MCP-server limits for this key, keyed by the registered MCP server name — the `` half of the `__` names the gateway exposes. A `tools/call` is metered against the entry for the server it targets **and** the key's own `rate_limit`, each in its own counter, so a burst against one server never consumes another's budget. A server with no entry here is bounded by `rate_limit` alone. Only tool calls are metered; the `initialize` / `tools/list` handshake is not.\n\nRead only when `mcp_rate_limits_by_id` is absent; ignored entirely when it is present.", + "type": [ + "object", + "null" + ] + }, + "mcp_rate_limits_by_id": { + "additionalProperties": { + "$ref": "#/definitions/McpRateLimit" + }, + "description": "The same per-server limits, keyed by the MCP server's resource id (`mcp_servers/`) rather than by its name, so renaming a server does not detach the limit that was set for it.\n\nPresent — including as an empty object — it is authoritative and `mcp_rate_limits` is ignored; an empty object therefore leaves every server bounded by `rate_limit` alone. A key naming no registered server imposes nothing, and the other entries are unaffected. Set to `null` it means the same as omitted: the key falls back to `mcp_rate_limits`.\n\nEach key names one server exactly; there is no \"every server\" key, which is the same as today — a server with no entry is bounded by `rate_limit` alone.", + "type": [ + "object", + "null" + ] + }, + "rate_limit": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimit" + }, + { + "type": "null" + } + ], + "description": "Request, token, and concurrency limits for this key." + }, + "tags": { + "additionalProperties": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "description": "Cost-attribution tags, as tag key to tag value — for example `{\"department\": \"rnd\", \"subsidiary\": \"emea\"}`. Every request this key authenticates is attributed to one value per tag key, resolved when the request is made with the nearest setting winning: this key's own value, else the value in the `tags` of the team `team_id` names, else the value in the `tags` of the member `user_id` names. Different keys combine, so a request can carry tags from all three. A request authenticated by a JWT takes the value its identity provider's `tag_claims` reads from the verified token ahead of all three; nothing else in a request sets a tag. Keys match `^[a-z][a-z0-9_]{0,62}$`, values are 1 to 128 characters, and at most 32 entries are allowed. Omitted or empty means the key adds no tags of its own.", + "maxProperties": 32, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]{0,62}$" + }, + "type": "object" + }, + "team_id": { + "description": "Team this API key belongs to. Used for matching team-scope rate limit policies.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Org member who owns this key. Used for matching member-scope rate limit policies.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "user_name": { + "description": "Readable display name of the owning member. Used only for telemetry labels alongside `user_id`; never used for authentication or routing. Read only when no `users` document exists for `user_id`; when one does, its name is used instead. When neither provides a name, telemetry reports the user name as `\"unknown\"`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key_hash" + ], + "title": "ApiKey", + "type": "object" +} diff --git a/third_party/aisix/schemas/budget.schema.json b/third_party/aisix/schemas/budget.schema.json new file mode 100644 index 0000000..7126c97 --- /dev/null +++ b/third_party/aisix/schemas/budget.schema.json @@ -0,0 +1,238 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "allOf": [ + { + "else": { + "not": { + "required": [ + "tag" + ] + }, + "required": [ + "scope_ref" + ] + }, + "if": { + "properties": { + "scope": { + "const": "tag" + } + } + }, + "then": { + "not": { + "required": [ + "scope_ref" + ] + }, + "required": [ + "tag" + ] + } + }, + { + "else": { + "not": { + "required": [ + "exhausted_members" + ] + } + }, + "if": { + "properties": { + "scope": { + "const": "team_member" + } + } + } + } + ], + "definitions": { + "BudgetExhausted": { + "additionalProperties": false, + "description": "The budget is exhausted for everything it applies to.", + "properties": { + "period_start": { + "description": "Start of the period the budget was exhausted in, as an RFC 3339 timestamp. The block holds only while this is the start of the current period, so it lifts at the next period boundary on its own. Omitted, the block holds in every period.", + "type": "string" + }, + "reason": { + "allOf": [ + { + "$ref": "#/definitions/BudgetReason" + } + ], + "description": "What a rejected request is told." + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "BudgetMemberExhausted": { + "additionalProperties": false, + "description": "One member's share of a `team_member` budget is exhausted.", + "properties": { + "period_start": { + "description": "Start of the period the member exhausted their share in, as an RFC 3339 timestamp. The block holds only while this is the start of the current period.", + "type": "string" + }, + "reason": { + "allOf": [ + { + "$ref": "#/definitions/BudgetReason" + } + ], + "description": "What a rejected request is told." + } + }, + "required": [ + "period_start", + "reason" + ], + "type": "object" + }, + "BudgetPeriod": { + "enum": [ + "day", + "week", + "month" + ], + "type": "string" + }, + "BudgetReason": { + "additionalProperties": false, + "description": "The detail a request rejected by an exhausted budget is answered with. Amounts are decimal strings in the organization's currency.", + "properties": { + "code": { + "description": "Error code. The response always reports `budget_exceeded`.", + "type": "string" + }, + "limit_usd": { + "description": "The budget's limit.", + "type": "string" + }, + "message": { + "description": "Human-readable message returned to the caller.", + "type": "string" + }, + "period": { + "description": "Accounting period.", + "type": "string" + }, + "period_resets_at": { + "description": "Ignored: the gateway computes when the period resets." + }, + "remaining_usd": { + "description": "Limit minus spend; zero or negative.", + "type": "string" + }, + "retry_after_seconds": { + "description": "Ignored: the gateway computes how long until the period resets." + }, + "scope": { + "description": "Scope of the exhausted budget.", + "type": "string" + }, + "scope_ref": { + "description": "Id of the capped resource, or the tag for scope `tag`.", + "type": "string" + }, + "spent_usd": { + "description": "Spend in the period when the budget was exhausted.", + "type": "string" + }, + "type": { + "description": "Error type. The response always reports `billing_error`.", + "type": "string" + } + }, + "type": "object" + }, + "BudgetScope": { + "enum": [ + "org", + "environment", + "api_key", + "member", + "team", + "team_member", + "tag", + "provider_key" + ], + "type": "string" + }, + "BudgetTag": { + "additionalProperties": false, + "properties": { + "key": { + "description": "Tag key.", + "type": "string" + }, + "value": { + "description": "Tag value.", + "type": "string" + } + }, + "required": [ + "key", + "value" + ], + "type": "object" + } + }, + "description": "A hard-stop spend cap that applies in this environment, and whether it is currently exhausted.\n\nThe document's id is the budget's id. Only hard-stop budgets are present: a budget that only warns never blocks a request, so it has no document.", + "properties": { + "exhausted": { + "allOf": [ + { + "$ref": "#/definitions/BudgetExhausted" + } + ], + "description": "Set while the budget is exhausted for everything it applies to. For scope `team_member` it applies to every member of the team." + }, + "exhausted_members": { + "additionalProperties": { + "$ref": "#/definitions/BudgetMemberExhausted" + }, + "description": "Members of the team who have exhausted their own share of a `team_member` budget, keyed by member id. Read only for scope `team_member`.", + "type": "object" + }, + "period": { + "allOf": [ + { + "$ref": "#/definitions/BudgetPeriod" + } + ], + "description": "Accounting period. Periods follow the UTC calendar: a day starts at 00:00 UTC, a week on Monday at 00:00 UTC, and a month on the 1st at 00:00 UTC." + }, + "scope": { + "allOf": [ + { + "$ref": "#/definitions/BudgetScope" + } + ], + "description": "What the budget caps.\n\n- `org`, `environment`: every API key in this environment. - `api_key`: the API key whose id is `scope_ref`. - `member`: every API key whose `user_id` is `scope_ref`. - `team`: every API key whose `team_id` is `scope_ref`. - `team_member`: every API key whose `team_id` is `scope_ref` and that also names a member; each member is capped separately. - `tag`: every request whose effective value for `tag.key` is `tag.value`. That value is what the identity provider's `tag_claims` reads from the token, else the API key's, else its team's, else its member's, so requests on one API key can differ. - `provider_key`: every upstream call made with the provider key whose id is `scope_ref`." + }, + "scope_ref": { + "description": "Id of the capped resource. Present for every scope except `tag`.", + "type": "string" + }, + "tag": { + "allOf": [ + { + "$ref": "#/definitions/BudgetTag" + } + ], + "description": "The capped tag. Present only for scope `tag`." + } + }, + "required": [ + "period", + "scope" + ], + "title": "Budget", + "type": "object" +} diff --git a/third_party/aisix/schemas/budget_policy.schema.json b/third_party/aisix/schemas/budget_policy.schema.json new file mode 100644 index 0000000..bb39dd1 --- /dev/null +++ b/third_party/aisix/schemas/budget_policy.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "BudgetFailMode": { + "enum": [ + "sticky", + "closed" + ], + "type": "string" + } + }, + "description": "What the gateway does with budgeted traffic while it cannot deliver usage to the control plane, whose spend counters are what mark a budget exhausted.\n\nAt most one document exists per environment, keyed by the environment id. Without it the gateway behaves as `sticky`.", + "properties": { + "closed_grace_seconds": { + "description": "How long usage delivery may fail before `closed` starts rejecting budgeted traffic, in seconds.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "fail_mode": { + "allOf": [ + { + "$ref": "#/definitions/BudgetFailMode" + } + ], + "description": "`sticky` keeps enforcing the last exhausted state the gateway received and lets everything else through. `closed` additionally rejects budgeted traffic once usage delivery has failed for `closed_grace_seconds`: a request from an API key that at least one budget applies to is answered `503 budget_unverified`, and an upstream whose provider key has a budget is not used." + } + }, + "required": [ + "closed_grace_seconds", + "fail_mode" + ], + "title": "BudgetPolicy", + "type": "object" +} diff --git a/third_party/aisix/schemas/cache_policy.schema.json b/third_party/aisix/schemas/cache_policy.schema.json new file mode 100644 index 0000000..6da9620 --- /dev/null +++ b/third_party/aisix/schemas/cache_policy.schema.json @@ -0,0 +1,176 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "CacheBackend": { + "description": "Cache backend choice for requests matched by a cache policy. `redis` requires `cache.redis`. Otherwise matching requests are not cached.", + "enum": [ + "memory", + "redis" + ], + "type": "string" + }, + "CacheScope": { + "description": "Sharing boundary for cache entries created under a policy. Applies to both matching layers: an entry written in one scope bucket is never served to a request in another.", + "oneOf": [ + { + "description": "Entries are private to the API key that created them. The safe default: one caller's answers are never replayed to another.", + "enum": [ + "api_key" + ], + "type": "string" + }, + { + "description": "Entries are shared by every API key in the environment. Pick this for shared-knowledge traffic (FAQ, documentation Q&A) where cross-caller reuse is the point.", + "enum": [ + "env" + ], + "type": "string" + } + ] + }, + "SemanticCacheConfig": { + "allOf": [ + { + "anyOf": [ + { + "properties": { + "embedding_model": { + "type": "string" + } + }, + "required": [ + "embedding_model" + ] + }, + { + "properties": { + "embedding_model_id": { + "type": "string" + } + }, + "required": [ + "embedding_model_id" + ] + } + ] + } + ], + "description": "Embedding-similarity matching for a cache policy. When present, a request that misses the exact layer is embedded and compared against stored entries; the nearest entry at or above `threshold` cosine similarity is served. Only requests whose messages are entirely text participate — requests containing images or audio never match by similarity.\n\nOn `backend: redis`, similarity matching requires a Redis server with vector search (Redis 8 or later, or the search module) in `single` or `sentinel` mode; without it — or in `cluster` mode — the policy keeps serving exact matches only and a warning is logged.", + "properties": { + "embedding_model": { + "description": "Name of the `embedding` model used to embed requests. The model must exist in the same environment and carry an `embedding` block; its `dimensions` value fixes the vector size for this policy's entries. Read only when `embedding_model_id` is absent.", + "minLength": 1, + "type": "string" + }, + "embedding_model_id": { + "description": "Resource id of the `embedding` model used to embed requests. Present, it is authoritative and `embedding_model` is ignored: the id is resolved against the models in the current configuration, so renaming that model keeps this policy pointing at it with no edit to this document. An id resolving to no model leaves similarity matching off for the policy, exactly as an `embedding_model` naming no model does.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "embedding_timeout_ms": { + "description": "Per-call deadline for the embedding request in milliseconds. `0` or absent disables the embedding-specific deadline. On timeout the request proceeds to the upstream uncached.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "max_entries": { + "default": 1000, + "description": "Upper bound on stored entries for this policy on the `memory` backend; the oldest entry is evicted first. Shared backends bound growth by TTL instead and ignore this value. The ceiling keeps the per-request similarity scan and the per-policy vector memory bounded; workloads needing more entries belong on a shared backend.", + "format": "uint32", + "maximum": 10000.0, + "minimum": 1.0, + "type": "integer" + }, + "threshold": { + "description": "Minimum cosine similarity for a stored entry to be served, in `[0, 1]`. Higher is stricter. Values below `0.9` noticeably increase wrong-answer risk for most embedding models.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "threshold" + ], + "type": "object" + } + }, + "description": "A prompt-response cache rule. Requests covered by an enabled policy are served from cache when an identical request was answered before (exact matching), and — when `semantic` is configured — when a sufficiently similar request was.", + "properties": { + "applies_to": { + "default": "all", + "description": "Free-form scope. Supports `\"all\"`, `\"model:\"`, and `\"api_key:\"`. Read only when `applies_to_model_id` is absent. See `parsed_applies_to`.", + "maxLength": 255, + "minLength": 1, + "type": "string" + }, + "applies_to_model_id": { + "description": "Scopes the policy to one model, named by resource id. Present, it is authoritative and `applies_to` is ignored entirely — the policy applies to the model this id resolves to and to nothing else. The id is resolved against the models in the current configuration, so renaming that model keeps the policy scoped to it with no edit to this document. An id resolving to no model makes the policy match nothing, exactly as `\"model:\"` naming no model does.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "backend": { + "allOf": [ + { + "$ref": "#/definitions/CacheBackend" + } + ], + "default": "memory", + "description": "Cache backend used for matching requests." + }, + "enabled": { + "default": true, + "description": "When false, the cache gate skips this policy. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and cache headers.", + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "purge_generation": { + "default": 0, + "description": "Invalidation counter. Entries are readable only while their stored generation matches; a purge bumps this value, making every earlier entry unreachable at once. Managed by the purge operation — not set directly. Full-document updates must carry the current value forward: writing a lower (or omitted, i.e. `0`) value re-exposes entries stored under that earlier generation until their TTL passes.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "scope": { + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "api_key", + "description": "Sharing boundary for entries created under this policy: `api_key` (default) keeps entries private to the caller that created them; `env` shares them across the environment." + }, + "semantic": { + "allOf": [ + { + "$ref": "#/definitions/SemanticCacheConfig" + } + ], + "description": "Embedding-similarity matching. Absent: the policy matches exactly-identical requests only." + }, + "ttl_seconds": { + "default": 3600, + "description": "Cache entry TTL in seconds.", + "format": "uint32", + "maximum": 604800.0, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "name" + ], + "title": "CachePolicy", + "type": "object" +} diff --git a/third_party/aisix/schemas/claim_mapping.schema.json b/third_party/aisix/schemas/claim_mapping.schema.json new file mode 100644 index 0000000..42aa996 --- /dev/null +++ b/third_party/aisix/schemas/claim_mapping.schema.json @@ -0,0 +1,121 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "ClaimMatch": { + "additionalProperties": false, + "description": "One claim condition. A mapping matches only when every condition holds (logical AND); within one condition, `values` are alternatives (logical OR).", + "properties": { + "claim": { + "description": "Claim to inspect, read as one claim of exactly this name, then with dots traversing nested objects (for example `realm_access.roles`). A missing claim never matches.", + "minLength": 1, + "type": "string" + }, + "op": { + "allOf": [ + { + "$ref": "#/definitions/ClaimMatchOp" + } + ], + "description": "Comparison operator. A claim whose JSON type does not fit the operator (an array for `exact`, a string for `contains`) never matches — mistyped claims deny rather than surprise." + }, + "values": { + "description": "Accepted values; the condition holds when any one matches.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "claim", + "op", + "values" + ], + "type": "object" + }, + "ClaimMatchOp": { + "description": "How one [`ClaimMatch`] compares the claim's value against `values`.", + "oneOf": [ + { + "description": "The claim must be a string equal to one of `values`. An array claim never matches `exact`.", + "enum": [ + "exact" + ], + "type": "string" + }, + { + "description": "The claim must be an array containing one of `values` among its string items; non-string items are ignored, matching the `bound_claims` array semantics. A string claim never matches `contains`.", + "enum": [ + "contains" + ], + "type": "string" + } + ] + }, + "ClaimResolve": { + "additionalProperties": false, + "description": "What a matched mapping resolves to. Targets always reference existing resources — a dangling reference rejects the request.", + "properties": { + "api_key_id": { + "description": "Id of the API key the request runs as. The key's model and tool access, rate limits, and budget apply exactly as if the caller had presented the key itself.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "api_key_id" + ], + "type": "object" + } + }, + "properties": { + "enabled": { + "default": true, + "description": "Whether the mapping participates in evaluation. A disabled mapping is kept but skipped. Treated as `true` when omitted.", + "type": "boolean" + }, + "jwt_provider": { + "description": "Name of the OIDC provider whose tokens this mapping applies to. A mapping never matches a token verified by a different provider, so two providers cannot select each other's keys.", + "minLength": 1, + "type": "string" + }, + "match": { + "description": "Claim conditions, all of which must hold for the mapping to match.", + "items": { + "$ref": "#/definitions/ClaimMatch" + }, + "minItems": 1, + "type": "array" + }, + "name": { + "description": "Human-readable mapping name, unique within the environment.", + "minLength": 1, + "type": "string" + }, + "priority": { + "default": 0, + "description": "Evaluation order among the provider's mappings: lower values are evaluated first, ties are broken by `name`. Defaults to 0.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "resolve": { + "allOf": [ + { + "$ref": "#/definitions/ClaimResolve" + } + ], + "description": "The API key a matching token resolves to." + } + }, + "required": [ + "jwt_provider", + "match", + "name", + "resolve" + ], + "title": "ClaimMapping", + "type": "object" +} diff --git a/third_party/aisix/schemas/embedding.schema.json b/third_party/aisix/schemas/embedding.schema.json new file mode 100644 index 0000000..10e5f94 --- /dev/null +++ b/third_party/aisix/schemas/embedding.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "description": "Embedding-modality metadata for a direct Model.", + "properties": { + "dimensions": { + "description": "Output vector dimensionality. Used to validate vectors, key the example-vector cache, and (for endpoints that support it) request a reduced output size.", + "format": "uint32", + "minimum": 1.0, + "type": "integer" + }, + "normalize": { + "default": true, + "description": "Whether the endpoint already returns L2-normalized vectors. When `false`, the gateway normalizes before computing cosine similarity. Defaults to `true`.", + "type": "boolean" + } + }, + "required": [ + "dimensions" + ], + "title": "EmbeddingConfig", + "type": "object" +} diff --git a/third_party/aisix/schemas/ensemble.schema.json b/third_party/aisix/schemas/ensemble.schema.json new file mode 100644 index 0000000..6f9095d --- /dev/null +++ b/third_party/aisix/schemas/ensemble.schema.json @@ -0,0 +1,175 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "Judge": { + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "model": { + "type": "string" + } + }, + "required": [ + "model" + ] + }, + { + "properties": { + "model_id": { + "type": "string" + } + }, + "required": [ + "model_id" + ] + } + ] + } + ], + "description": "The judge model that synthesizes the panel responses into one answer. The judge model is named either by `model` (its display name) or by `model_id` (its resource id); the judge must carry at least one of the two.", + "properties": { + "model": { + "description": "Model alias for the direct model that synthesizes panel responses. Read only when `model_id` is absent.", + "minLength": 1, + "type": "string" + }, + "model_id": { + "description": "Resource id of the direct model that synthesizes panel responses. Present, it is authoritative and `model` is ignored: the id is resolved against the models in the current configuration, so renaming that model keeps the judge pointing at it with no edit to this document. An id resolving to no model is a judge that does not exist, exactly as a `model` naming no model is.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "synthesis_prompt": { + "description": "Override for the built-in synthesis prompt template.", + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "PanelMember": { + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "model": { + "type": "string" + } + }, + "required": [ + "model" + ] + }, + { + "properties": { + "model_id": { + "type": "string" + } + }, + "required": [ + "model_id" + ] + } + ] + } + ], + "description": "One member of an ensemble panel. The member model is named either by `model` (its display name) or by `model_id` (its resource id); a member must carry at least one of the two.", + "properties": { + "model": { + "description": "Model alias for a direct model that receives one panel request. Read only when `model_id` is absent.", + "minLength": 1, + "type": "string" + }, + "model_id": { + "description": "Resource id of the direct model that receives one panel request. Present, it is authoritative and `model` is ignored: the id is resolved against the models in the current configuration, so renaming that model keeps this member pointing at it with no edit to this document. An id resolving to no model is a panel member that does not exist, exactly as a `model` naming no model is.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "seed": { + "description": "Sampling seed for this panel member.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "temperature": { + "description": "Sampling temperature for this panel member. Omit it to keep the request's temperature.", + "format": "float", + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "weight": { + "description": "Reserved for a future voting/quorum strategy. AISIX currently ignores this field.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "properties": { + "judge": { + "allOf": [ + { + "$ref": "#/definitions/Judge" + } + ], + "description": "Direct model that combines successful panel responses." + }, + "min_responses": { + "description": "Minimum successful panel responses required before judge synthesis. When omitted, the gateway requires the smaller of 2 and the panel size.", + "format": "uint32", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "panel": { + "description": "Direct models called concurrently for each ensemble request.", + "items": { + "$ref": "#/definitions/PanelMember" + }, + "minItems": 1, + "type": "array" + }, + "timeout_ms": { + "description": "Per-call upstream deadline applied to each panel member and the judge. Set `0` or omit it to disable the ensemble-level deadline.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "judge", + "panel" + ], + "title": "EnsembleConfig", + "type": "object" +} diff --git a/third_party/aisix/schemas/guardrail.schema.json b/third_party/aisix/schemas/guardrail.schema.json new file mode 100644 index 0000000..3fe8acb --- /dev/null +++ b/third_party/aisix/schemas/guardrail.schema.json @@ -0,0 +1,1989 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "BedrockAWSCredentials": { + "description": "AWS credentials for a Bedrock guardrail.", + "oneOf": [ + { + "additionalProperties": false, + "dependencies": { + "external_id": [ + "role_arn" + ], + "role_session_name": [ + "role_arn" + ] + }, + "properties": { + "access_key_id": { + "description": "AWS access key ID for static Bedrock guardrail credentials.", + "minLength": 1, + "type": "string" + }, + "external_id": { + "description": "External ID the role's trust policy requires. Requires `role_arn`.", + "minLength": 1, + "type": "string" + }, + "kind": { + "description": "Credential mode for explicitly configured AWS access keys.", + "enum": [ + "static" + ], + "type": "string" + }, + "role_arn": { + "description": "ARN of an IAM role to assume with these credentials through `sts:AssumeRole`. The temporary credentials are refreshed before they expire. When the credentials already belong to this role, they are used as they are.", + "minLength": 1, + "type": "string" + }, + "role_session_name": { + "description": "Session name for the assumed role. Requires `role_arn`. When omitted, the gateway generates one.", + "minLength": 1, + "type": "string" + }, + "secret_access_key": { + "description": "AWS secret access key used to authenticate requests to Amazon Bedrock. The gateway does not log the plaintext value.", + "minLength": 1, + "type": "string" + }, + "session_token": { + "description": "AWS session token that accompanies temporary access keys. The gateway does not log the plaintext value.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "access_key_id", + "kind", + "secret_access_key" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A Bedrock API key, sent as a bearer token instead of a SigV4 signature.", + "properties": { + "api_key": { + "description": "Amazon Bedrock API key. The gateway does not log the plaintext value.", + "minLength": 1, + "type": "string" + }, + "kind": { + "description": "Credential mode for an Amazon Bedrock API key.", + "enum": [ + "api_key" + ], + "type": "string" + } + }, + "required": [ + "api_key", + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "dependencies": { + "external_id": [ + "role_arn" + ], + "role_session_name": [ + "role_arn" + ] + }, + "description": "Credentials from the gateway's own AWS environment: environment variables, web identity (for example IRSA on EKS), the ECS container credentials endpoint, or the EC2 instance profile. Temporary credentials are refreshed before they expire.", + "properties": { + "external_id": { + "description": "External ID the role's trust policy requires. Requires `role_arn`.", + "minLength": 1, + "type": "string" + }, + "kind": { + "description": "Credential mode that uses the gateway's own AWS environment.", + "enum": [ + "default_chain" + ], + "type": "string" + }, + "role_arn": { + "description": "ARN of an IAM role to assume with these credentials through `sts:AssumeRole`. The temporary credentials are refreshed before they expire. When the credentials already belong to this role, they are used as they are.", + "minLength": 1, + "type": "string" + }, + "role_session_name": { + "description": "Session name for the assumed role. Requires `role_arn`. When omitted, the gateway generates one.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "BedrockLatencyMode": { + "description": "Per-guardrail latency policy for `kind: \"bedrock\"`. Serial mode waits for the guardrail response; timed mode aborts at `timeout_ms` and applies `fail_open`.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "description": "Latency mode that waits for the Bedrock guardrail response.", + "enum": [ + "serial" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "description": "Latency mode that stops waiting after `timeout_ms`.", + "enum": [ + "timed" + ], + "type": "string" + }, + "timeout_ms": { + "description": "Maximum time in milliseconds to wait for the Bedrock guardrail response.", + "format": "uint32", + "maximum": 5000.0, + "minimum": 100.0, + "type": "integer" + } + }, + "required": [ + "kind", + "timeout_ms" + ], + "type": "object" + } + ] + }, + "GuardrailHookPoint": { + "description": "What part of the request lifecycle a guardrail inspects.", + "oneOf": [ + { + "description": "Run on the request payload before the upstream call.", + "enum": [ + "input" + ], + "type": "string" + }, + { + "description": "Run on the upstream response before the cache write + render.", + "enum": [ + "output" + ], + "type": "string" + }, + { + "description": "Run on both input and output.", + "enum": [ + "both" + ], + "type": "string" + } + ] + }, + "GuardrailInputMessages": { + "description": "Which messages of a request an input guardrail considers — its window. What the guardrail reads inside the window depends on its kind.\n\nIDE and agent clients replay the whole conversation on every call, so a rule that matched once keeps matching for the rest of the session even after the offending message is long past. `LatestTurn` narrows every input hook — the block check and the masking pass alike — to the part of the conversation the model has not answered yet.", + "oneOf": [ + { + "description": "The window is the whole request: system, developer, user, assistant and tool messages alike.", + "enum": [ + "all" + ], + "type": "string" + }, + { + "description": "The window is only the messages after the last assistant message, with system and developer messages excluded — this turn's user messages together with the tool results answering them. That assistant message is looked for only before the request's final message other than a system or developer one, so a trailing assistant prefill stays inside the current turn. For a request carrying no assistant message, the window is the whole request apart from its system and developer messages. Messages the model has already replied to are neither screened nor rewritten.", + "enum": [ + "latest_turn" + ], + "type": "string" + } + ] + }, + "KeywordPattern": { + "description": "Literal or regular-expression pattern used by a keyword guardrail.", + "oneOf": [ + { + "additionalProperties": false, + "description": "Literal string to match.", + "properties": { + "kind": { + "description": "Pattern type for matching the value as plain text.", + "enum": [ + "literal" + ], + "type": "string" + }, + "value": { + "description": "Literal string to match.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Regular expression pattern to match.", + "properties": { + "kind": { + "description": "Pattern type for matching the value as a regular expression.", + "enum": [ + "regex" + ], + "type": "string" + }, + "value": { + "description": "Regular expression pattern to match.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "PiiCustomPattern": { + "additionalProperties": false, + "description": "One operator-supplied regex detector for `kind: \"pii\"`.", + "properties": { + "action": { + "description": "Per-pattern action override. Falls back to the guardrail's `default_action` when omitted.", + "enum": [ + "mask", + "block" + ], + "type": "string" + }, + "name": { + "description": "Detector name surfaced in the mask token (`[_REDACTED]`), telemetry counts, and block reasons. Never the matched value.", + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + "regex": { + "description": "Regular expression AISIX compiles when building the guardrail chain. An invalid pattern makes AISIX log and skip the guardrail.\n\nWhen the expression declares at least one capture group, a `mask` action rewrites only the first capture group of each match and keeps the rest of the match unchanged. Use this to replace a value while preserving its surrounding key or label, for example `\"version\"\\s*:\\s*\"([^\"]*)\"`. Without capture groups, the whole match is rewritten.", + "minLength": 1, + "type": "string" + }, + "replacement": { + "description": "Literal text that replaces the masked span, such as `***`. When omitted, the span is rewritten to `[_REDACTED]`. An empty string removes the span. Only valid when the pattern's effective action is `mask`.", + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "name", + "regex" + ], + "type": "object" + }, + "PiiDetectorConfig": { + "additionalProperties": false, + "description": "One built-in detector selection for `kind: \"pii\"`. The `type` names the detector to enable; `action` optionally overrides the guardrail-level `default_action` for this detector only.", + "properties": { + "action": { + "description": "Per-detector action override. Falls back to the guardrail's `default_action` when omitted.", + "enum": [ + "mask", + "block" + ], + "type": "string" + }, + "type": { + "description": "Built-in detector to enable for this PII guardrail entry.", + "enum": [ + "email", + "china_mobile", + "china_id_card", + "bank_card", + "us_ssn", + "ip_address", + "api_key", + "jwt", + "private_key" + ], + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PresidioEntityConfig": { + "additionalProperties": false, + "description": "One entity selection for `kind: \"presidio\"`. The `type` names a Presidio entity (`EMAIL_ADDRESS`, `PHONE_NUMBER`, `PERSON`, `CREDIT_CARD`, …); `action` optionally overrides the guardrail-level `default_action` for this entity only — the same per-detector shape as `kind: \"pii\"`.", + "properties": { + "action": { + "description": "Per-entity action override. Falls back to the guardrail's `default_action` when omitted.", + "enum": [ + "mask", + "block" + ], + "type": "string" + }, + "type": { + "description": "Presidio entity type, e.g. `EMAIL_ADDRESS`, `PERSON`, `US_SSN`.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "description": "Content policy evaluated before or after upstream calls.", + "oneOf": [ + { + "additionalProperties": false, + "description": "In-process literal/regex blocklist. Always available.", + "properties": { + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for literal and regular expression matching.", + "enum": [ + "keyword" + ], + "type": "string" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "patterns": { + "description": "Blocklist patterns. An empty list is valid and allows every request, equivalent to `enabled: false`.", + "items": { + "$ref": "#/definitions/KeywordPattern" + }, + "type": "array" + } + }, + "required": [ + "kind", + "patterns" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "AWS Bedrock managed guardrail using `ApplyGuardrail` on input, output, or both.", + "properties": { + "aws_credentials": { + "allOf": [ + { + "$ref": "#/definitions/BedrockAWSCredentials" + } + ], + "description": "IAM credentials for Bedrock requests." + }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "guardrail_id": { + "description": "Guardrail identifier issued by the AWS console.", + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + "guardrail_version": { + "description": "Version label: `DRAFT`, `1`, `2`, ...", + "maxLength": 16, + "minLength": 1, + "type": "string" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for Amazon Bedrock Guardrails.", + "enum": [ + "bedrock" + ], + "type": "string" + }, + "latency_mode": { + "allOf": [ + { + "$ref": "#/definitions/BedrockLatencyMode" + } + ], + "description": "Bedrock guardrail latency policy. Timed mode caps wait time with `timeout_ms`." + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), a Bedrock outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", + "type": "boolean" + }, + "region": { + "description": "AWS region for the Bedrock endpoint, such as `us-east-1`.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "aws_credentials", + "guardrail_id", + "guardrail_version", + "kind", + "latency_mode", + "region" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Azure AI Content Safety Prompt Shield. Detects jailbreak and indirect injection attacks via the `/contentsafety/text:shieldPrompt` API.", + "properties": { + "api_key": { + "description": "Azure subscription key sent with the `Ocp-Apim-Subscription-Key` header. Decrypted before projection. Plaintext is held in memory only and is not logged.", + "minLength": 1, + "type": "string" + }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "description": "Azure Cognitive Services resource endpoint, e.g. `https://my-resource.cognitiveservices.azure.com`. The gateway appends `/contentsafety/text:shieldPrompt?api-version=2024-09-01`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for Azure Prompt Shield.", + "enum": [ + "azure_content_safety" + ], + "type": "string" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), an Azure outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", + "type": "boolean" + }, + "timeout_ms": { + "default": 5000, + "description": "HTTP call timeout in milliseconds. A value of `0` triggers the timeout immediately.", + "format": "uint32", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "api_key", + "endpoint", + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Azure AI Content Safety Text Moderation. Category-severity and blocklist moderation via the `/contentsafety/text:analyze` API, on input and/or output, including streaming output.", + "properties": { + "api_key": { + "description": "Azure subscription key sent with the `Ocp-Apim-Subscription-Key` header. Plaintext is held in memory only and is not logged.", + "minLength": 1, + "type": "string" + }, + "blocklist_names": { + "default": [], + "description": "Azure CS blocklist names to match against.", + "items": { + "description": "Azure blocklist name to match against.", + "type": "string" + }, + "type": "array" + }, + "categories": { + "default": [ + "Hate", + "Sexual", + "SelfHarm", + "Violence" + ], + "description": "Categories to analyze.", + "items": { + "description": "Azure content category to analyze.", + "enum": [ + "Hate", + "Sexual", + "SelfHarm", + "Violence" + ], + "type": "string" + }, + "type": "array" + }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "description": "Azure Cognitive Services resource endpoint. The gateway appends `/contentsafety/text:analyze?api-version=2024-09-01`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "halt_on_blocklist_hit": { + "default": false, + "description": "Forwarded to Azure's `haltOnBlocklistHit`.", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for Azure text moderation.", + "enum": [ + "azure_content_safety_text_moderation" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes of model-generated content held back from a streamed response before `on_buffer_exceeded` applies: in `buffer_full` mode, and in `window` mode wherever output is held whole (the whole stream on `/v1/messages` and `/v1/responses`, and tool-call arguments on `/v1/chat/completions`) and no output guardrail in the chain uses `buffer_full`, whose rows alone then set the cap. Counts assistant text, reasoning, and tool-call arguments; SSE and JSON framing is not counted.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled, an Azure outage does not release unscanned model output.", + "type": "boolean" + }, + "output_type": { + "default": "FourSeverityLevels", + "description": "Severity scale used for Azure category scores. The four-level scale returns even severities; the eight-level scale returns every integer severity.", + "enum": [ + "FourSeverityLevels", + "EightSeverityLevels" + ], + "type": "string" + }, + "severity_threshold": { + "default": 2, + "description": "General severity threshold. A category at or above it blocks.", + "format": "uint8", + "maximum": 7.0, + "minimum": 0.0, + "type": "integer" + }, + "severity_threshold_by_category": { + "additionalProperties": { + "description": "Severity threshold for the category key.", + "format": "uint8", + "minimum": 0.0, + "type": "integer" + }, + "default": {}, + "description": "Per-category threshold overrides. These take precedence over the general threshold.", + "type": "object" + }, + "stream_processing_mode": { + "default": "window", + "description": "Streaming output moderation mode: sliding-window incremental release or whole-response hold-back.", + "enum": [ + "window", + "buffer_full" + ], + "type": "string" + }, + "text_source": { + "default": "concatenate_user_content", + "description": "Input-hook text selection. The default scans user messages only; the alternate mode includes all message content. Ignored on the output hook.", + "enum": [ + "concatenate_user_content", + "concatenate_all_content" + ], + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "description": "HTTP call timeout in milliseconds. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", + "format": "uint32", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + }, + "window_overlap_size": { + "default": 256, + "description": "Chars carried between windows so a span split across a boundary is still caught.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "window_size": { + "default": 10000, + "description": "Sliding-window size in characters for window mode.", + "format": "uint32", + "maximum": 10000.0, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "api_key", + "endpoint", + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Aliyun content-safety guardrail. Risk-level moderation via the `TextModerationPlus` action on `green-cip..aliyuncs.com`, on input and/or output, including streaming output.", + "properties": { + "access_key_id": { + "description": "Aliyun AccessKey ID.", + "minLength": 1, + "type": "string" + }, + "access_key_secret": { + "description": "Aliyun AccessKey secret. Decrypted before projection. Plaintext is held in memory only and is not logged. Used to sign the request.", + "minLength": 1, + "type": "string" + }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "default": null, + "description": "Explicit endpoint override as a full URL with no trailing slash. When set, it takes precedence over `region`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for Aliyun text moderation.", + "enum": [ + "aliyun_text_moderation" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes of model-generated content held back from a streamed response before `on_buffer_exceeded` applies: in `buffer_full` mode, and in `window` mode wherever output is held whole (the whole stream on `/v1/messages` and `/v1/responses`, and tool-call arguments on `/v1/chat/completions`) and no output guardrail in the chain uses `buffer_full`, whose rows alone then set the cap. Counts assistant text, reasoning, and tool-call arguments; SSE and JSON framing is not counted.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled, an Aliyun outage does not release unscanned model output.", + "type": "boolean" + }, + "region": { + "description": "Aliyun region the guardrail lives in, e.g. `cn-shanghai`. The gateway builds the endpoint `https://green-cip..aliyuncs.com`.", + "minLength": 1, + "type": "string" + }, + "risk_level_threshold": { + "default": "high", + "description": "Minimum risk level that triggers a block. A returned level at or above this threshold blocks.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "stream_processing_mode": { + "default": "window", + "description": "Streaming output moderation mode: sliding-window incremental release or whole-response hold-back.", + "enum": [ + "window", + "buffer_full" + ], + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "description": "HTTP call timeout in milliseconds. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", + "format": "uint32", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + }, + "window_overlap_size": { + "default": 128, + "description": "Chars carried between windows so a span split across a boundary is still caught.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "window_size": { + "default": 2000, + "description": "Sliding-window size in characters when window mode is used. Aliyun limits each `llm_response_moderation` call to 2,000 characters.", + "format": "uint32", + "maximum": 2000.0, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "access_key_id", + "access_key_secret", + "kind", + "region" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Aliyun AI Guardrails. Policy-driven moderation through the `MultiModalGuard` action: the verdict follows the policy configured in the provider's console. Content that policy marks for masking is released with the provider's desensitized text in place of the original, and blocked when no desensitized text is returned. Applies on input and/or output, including streaming output.", + "properties": { + "access_key_id": { + "description": "Aliyun AccessKey ID.", + "minLength": 1, + "type": "string" + }, + "access_key_secret": { + "description": "Aliyun AccessKey secret. Decrypted before projection. Plaintext is held in memory only and is not logged. Used to sign the request.", + "minLength": 1, + "type": "string" + }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "default": null, + "description": "Explicit endpoint override as a full URL with no trailing slash. When set, it takes precedence over `region`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for Aliyun AI Guardrails policy-driven moderation.", + "enum": [ + "aliyun_ai_guardrail" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes of model-generated content held back from a streamed response before `on_buffer_exceeded` applies: in `buffer_full` mode, and in `window` mode wherever output is held whole (the whole stream on `/v1/messages` and `/v1/responses`, and tool-call arguments on `/v1/chat/completions`) and no output guardrail in the chain uses `buffer_full`, whose rows alone then set the cap. Counts assistant text, reasoning, and tool-call arguments; SSE and JSON framing is not counted.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled, an Aliyun outage does not release unscanned model output.", + "type": "boolean" + }, + "region": { + "description": "Aliyun region the guardrail lives in, e.g. `cn-shanghai`. The gateway builds the endpoint `https://green-cip..aliyuncs.com`.", + "minLength": 1, + "type": "string" + }, + "service_level": { + "default": "pro", + "description": "Which AI Guardrails service tier to call: `pro` uses `query_security_check_pro` / `response_security_check_pro`, `basic` uses `query_security_check` / `response_security_check`. Must match the tier activated on the Aliyun account.", + "type": "string" + }, + "stream_processing_mode": { + "default": "window", + "description": "Streaming output moderation mode: sliding-window incremental release or whole-response hold-back.", + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "description": "HTTP call timeout in milliseconds. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", + "format": "uint32", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + }, + "window_overlap_size": { + "default": 128, + "description": "Chars carried between windows so a span split across a boundary is still caught.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "window_size": { + "default": 2000, + "description": "Sliding-window size in characters when window mode is used. Aliyun limits each MultiModalGuard call to 2,000 characters of text.", + "format": "uint32", + "maximum": 2000.0, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "access_key_id", + "access_key_secret", + "kind", + "region" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Built-in sensitive-data detection and redaction inside AISIX. Built-in detectors and custom regex patterns can mask matched spans or block traffic on input, output, or both, including buffered streaming output. Always available and does not call an external service.", + "properties": { + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "custom_patterns": { + "default": [], + "description": "Operator-supplied regex detectors, evaluated after the built-ins.", + "items": { + "$ref": "#/definitions/PiiCustomPattern" + }, + "type": "array" + }, + "default_action": { + "default": "mask", + "description": "Action for detectors that do not set their own override.", + "enum": [ + "mask", + "block" + ], + "type": "string" + }, + "detectors": { + "default": [], + "description": "Built-in detectors to enable. The resource schema rejects unknown detector ids, so a typo cannot silently disable the policy.", + "items": { + "$ref": "#/definitions/PiiDetectorConfig" + }, + "type": "array" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for in-process sensitive-data detection and redaction.", + "enum": [ + "pii" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes of model-generated content held back from a streamed response before `on_buffer_exceeded` applies. Counts assistant text, reasoning, and tool-call arguments; SSE and JSON framing is not counted.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Lakera Guard screening via `POST /v2/guard`. Prompt-injection, jailbreak, and content-policy detections block traffic. Detections that involve only PII mask spans using the returned offsets. Applies on input, output, or both, including buffered streaming output.", + "properties": { + "api_key": { + "description": "Lakera API key sent as a `Authorization: Bearer` header. Decrypted before projection. Plaintext is held in memory only and is not logged.", + "minLength": 1, + "type": "string" + }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "default": null, + "description": "Endpoint override, e.g. a regional or self-hosted Lakera deployment. The gateway appends `/v2/guard`. Defaults to `https://api.lakera.ai`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for Lakera Guard screening.", + "enum": [ + "lakera" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes of model-generated content held back from a streamed response before `on_buffer_exceeded` applies. Counts assistant text, reasoning, and tool-call arguments; SSE and JSON framing is not counted.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), a Lakera outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", + "type": "boolean" + }, + "project_id": { + "description": "Lakera project whose policy applies (`project-...`). Omitted → the account's default policy.", + "minLength": 1, + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "description": "HTTP call timeout in milliseconds. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", + "format": "uint32", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "api_key", + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "OpenAI Moderation API category screening via `POST /moderations`. Flagged content or configured category thresholds block traffic. This guardrail is detection-only and never rewrites content. Applies on input, output, or both, including buffered streaming output.", + "properties": { + "api_key": { + "description": "OpenAI API key sent as a `Authorization: Bearer` header. Decrypted before projection. Plaintext is held in memory only and is not logged.", + "minLength": 1, + "type": "string" + }, + "category_thresholds": { + "additionalProperties": { + "description": "Score threshold for the category key.", + "format": "double", + "type": "number" + }, + "default": {}, + "description": "Per-category score thresholds, e.g. `{\"violence\": 0.5}`. When set, only the listed categories are enforced and a category blocks when its score reaches the threshold. When empty (the default), the provider's `flagged` decision determines whether to block.", + "type": "object" + }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "endpoint": { + "default": null, + "description": "Endpoint override, such as an Azure OpenAI deployment. AISIX appends `/moderations`. Defaults to `https://api.openai.com/v1`.", + "minLength": 1, + "type": "string" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for the OpenAI Moderation API.", + "enum": [ + "openai_moderation" + ], + "type": "string" + }, + "model": { + "default": "omni-moderation-latest", + "description": "Moderation model sent to the provider. The default is `omni-moderation-latest`; provider model names are not restricted by AISIX.", + "minLength": 1, + "type": "string" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), an OpenAI outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", + "type": "boolean" + }, + "timeout_ms": { + "default": 5000, + "description": "HTTP call timeout in milliseconds. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", + "format": "uint32", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "api_key", + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "PII detection and anonymization by a customer-run Presidio. Analyzer entities can mask or block per entity, and masked entities use the selected anonymize operator. Applies on input, output, or both, including buffered streaming output.", + "properties": { + "analyzer_url": { + "description": "Presidio analyzer base URL, e.g. `http://presidio-analyzer:3000`. The gateway appends `/analyze`.", + "minLength": 1, + "type": "string" + }, + "anonymizer_url": { + "description": "Presidio anonymizer base URL, e.g. `http://presidio-anonymizer:3000`. The gateway appends `/anonymize`. Only called when a detected entity's effective action is `mask`.", + "minLength": 1, + "type": "string" + }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "default_action": { + "default": "mask", + "description": "Action for entities that do not set their own override.", + "enum": [ + "mask", + "block" + ], + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "entities": { + "default": [], + "description": "Entities to detect. Empty (the default) analyzes with Presidio's full recognizer set and applies `default_action` to every hit.", + "items": { + "$ref": "#/definitions/PresidioEntityConfig" + }, + "type": "array" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for PII detection and anonymization by a customer-run Presidio.", + "enum": [ + "presidio" + ], + "type": "string" + }, + "language": { + "default": "en", + "description": "Analyzer language code.", + "minLength": 1, + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes of model-generated content held back from a streamed response before `on_buffer_exceeded` applies. Counts assistant text, reasoning, and tool-call arguments; SSE and JSON framing is not counted.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "type": "string" + }, + "operator": { + "default": "replace", + "description": "Anonymize operator applied to masked entities.", + "enum": [ + "replace", + "mask", + "hash", + "redact" + ], + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), a Presidio outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", + "type": "boolean" + }, + "score_threshold": { + "description": "Minimum analyzer confidence for a hit to count. Omitted → every result the analyzer returns counts (Presidio's own per-recognizer defaults apply).", + "format": "double", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + "timeout_ms": { + "default": 5000, + "description": "HTTP call timeout in milliseconds, applied per analyzer/anonymizer call. `fail_open` and `output_fail_open` govern the verdict when it elapses. A value of `0` triggers the timeout immediately.", + "format": "uint32", + "maximum": 4294967295.0, + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "analyzer_url", + "anonymizer_url", + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "deny_examples": { + "minItems": 1 + } + }, + "required": [ + "deny_examples" + ] + }, + "then": { + "required": [ + "deny_threshold" + ] + } + }, + { + "if": { + "properties": { + "allow_examples": { + "minItems": 1 + } + }, + "required": [ + "allow_examples" + ] + }, + "then": { + "required": [ + "allow_threshold" + ] + } + }, + { + "anyOf": [ + { + "properties": { + "embedding_model": { + "type": "string" + } + }, + "required": [ + "embedding_model" + ] + }, + { + "properties": { + "embedding_model_id": { + "type": "string" + } + }, + "required": [ + "embedding_model_id" + ] + } + ] + } + ], + "description": "Embedding-similarity screening against operator-supplied example texts. Deny examples block on a close match; an allow-list, when present, blocks everything that matches none of it. Calls an `embedding`-kind Model through the gateway's provider bridges. Detection-only — never rewrites content. Applies on input, output, or both, including buffered streaming output.", + "properties": { + "allow_examples": { + "default": [], + "description": "Example texts whose meaning is PERMITTED. When non-empty, a screened text that clears none of them blocks — an allow-list narrows traffic to the listed topics. Empty (the default) means \"no allow-list\": only `deny_examples` can block.", + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + }, + "allow_threshold": { + "description": "Cosine-similarity threshold for `allow_examples`, in `[-1, 1]`. RAISE it to block more — a text must reach it to be admitted. Required whenever `allow_examples` is non-empty, and only then: a row with no allow-list has nothing for this number to decide. See `deny_threshold` for why there is no portable value.", + "format": "float", + "maximum": 1.0, + "minimum": -1.0, + "type": "number" + }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "deny_examples": { + "default": [], + "description": "Example texts whose meaning must be REFUSED. A screened text scoring at or above `deny_threshold` against any of them blocks.", + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + }, + "deny_threshold": { + "description": "Cosine-similarity threshold for `deny_examples`, in `[-1, 1]`. Lower it to block more. Required whenever `deny_examples` is non-empty.\n\nThere is no portable value. Cosine scores are not comparable across embedding models — the same pair of texts can fall on opposite sides of a fixed threshold depending on which model produced the vectors, and a threshold carried over from another model under-screens without any sign that it is doing so. Measure one against the model named in `embedding_model`, on your own traffic: a request that emits a usage event reports what it scored in `guardrail_scores`, including the requests this guardrail allowed. Not every surface can produce that sample. `/a2a`, `rerank`, `/v1/embeddings`, `/v1/images/*`, `/v1/videos`, `/v1/audio/speech` and `/v1/messages/count_tokens` run the INPUT hook only, so an output-hook row scores nothing on them (audio transcription and translation do run both); `/a2a` also resolves no model and no MCP server, so only a row attached at the environment, API-key or team scope reaches it at all.", + "format": "float", + "maximum": 1.0, + "minimum": -1.0, + "type": "number" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "embedding_model": { + "default": "", + "description": "Alias of an `embedding`-kind Model used to embed both the examples and the screened text. Must resolve in the same environment as this guardrail.\n\nDefaulted at the TYPE level and required by the strict write schema instead: a row the loader cannot deserialize is skipped whole, and a screening row that vanishes is a guardrail that stopped screening. Empty resolves to nothing, so the row degrades per `fail_open` — fail-closed by default — rather than disappearing.", + "minLength": 1, + "type": "string" + }, + "embedding_model_id": { + "description": "Resource id of the `embedding`-kind Model used to embed both the examples and the screened text. Present, it is authoritative and `embedding_model` is ignored: the id is resolved against the models in the current configuration, so renaming that model keeps this row screening with it and needs no edit here. An id resolving to no model is an embedder that cannot be resolved, exactly as an `embedding_model` naming no model is — the row degrades per `fail_open`, fail-closed by default.\n\nDefaulted at the type level for the same reason `embedding_model` is: the strict write schema requires one of the two, the read path requires neither, and a screening row that fails to load is fail-OPEN.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for embedding-similarity screening against example texts, using an embedding-kind Model.", + "enum": [ + "semantic" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes of model-generated content held back from a streamed response before `on_buffer_exceeded` applies. Counts assistant text, reasoning, and tool-call arguments; SSE and JSON framing is not counted.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "max_screened_texts": { + "default": 8, + "description": "Cap on how many texts ONE request screens, bounding the embedding tokens a single request can spend. The input hook screens the MOST RECENT messages first and drops the rest: an earlier turn was already screened as the latest message of an earlier request, so the dropped tail is seen history rather than unread content.", + "format": "uint32", + "maximum": 64.0, + "minimum": 1.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "enum": [ + "fail_closed", + "fail_open" + ], + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled, an embedding outage does not release unscreened model output.", + "type": "boolean" + }, + "text_source": { + "default": "user_messages", + "description": "Input-hook text selection. The default screens user messages only; the alternate mode screens every message. Ignored on the output hook.", + "enum": [ + "user_messages", + "all_messages" + ], + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "description": "Per-call deadline for the embedding request in milliseconds.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Screening by an operator-supplied script the gateway runs in a sandboxed engine, for a screening service that speaks its own protocol. The script can allow, block, or rewrite content. Applies on input, output, or both, including streaming output.", + "properties": { + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "kind": { + "description": "Guardrail provider type for screening by an operator-supplied script the gateway runs in a sandboxed engine.", + "enum": [ + "custom" + ], + "type": "string" + }, + "max_buffer_bytes": { + "default": 262144, + "description": "Max bytes of model-generated content held back from a streamed response before `on_buffer_exceeded` applies: in `buffer_full` mode, and in `window` mode wherever output is held whole (the whole stream on `/v1/messages` and `/v1/responses`, and tool-call arguments on `/v1/chat/completions`) and no output guardrail in the chain uses `buffer_full`, whose rows alone then set the cap. Counts assistant text, reasoning, and tool-call arguments; SSE and JSON framing is not counted.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "max_memory_bytes": { + "default": 16777216, + "description": "Memory ceiling for the script engine, in bytes. A script that exceeds it is terminated and the hook's fail-open policy applies.", + "format": "uint64", + "maximum": 536870912.0, + "minimum": 1048576.0, + "type": "integer" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, + "on_buffer_exceeded": { + "default": "fail_closed", + "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", + "enum": [ + "fail_closed", + "fail_open" + ], + "type": "string" + }, + "output_fail_open": { + "default": false, + "description": "Fail-open policy for the output hook. When disabled (the default), a script failure blocks model output instead of releasing unscanned content. The input hook uses the top-level `fail_open` policy.", + "type": "boolean" + }, + "script": { + "description": "The script source, as an ES module exporting `checkInput` and/or `checkOutput`. A hook whose function the module does not export is skipped, so a script may cover one direction only.\n\nRequired on both the write schema and the read one, so a row that omits it is refused rather than loaded: unlike the fields that are write-path-only, a `custom` row with no script screens nothing either way, and rejecting it is what puts it in `/status/config`'s `rejected` list where an operator can see it.\n\nA script that is whitespace-only or does not compile passes the schema — `minLength` counts characters, so a whitespace-only value is non-empty — and is refused when the chain is built instead. `aisix validate` reports that and exits non-zero; a serving gateway reports the runtime rejection through config status (api7/aisix#1084).", + "minLength": 1, + "type": "string" + }, + "secrets": { + "additionalProperties": { + "description": "Value the script reads as ctx.secrets under this name.", + "type": "string" + }, + "description": "Values the script reads as `ctx.secrets.`, for credentials the screening service requires. Stored encrypted and decrypted before projection; plaintext is held in memory only and is never logged.", + "type": "object" + }, + "stream_processing_mode": { + "default": "window", + "description": "Streaming output moderation mode: sliding-window incremental release or whole-response hold-back.", + "enum": [ + "window", + "buffer_full" + ], + "type": "string" + }, + "timeout_ms": { + "default": 5000, + "description": "Wall-clock budget for one hook invocation, in milliseconds, covering the script's own execution and every call it makes. `fail_open` and `output_fail_open` govern the verdict when it elapses. Per-call timeouts within the budget are the script's own to set.", + "format": "uint32", + "maximum": 300000.0, + "minimum": 1.0, + "type": "integer" + }, + "window_overlap_size": { + "default": 256, + "description": "Chars carried between windows so a span split across a boundary is still caught.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "window_size": { + "default": 10000, + "description": "Sliding-window size in characters for window mode.", + "format": "uint32", + "maximum": 10000.0, + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "kind", + "script" + ], + "type": "object" + } + ], + "properties": { + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": false, + "description": "Behavior when this guardrail cannot complete its check. Two causes: a remote provider that is unreachable, timing out, throttling, or rejecting the call; and a body the gateway could not give the guardrail at all — one that does not decode as UTF-8 or does not parse, which the proxy would otherwise refuse with `unscannable_body`. `true` allows the request; `false` (the default) blocks with 422.\n\nBoth causes apply to EVERY kind: the second one is the gateway failing to produce scannable text, which happens before any guardrail runs and so reaches all of them. `keyword` and `pii` never call out, so only the second can arise for them; a kind that calls out can meet either.\n\nThe per-hook split follows the same line. A kind that calls out carries its own `output_fail_open` for the output hook, leaving this field to govern the input hook. `keyword` and `pii` have no `output_fail_open`, so this one value governs both of their hooks.\n\nDefaults to fail-closed so an unchecked request is never released on the strength of a guardrail that did not run: an operator who prefers availability over enforcement opts in explicitly. This matches `output_fail_open` and `on_buffer_exceeded`, which have always defaulted closed (AISIX-Cloud#1382).", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, + "input_messages": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailInputMessages" + } + ], + "default": "all", + "description": "Which messages of a request this rule's input check considers — its window. What the rule reads inside the window depends on its kind: some kinds read only its user messages by default.\n\n`all` (the default) makes the window the whole request: system, developer, user, assistant and tool messages alike. `latest_turn` makes it only the messages after the last assistant message, with system and developer messages excluded — the current user message plus any tool results answering it. It exists for clients that resend the whole conversation on every call, where a rule that matched one earlier message would otherwise keep refusing the rest of the session.\n\nA rule reads, and can rewrite, only the messages in its window: under `latest_turn` a masking rule rewrites only the current turn, and messages outside it reach the upstream exactly as the caller sent them. A rule whose job is to mask the whole conversation belongs on `all`.\n\nIgnored at the output hook, which always reads the whole response." + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "Guardrail", + "type": "object" +} diff --git a/third_party/aisix/schemas/guardrail_attachment.schema.json b/third_party/aisix/schemas/guardrail_attachment.schema.json new file mode 100644 index 0000000..e45a0a1 --- /dev/null +++ b/third_party/aisix/schemas/guardrail_attachment.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "GuardrailScopeType": { + "description": "Which dimension of the request a guardrail attachment is scoped to.\n\n`Env` applies to every request in the environment. The narrower scopes let operators attach a guardrail to only the models, MCP servers, API keys, or teams that need it.\n\n`Model`, `McpServer` and `PassthroughRoute` select dimensions a request carries only one of: an MCP tool call resolves no model, an LLM request routes to no MCP server, and a passthrough-route request resolves neither. A `Model`-scoped guardrail therefore never inspects MCP or passthrough traffic, an `McpServer`-scoped one never inspects model traffic, and a `PassthroughRoute`-scoped one inspects only the traffic of that route.\n\nScope follows the entry the caller addresses: a guardrail attached to a model runs only for requests addressed to that model. When the model is reached as a member of a routing, semantic, or ensemble group, its guardrails do not run — attach the guardrail to the group instead.", + "enum": [ + "env", + "model", + "mcp_server", + "api_key", + "team", + "passthrough_route" + ], + "type": "string" + } + }, + "description": "Guardrail attachment that scopes one guardrail to an environment, model, MCP server, caller API key, or team. AISIX loads attachments with the guardrail definitions and uses `scope_type` plus `scope_id` to decide which guardrails apply to each request.", + "properties": { + "enabled": { + "default": true, + "description": "When `false`, AISIX ignores this attachment.", + "type": "boolean" + }, + "env_id": { + "description": "Environment the attachment belongs to. Written by the managed control plane for its own scoping; the gateway does not read it.", + "type": [ + "string", + "null" + ] + }, + "guardrail_id": { + "description": "UUID of the guardrail definition this attachment points to.", + "minLength": 1, + "type": "string" + }, + "priority": { + "description": "Higher number = higher precedence. When the same guardrail appears via multiple matching scopes, the highest-priority attachment wins and duplicates are dropped.", + "format": "int32", + "type": "integer" + }, + "scope_id": { + "description": "The UUID of the specific resource (model / mcp_server / api_key / team / passthrough_route). `None` when `scope_type` is `Env` (applies to all requests).", + "type": [ + "string", + "null" + ] + }, + "scope_type": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailScopeType" + } + ], + "description": "What dimension of the request this attachment is scoped to." + } + }, + "required": [ + "guardrail_id", + "priority", + "scope_type" + ], + "title": "GuardrailAttachment", + "type": "object" +} diff --git a/third_party/aisix/schemas/mcp_auth_settings.schema.json b/third_party/aisix/schemas/mcp_auth_settings.schema.json new file mode 100644 index 0000000..689dc2a --- /dev/null +++ b/third_party/aisix/schemas/mcp_auth_settings.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "McpAnonymousAccess": { + "additionalProperties": false, + "description": "Anonymous access configuration for this environment's `/mcp` entries.\n\nA request that carries NO gateway credential and arrives from `source_cidrs` runs as the `api_key_id` principal: its MCP tool grant, rate limits, budget, guardrails and usage attribution all apply, so anonymous traffic stays governable instead of bypassing the pipeline. A request carrying a credential is authenticated normally and a bad one is rejected — never downgraded to anonymous.", + "properties": { + "aggregate_entry": { + "description": "Whether the aggregated `/mcp` endpoint ALSO serves anonymous callers. It exposes the same allowlisted servers, under their `__` namespaced names. It cannot stand in for the allowlist: with no server allowlisted the aggregated entry stays closed whatever this says, the same way every scoped entry does.\n\nOff by default: it is the entry a standard MCP client uses for OAuth discovery, and the namespaced names are not what a client migrating from a single-server endpoint uses. Turning it on suppresses the `WWW-Authenticate` discovery hint there, since a no-credential request succeeds instead of producing the 401 that carries it.", + "type": "boolean" + }, + "api_key_id": { + "description": "The API key anonymous traffic runs as. Everything keyed on a principal — MCP tool ACL, per-server and per-key rate limits, budget, guardrail scopes, usage events — resolves through this key, which is why anonymous callers stay attributable. The key must carry an explicit MCP grant: a key left on `inherit` would pick up the environment-default policy, so an `all` default would silently hand every registered tool to anonymous callers (the control plane rejects that at write time).", + "minLength": 1, + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether anonymous access is served. `false` keeps the configuration but closes the door, so an operator can suspend it without losing the principal and allowlists.", + "type": "boolean" + }, + "server_ids": { + "description": "The same allowlist written by MCP server resource id (`mcp_servers/`) instead of by server name, so renaming a server does not drop it out of the anonymous ceiling.\n\nPresent — including as an empty array — it is authoritative and `servers` is ignored. An empty array therefore admits no server at all: every `/mcp/{server}` entry closes, and so does the aggregated `/mcp` entry even with `aggregate_entry` set, since an open door onto an empty room reads as enabled, serves nothing, and suppresses the `WWW-Authenticate` discovery hint a standard client would otherwise follow. An id naming no registered server admits nothing, and the other entries are unaffected. Absent or `null`, the allowlist falls back to `servers`.\n\nA gateway one release behind the control plane does not read this field and applies `servers` instead, so the two spellings must be written to mean the same thing. For a non-empty array that is simply the current names of the servers it lists. The empty array has no name-form spelling at all — `servers` is required and non-empty on every release — so a control plane that offers it MUST keep an older gateway from reading a permissive `servers` beside it; emptying the ceiling would otherwise close it on new gateways and leave it open on older ones, which is the wrong direction for an access control. Even then the older gateway keeps serving the aggregated entry when `aggregate_entry` is set: that entry closing on an empty ceiling is a rule only this release knows, and no `servers` value can carry it back.\n\nIt cannot express \"every server\": each entry names one server exactly and an id is never a glob, so an enumeration of the servers registered today silently fails to cover one registered tomorrow. To admit every server, present and future, leave this absent and use the name form.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "servers": { + "description": "Registered MCP server names anonymous callers may reach.\n\nThis is the anonymous principal's CEILING, not merely the list of scoped entries to open: the listed servers' tools are intersected with the key's own grant on BOTH entries. Without that, a key whose grant is wider than the list would let an anonymous caller reach an unlisted server's tools through the aggregated endpoint by naming `__` directly — `/mcp/{server}` closed, aggregated `/mcp` open.\n\nRequired and non-empty, because an empty ceiling admits no tool on either entry: an anonymous block listing no server could never serve a useful request, including through `aggregate_entry`. It is also why a newly registered server is never anonymous by default — reaching anonymous callers is always a name added here.\n\nRead only when `server_ids` is absent; ignored entirely when it is present. It stays required either way — it is the only spelling a gateway one release behind the control plane can read.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "source_cidrs": { + "description": "Client source CIDRs allowed to enter anonymously. Required and non-empty: with no credential to check, network reachability is the only gate in front of the principal. Matched against the source IP the proxy's real-ip chain resolves, never against a caller-supplied header value.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "api_key_id", + "servers", + "source_cidrs" + ], + "type": "object" + } + }, + "properties": { + "anonymous": { + "allOf": [ + { + "$ref": "#/definitions/McpAnonymousAccess" + } + ], + "description": "Anonymous access to named `/mcp` entries. Unset (the default) means every `/mcp` request needs a valid gateway credential." + }, + "resource_url": { + "description": "Canonical URI of this environment's `/mcp` endpoint, e.g. `https://gw.example.com/mcp`. Published verbatim as the PRM document's `resource` (never derived from the request Host header). A token's `aud` is never compared with this value: an inbound token is checked against its trust provider's `audiences` alone. The URL path must end in `/mcp` at a segment boundary, such as `/mcp` or `/aisix/mcp`. A prefix in front of `/mcp` is for a reverse proxy that strips it before forwarding to the gateway, and the protected resource metadata is then advertised under the same prefix. Unset leaves the OAuth discovery surface dormant.", + "minLength": 1, + "type": "string" + } + }, + "title": "McpAuthSettings", + "type": "object" +} diff --git a/third_party/aisix/schemas/mcp_policy.schema.json b/third_party/aisix/schemas/mcp_policy.schema.json new file mode 100644 index 0000000..3fc667b --- /dev/null +++ b/third_party/aisix/schemas/mcp_policy.schema.json @@ -0,0 +1,149 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "scope": { + "const": "team" + } + } + }, + "then": { + "properties": { + "scope_ref": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "scope_ref" + ] + } + }, + { + "if": { + "properties": { + "deny_ids": { + "type": "array" + } + }, + "required": [ + "deny_ids" + ] + }, + "then": { + "required": [ + "deny" + ] + } + } + ], + "definitions": { + "McpPolicyScope": { + "description": "Which API keys an MCP access policy applies to.", + "oneOf": [ + { + "description": "Applied to every key in the environment.", + "enum": [ + "env" + ], + "type": "string" + }, + { + "description": "Applied to the keys belonging to the team named by `scope_ref`, on top of the environment policy rather than in place of it.", + "enum": [ + "team" + ], + "type": "string" + } + ] + }, + "McpToolRef": { + "additionalProperties": false, + "description": "One tool on one MCP server, the server named by its resource id and the tool by name.\n\nThe tool half is matched with the same single-`*` glob rule the name form uses, so `tool: \"*\"` is the id spelling of `__*`. The server half is an exact id: it is compared against the id of the server the addressed tool actually belongs to, never glob-matched, so a server whose *name* happens to contain a `*` cannot widen a grant.\n\nBoth halves are required on the WRITE path and defaulted by the runtime loader — the strict schemas add them to this definition's `required`, the types do not. An entry these were required of at the type level would fail to deserialize, and the loader skips a row it cannot deserialize whole: one malformed entry in one `allow_ids` array would stop the entire `api_key` from authenticating any traffic at all, not merely lose it MCP access. Defaulted, the malformed entry matches nothing instead — an empty `server_id` names no registered server, and an empty `tool` glob covers no tool name the gateway exposes.", + "properties": { + "server_id": { + "description": "Resource id of the registered MCP server (`mcp_servers/`) this entry refers to. An id matching no registered server refers to nothing: the entry never matches, and the other entries are unaffected.", + "minLength": 1, + "type": "string" + }, + "tool": { + "description": "Tool on that server, matched as a single-`*` glob against the bare tool name — the part after the `__` namespace prefix. `\"*\"` covers every tool the server exposes.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "server_id", + "tool" + ], + "type": "object" + } + }, + "properties": { + "allow": { + "default": [], + "description": "Namespaced `__` patterns this layer allows. Entries are matched as single-`*` globs: `\"*\"` allows every tool, `\"__*\"` every tool on one server, and an entry without a `*` matches one tool exactly. An empty list allows nothing, which is how a policy blocks all MCP access; a policy that only means to subtract tools writes `[\"*\"]` here and lists them under `deny`.\n\nThe write path requires the field (the strict schema adds it to `required`), so a layer never allows something by omission. The runtime loader defaults it to empty instead of rejecting the row: a document written before the layered shape would otherwise fail to deserialize, and a skipped `api_key` row stops authenticating altogether rather than merely losing MCP access.\n\nRead only when `allow_ids` is absent; ignored entirely when it is present.", + "items": { + "type": "string" + }, + "type": "array" + }, + "allow_ids": { + "description": "This layer's allow side written by MCP server resource id instead of by server name, so renaming a server does not change what the layer allows. Each entry names one MCP server by the id it is registered under and one tool by name, the tool matched as a single-`*` glob against the bare tool name.\n\nPresent — including as an empty array — it is authoritative and `allow` is ignored; an empty array therefore allows nothing. Set to `null` it means the same as omitted: the layer falls back to `allow`.\n\nIt cannot express \"every server\": each entry names one server exactly and `server_id` is never a glob, so an enumeration of the servers registered today silently fails to cover one registered tomorrow. To allow (or deny) every server, present and future, leave this absent and use the name form's `\"*\"`.", + "items": { + "$ref": "#/definitions/McpToolRef" + }, + "type": [ + "array", + "null" + ] + }, + "deny": { + "description": "Namespaced `__` patterns subtracted from the effective grant of every key the policy applies to, using the same single-`*` glob matching as `allow`. Deny always wins: a tool matched here stays unavailable however the other layers allow it.\n\nRead only when `deny_ids` is absent; ignored entirely when it is present.", + "items": { + "type": "string" + }, + "type": "array" + }, + "deny_ids": { + "description": "This layer's deny side written by MCP server resource id instead of by server name. Present — including as an empty array — it is authoritative and `deny` is ignored; an empty array subtracts nothing. Absent or `null`, the layer falls back to `deny`.\n\nIt cannot express \"every server\": each entry names one server exactly and `server_id` is never a glob, so an enumeration of the servers registered today silently fails to cover one registered tomorrow. To allow (or deny) every server, present and future, leave this absent and use the name form's `\"*\"`.", + "items": { + "$ref": "#/definitions/McpToolRef" + }, + "type": [ + "array", + "null" + ] + }, + "enabled": { + "default": true, + "description": "Whether the policy is applied. A disabled policy is kept but contributes neither its allow nor its deny side. Treated as `true` when omitted.", + "type": "boolean" + }, + "scope": { + "allOf": [ + { + "$ref": "#/definitions/McpPolicyScope" + } + ], + "description": "Which API keys the policy applies to: the whole environment or one team." + }, + "scope_ref": { + "description": "Team identifier the policy targets. Required when `scope` is `team`; omitted for an environment policy.", + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "scope", + "allow" + ], + "title": "McpPolicy", + "type": "object" +} diff --git a/third_party/aisix/schemas/mcp_server.schema.json b/third_party/aisix/schemas/mcp_server.schema.json new file mode 100644 index 0000000..b6ad17a --- /dev/null +++ b/third_party/aisix/schemas/mcp_server.schema.json @@ -0,0 +1,525 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "auth_type": { + "const": "bearer" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "secret": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret" + ] + } + }, + { + "if": { + "properties": { + "auth_type": { + "const": "api_key" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "secret": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret" + ] + } + }, + { + "if": { + "properties": { + "auth_type": { + "const": "oauth2" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "client_id": { + "minLength": 1, + "type": "string" + }, + "secret": { + "minLength": 1, + "type": "string" + }, + "token_url": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret", + "client_id", + "token_url" + ] + } + }, + { + "if": { + "properties": { + "auth_type": { + "const": "oauth2_token_exchange" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "client_id": { + "minLength": 1, + "type": "string" + }, + "scopes": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "secret": { + "minLength": 1, + "type": "string" + }, + "token_exchange_profile": { + "type": "string" + }, + "token_url": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "secret", + "client_id", + "token_url", + "scopes", + "token_exchange_profile" + ] + } + }, + { + "if": { + "properties": { + "auth_type": { + "const": "oauth2_authorization_code" + } + }, + "required": [ + "auth_type" + ] + }, + "then": { + "properties": { + "type": { + "const": "mcp" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "openapi" + } + }, + "required": [ + "type" + ] + }, + "then": { + "allOf": [ + { + "if": { + "properties": { + "api_key_header": { + "type": "string" + } + }, + "required": [ + "api_key_header" + ] + }, + "then": { + "properties": { + "auth_type": { + "const": "api_key" + } + }, + "required": [ + "auth_type" + ] + } + } + ], + "properties": { + "api_key_header": { + "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" + }, + "spec": { + "not": { + "required": [ + "swagger" + ] + }, + "type": "object" + } + }, + "required": [ + "spec" + ] + } + }, + { + "if": { + "anyOf": [ + { + "properties": { + "type": { + "const": "mcp" + } + }, + "required": [ + "type" + ], + "title": "type is mcp" + }, + { + "not": { + "required": [ + "type" + ] + }, + "title": "type is absent (defaults to mcp)" + } + ] + }, + "then": { + "properties": { + "api_key_header": { + "type": "null" + }, + "spec": { + "type": "null" + } + } + } + } + ], + "anyOf": [ + { + "required": [ + "name" + ], + "title": "name" + }, + { + "required": [ + "display_name" + ], + "title": "display_name" + } + ], + "definitions": { + "McpAuthType": { + "description": "How the gateway authenticates to an upstream MCP server.", + "oneOf": [ + { + "description": "No authentication; the server is reached as-is.", + "enum": [ + "none" + ], + "title": "No authentication", + "type": "string" + }, + { + "description": "Bearer token authentication. The token is supplied in `secret` and sent as `Authorization: Bearer `.", + "enum": [ + "bearer" + ], + "title": "Bearer token", + "type": "string" + }, + { + "description": "API key authentication. The key is supplied in `secret` and sent as an `x-api-key: ` header on every upstream request.", + "enum": [ + "api_key" + ], + "title": "API key", + "type": "string" + }, + { + "description": "OAuth 2.0 client credentials grant. The gateway exchanges `client_id`, the client secret in `secret`, and the optional `scopes` at `token_url` for an access token, and sends it as `Authorization: Bearer ` on every upstream request. Access tokens are cached until shortly before their reported expiry.", + "enum": [ + "oauth2" + ], + "title": "OAuth 2.0 client credentials", + "type": "string" + }, + { + "description": "Delegated OAuth: the gateway holds no upstream credential and relays the caller's own `Authorization` header to the server unchanged. An `Authorization` the caller authenticated to the gateway with is never relayed, so a caller that needs both sends its gateway API key in `x-aisix-api-key` and its upstream token in `Authorization`. With no upstream token the server is called without credentials and its own response decides.", + "enum": [ + "oauth_delegate" + ], + "title": "Delegated OAuth (caller's own token)", + "type": "string" + }, + { + "description": "OAuth token exchange on behalf of the caller: the gateway exchanges the caller's bearer token for an access token to this server at `token_url`, authenticating with `client_id` and the client secret in `secret` and requesting `scopes`, and sends the result as `Authorization: Bearer `. `token_exchange_profile` selects the exchange protocol. Only a token the gateway itself verified through a JWT trust provider on the same request is exchanged; for a caller authenticated any other way there is nothing to exchange, and the single-server endpoint `/mcp/{name}` answers `401`. The caller's token never reaches the server. Exchanged tokens are cached per caller token until shortly before their reported expiry.", + "enum": [ + "oauth2_token_exchange" + ], + "title": "OAuth 2.0 token exchange on behalf of the caller", + "type": "string" + }, + { + "description": "Per-user OAuth 2.0 authorization code flow with PKCE, for `type: mcp` only. Each user signs in to the upstream authorization server once; the gateway keeps the resulting tokens and sends that user's access token as `Authorization: Bearer `, refreshing it when needed. A caller whose gateway credential has no user cannot use such a server. The upstream tokens never reach the MCP client, and the client's own `Authorization` header is never sent to the server. The public address of the sign-in endpoints comes from `resource_url` in the MCP authentication settings. A client with no gateway credential can connect when a trust provider in the environment has `login`.", + "enum": [ + "oauth2_authorization_code" + ], + "title": "OAuth 2.0 authorization code flow, per user", + "type": "string" + } + ] + }, + "McpProtocolVersion": { + "description": "MCP protocol revision used for an upstream connection. Values are the specification's dated version identifiers; revisions before `2026-07-28` need no entry here because the `initialize` handshake negotiates among them automatically when `protocol_version` is omitted.", + "oneOf": [ + { + "description": "The stateless MCP `2026-07-28` revision: handshake-free startup via `server/discover`, with self-contained per-request metadata.", + "enum": [ + "2026-07-28" + ], + "type": "string" + } + ] + }, + "McpServerType": { + "description": "What backs a registered MCP server entry.", + "oneOf": [ + { + "description": "A real upstream MCP server the gateway connects to.", + "enum": [ + "mcp" + ], + "title": "Upstream MCP server", + "type": "string" + }, + { + "description": "A REST API described by an OpenAPI document; the gateway generates the tools itself and issues plain HTTP requests against `url`.", + "enum": [ + "openapi" + ], + "title": "REST API described by an OpenAPI document", + "type": "string" + } + ] + }, + "McpTokenExchangeProfile": { + "description": "Token exchange protocol for `auth_type: oauth2_token_exchange`.", + "oneOf": [ + { + "description": "Microsoft Entra ID On-Behalf-Of flow: a `jwt-bearer` grant carrying the caller's token as `assertion` with `requested_token_use=on_behalf_of`. The caller's token must be issued for the gateway's `client_id`.", + "enum": [ + "entra_obo" + ], + "title": "Microsoft Entra ID On-Behalf-Of", + "type": "string" + } + ] + }, + "McpTransport": { + "description": "Transport used to reach an upstream MCP server.", + "oneOf": [ + { + "description": "Streamable HTTP transport: a single endpoint that serves both POST and GET.", + "enum": [ + "streamable_http" + ], + "title": "Streamable HTTP", + "type": "string" + } + ] + } + }, + "properties": { + "api_key_header": { + "description": "Header name the API key is sent under when `type` is `openapi` and `auth_type` is `api_key`. Defaults to `x-api-key` when unset. Ignored for `type: mcp`, whose API-key header is fixed.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "auth_type": { + "allOf": [ + { + "$ref": "#/definitions/McpAuthType" + } + ], + "default": "none", + "description": "How the gateway authenticates to the upstream server. A gateway-held credential (`bearer`, `api_key`, `oauth2`, `oauth2_token_exchange`, `oauth2_authorization_code`) is never exposed to the calling client. `oauth_delegate` holds none: the caller's own upstream token is relayed instead." + }, + "authorization_url": { + "description": "OAuth authorization endpoint URL that users are sent to when signing in, such as `https://auth.example.com/oauth/authorize`. Used when `auth_type` is `oauth2_authorization_code`; when omitted, it is discovered from the upstream server. Ignored otherwise.", + "type": [ + "string", + "null" + ] + }, + "client_id": { + "description": "OAuth client identifier the gateway authenticates to `token_url` with. Required when `auth_type` is `oauth2` or `oauth2_token_exchange`. For `oauth2_authorization_code` it is optional: when omitted, the gateway registers itself once with the authorization server as a public client. Ignored otherwise.", + "type": [ + "string", + "null" + ] + }, + "display_name": { + "description": "Accepted as an alternative spelling of `name`. Provide the label under exactly one of the two names.", + "minLength": 1, + "pattern": "^(?:[^_*]|_[^_*])*$", + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether this server is active. When `false`, its tools are not listed and cannot be called.", + "type": "boolean" + }, + "forward_client_headers": { + "description": "Inbound client headers forwarded to this server, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to both `type: mcp` and `type: openapi`, so a REST API exposed here as tools receives them on every tool call.\n\nA header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded.\n\nA header named here reaches the server whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer` and `oauth2`, `api_key_header` for `api_key` — hands the server the caller's own credential in place of the gateway's, never both. `oauth_delegate`, `oauth2_token_exchange` and `oauth2_authorization_code` own `authorization` themselves, so naming it on such a server has no effect. That is what lets an internal server that already authorizes on the end user's `Authorization` keep doing so unchanged. A server that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and the MCP session slots (`mcp-session-id`, `mcp-protocol-version`, `last-event-id`), which name the caller's session with this gateway and which an upstream MCP server refuses outright when they carry a foreign value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "description": "Operator-facing label, unique within the gateway. It is used as the namespace prefix for this server's tools, which are exposed to clients as `__`, so it must not contain the reserved separator `__`.", + "minLength": 1, + "pattern": "^(?:[^_*]|_[^_*])*$", + "type": "string" + }, + "protocol_version": { + "anyOf": [ + { + "$ref": "#/definitions/McpProtocolVersion" + }, + { + "type": "null" + } + ], + "description": "MCP protocol revision the gateway uses when connecting to this upstream server. When omitted, the gateway opens the session with the `initialize` handshake, which negotiates among the pre-2026 protocol revisions — the right choice for most servers, including `2026-07-28` servers that keep backward compatibility. Set `2026-07-28` for a server that requires the stateless MCP `2026-07-28` revision (handshake-free `server/discover` startup); the connection fails rather than silently downgrading when the server does not support the configured revision. Only used when `type` is `mcp`; ignored for `type: openapi`." + }, + "registration_url": { + "description": "OAuth dynamic client registration endpoint URL. Used when `auth_type` is `oauth2_authorization_code` and `client_id` is omitted; when this is omitted too, it is discovered from the upstream server. Ignored otherwise.", + "type": [ + "string", + "null" + ] + }, + "scopes": { + "description": "OAuth scopes to request. Joined with spaces into the `scope` parameter of the token request. Used when `auth_type` is `oauth2` (optional) or `oauth2_token_exchange` (required, at least one — for `entra_obo` the target API's scope, such as `api:///.default`). For `oauth2_authorization_code` they are requested when signing in unless the MCP client asks for its own.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "secret": { + "description": "Authentication credential for the upstream server. Its meaning follows `auth_type`: the bearer token when `auth_type` is `bearer` (sent as `Authorization: Bearer `), the API key when `auth_type` is `api_key` (sent as `x-api-key: `, or under `api_key_header` for `type: openapi`), or the OAuth client secret when `auth_type` is `oauth2`, `oauth2_token_exchange`, or `oauth2_authorization_code` with a `client_id` that has one. Leave unset when `auth_type` is `none` or `oauth_delegate`.", + "type": [ + "string", + "null" + ] + }, + "spec": { + "description": "The OpenAPI 3.x document (as a JSON object) whose operations become this server's tools. Required when `type` is `openapi`; ignored otherwise. A document with no operation that can become a tool is rejected." + }, + "timeout_ms": { + "description": "Maximum time, in milliseconds, to wait for a single upstream operation (establishing the session, listing tools, or calling a tool). Must be at least `1` when set. When omitted, the gateway applies a built-in default.", + "format": "uint64", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "token_exchange_profile": { + "anyOf": [ + { + "$ref": "#/definitions/McpTokenExchangeProfile" + }, + { + "type": "null" + } + ], + "description": "Token exchange protocol used when `auth_type` is `oauth2_token_exchange`. Required for that `auth_type`; ignored otherwise." + }, + "token_url": { + "description": "OAuth token endpoint URL where the gateway obtains the upstream access token, such as `https://auth.example.com/oauth/token`. Required when `auth_type` is `oauth2` or `oauth2_token_exchange`. For `oauth2_authorization_code` it is optional: when omitted, it is discovered from the upstream server. Ignored otherwise.", + "type": [ + "string", + "null" + ] + }, + "transport": { + "allOf": [ + { + "$ref": "#/definitions/McpTransport" + } + ], + "default": "streamable_http", + "description": "Transport used to reach the upstream server. Streamable HTTP is the only supported transport." + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/McpServerType" + } + ], + "default": "mcp", + "description": "What backs this server: a real upstream MCP server (`mcp`, the default), or a plain REST API described by an OpenAPI document (`openapi`) whose operations the gateway itself exposes as MCP tools." + }, + "url": { + "description": "For `type: mcp`, the upstream server's MCP endpoint URL, reached over the Streamable HTTP transport, such as `https://api.example.com/mcp`. For `type: openapi`, the REST API's base URL that generated tool calls are issued against, such as `https://erp.internal/api/v1`.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "McpServer", + "type": "object" +} diff --git a/third_party/aisix/schemas/model.schema.json b/third_party/aisix/schemas/model.schema.json new file mode 100644 index 0000000..075c7ad --- /dev/null +++ b/third_party/aisix/schemas/model.schema.json @@ -0,0 +1,1540 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "Aggregation": { + "description": "How a request's per-example similarity scores collapse into one score for the route. v1 uses `max` (the single best-matching example), chosen for explainability — the UI surfaces \"the top matching example\" — over semantic-router's default sum/mean-over-top_k.", + "oneOf": [ + { + "description": "Route score = the highest cosine across its examples.", + "enum": [ + "max" + ], + "type": "string" + } + ] + }, + "AutoPromptCaching": { + "additionalProperties": false, + "description": "Automatic prompt-cache breakpoint injection for a Claude model — on the Anthropic API, on Amazon Bedrock, or on Vertex AI. When enabled, the gateway adds cache-control markers to chat completions and responses requests that carry none of their own, so callers get provider-side prompt-cache discounts without changing their requests. Requests that already set their own cache-control markers are forwarded unchanged. Other models ignore it.", + "properties": { + "enabled": { + "description": "Whether automatic prompt-cache injection is active for this model.", + "type": "boolean" + }, + "ttl": { + "allOf": [ + { + "$ref": "#/definitions/CacheTtl" + } + ], + "description": "Cache lifetime for injected breakpoints: `5m` (default when omitted) or `1h`. A `1h` cache write costs 2x the base input rate versus 1.25x for `5m`, so it pays off only when the cached prefix is reused across a longer session. Some Claude models on Amazon Bedrock accept only the 5-minute cache, and Bedrock rejects a request to such a model that asks for `1h`." + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "BackgroundModelCheck": { + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Whether background health checks are enabled for this model.", + "type": "boolean" + }, + "ignore_statuses": { + "description": "Upstream status codes to ignore when evaluating background check failures.", + "items": { + "format": "uint16", + "maximum": 599.0, + "minimum": 100.0, + "type": "integer" + }, + "type": "array" + }, + "interval_seconds": { + "description": "Seconds between background health checks. Minimum: 5.", + "format": "uint64", + "minimum": 5.0, + "type": "integer" + }, + "max_tokens": { + "description": "Maximum completion tokens requested during each background health check.", + "format": "uint32", + "minimum": 1.0, + "type": "integer" + }, + "prompt": { + "description": "Prompt sent to the model during each background health check.", + "minLength": 1, + "type": "string" + }, + "stale_after_seconds": { + "description": "Seconds after which the last completed background check is considered stale.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "timeout_seconds": { + "description": "Request timeout in seconds for each background health check. Minimum: 1.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "enabled", + "interval_seconds", + "max_tokens", + "prompt", + "stale_after_seconds", + "timeout_seconds" + ], + "type": "object" + }, + "CacheTtl": { + "description": "Cache lifetime for gateway-injected prompt-cache breakpoints.", + "enum": [ + "5m", + "1h" + ], + "type": "string" + }, + "ClassifierType": { + "description": "Hosted decision model a semantic router can use in place of embeddings.", + "oneOf": [ + { + "description": "TypeSafe's jev decision model.", + "enum": [ + "jev" + ], + "type": "string" + } + ] + }, + "CooldownConfig": { + "additionalProperties": false, + "description": "Request-path cooldown settings for a direct model after retryable upstream failures. Cooldown is opt-in: it runs only when `enabled` is `true`.", + "properties": { + "default_seconds": { + "description": "Cooldown TTL in seconds when the upstream did not supply a `Retry-After` header or `honor_retry_after` is `false`.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "enabled": { + "description": "Whether cooldown is active for this model. Cooldown is off unless this is set to `true`, so a model that omits it stays in rotation regardless of upstream failures.", + "type": "boolean" + }, + "honor_retry_after": { + "description": "Whether to use the upstream's `Retry-After` header as the cooldown TTL when it contains seconds.", + "type": "boolean" + }, + "max_seconds": { + "description": "Upper bound on cooldown TTL when `Retry-After` is used.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "trigger_on_timeout": { + "description": "Whether request-path timeouts trigger cooldown.", + "type": "boolean" + }, + "trigger_on_transport": { + "description": "Whether transport, decode, or stream-abort errors trigger cooldown.", + "type": "boolean" + }, + "trigger_statuses": { + "description": "Status codes that trigger cooldown, covering authentication failures, rate limits, and transient server errors. Caller-side validation errors such as `400`, `403`, and `422` are excluded.", + "items": { + "format": "uint16", + "maximum": 599.0, + "minimum": 100.0, + "type": "integer" + }, + "type": "array" + } + }, + "type": "object" + }, + "DistanceMetric": { + "description": "Distance metric used to compare the request embedding against route example embeddings. v1 supports cosine only.", + "oneOf": [ + { + "description": "Cosine similarity — higher is more similar.", + "enum": [ + "cosine" + ], + "type": "string" + } + ] + }, + "EmbeddingConfig": { + "additionalProperties": false, + "description": "Embedding-modality metadata for a direct Model.", + "properties": { + "dimensions": { + "description": "Output vector dimensionality. Used to validate vectors, key the example-vector cache, and (for endpoints that support it) request a reduced output size.", + "format": "uint32", + "minimum": 1.0, + "type": "integer" + }, + "normalize": { + "default": true, + "description": "Whether the endpoint already returns L2-normalized vectors. When `false`, the gateway normalizes before computing cosine similarity. Defaults to `true`.", + "type": "boolean" + } + }, + "required": [ + "dimensions" + ], + "type": "object" + }, + "EmbeddingFailureMode": { + "description": "Mode for an enum-only failure policy: route to the router's `default`, or fail the request with `503`.", + "oneOf": [ + { + "description": "Route to the semantic router's `default` model.", + "enum": [ + "default" + ], + "type": "string" + }, + { + "description": "Reject the request with `503`.", + "enum": [ + "fail" + ], + "type": "string" + } + ] + }, + "EnsembleConfig": { + "additionalProperties": false, + "properties": { + "judge": { + "allOf": [ + { + "$ref": "#/definitions/Judge" + } + ], + "description": "Direct model that combines successful panel responses." + }, + "min_responses": { + "description": "Minimum successful panel responses required before judge synthesis. When omitted, the gateway requires the smaller of 2 and the panel size.", + "format": "uint32", + "minimum": 1.0, + "type": "integer" + }, + "panel": { + "description": "Direct models called concurrently for each ensemble request.", + "items": { + "$ref": "#/definitions/PanelMember" + }, + "minItems": 1, + "type": "array" + }, + "timeout_ms": { + "description": "Per-call upstream deadline applied to each panel member and the judge. Set `0` or omit it to disable the ensemble-level deadline.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "judge", + "panel" + ], + "type": "object" + }, + "HashOnSource": { + "additionalProperties": false, + "description": "One source for the `consistent_hash` hash key. Sources are tried in order; the first one that yields a non-empty value wins.", + "properties": { + "name": { + "description": "The header or cookie name to read. Required for `header` and `cookie` sources; not accepted for `api_key` or `client_ip`.", + "minLength": 1, + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/HashOnType" + } + ], + "description": "Which request attribute supplies the hash key." + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "HashOnType": { + "description": "Which request attribute a [`HashOnSource`] reads the hash key from.", + "oneOf": [ + { + "description": "A request header, named by `name`.", + "enum": [ + "header" + ], + "type": "string" + }, + { + "description": "A cookie from the request's `Cookie` header, named by `name`.", + "enum": [ + "cookie" + ], + "type": "string" + }, + { + "description": "The caller's API key id.", + "enum": [ + "api_key" + ], + "type": "string" + }, + { + "description": "The caller's resolved client IP (honouring the trusted-proxy configuration).", + "enum": [ + "client_ip" + ], + "type": "string" + } + ] + }, + "Judge": { + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "model": { + "type": "string" + } + }, + "required": [ + "model" + ] + }, + { + "properties": { + "model_id": { + "type": "string" + } + }, + "required": [ + "model_id" + ] + } + ] + } + ], + "description": "The judge model that synthesizes the panel responses into one answer. The judge model is named either by `model` (its display name) or by `model_id` (its resource id); the judge must carry at least one of the two.", + "properties": { + "model": { + "description": "Model alias for the direct model that synthesizes panel responses. Read only when `model_id` is absent.", + "minLength": 1, + "type": "string" + }, + "model_id": { + "description": "Resource id of the direct model that synthesizes panel responses. Present, it is authoritative and `model` is ignored: the id is resolved against the models in the current configuration, so renaming that model keeps the judge pointing at it with no edit to this document. An id resolving to no model is a judge that does not exist, exactly as a `model` naming no model is.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "synthesis_prompt": { + "description": "Override for the built-in synthesis prompt template.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "ModelCost": { + "additionalProperties": false, + "description": "Per-token cost for budget tracking. Both values are in USD per 1,000 tokens.", + "properties": { + "input_per_1k": { + "description": "Prompt token cost in USD per 1,000 tokens.", + "format": "double", + "minimum": 0.0, + "type": "number" + }, + "output_per_1k": { + "description": "Completion token cost in USD per 1,000 tokens.", + "format": "double", + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "input_per_1k", + "output_per_1k" + ], + "type": "object" + }, + "OnEmbeddingFailure": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/definitions/EmbeddingFailureMode" + } + ], + "description": "`\"default\"` or `\"fail\"`." + }, + { + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "target": { + "type": "string" + } + }, + "required": [ + "target" + ] + }, + { + "properties": { + "target_id": { + "type": "string" + } + }, + "required": [ + "target_id" + ] + } + ] + } + ], + "description": "`{ \"target\": \"\" }` (or `{ \"target_id\": \"\" }`) — route to a specific safe model.", + "properties": { + "target": { + "description": "Direct-model alias to route to when embedding fails. Read only when `target_id` is absent.", + "minLength": 1, + "type": "string" + }, + "target_id": { + "description": "Resource id of the direct model to route to when embedding fails. Present, it is authoritative and `target` is ignored, so renaming that model keeps this fallback pointing at it with no edit to this document.", + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + ], + "description": "What the router does when the embedding call errors or times out. Use `\"default\"` to route to the router's default model, `\"fail\"` to reject the request with `503`, or `{ \"target\": \"\" }` to route to a specific fallback model." + }, + "PanelMember": { + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "model": { + "type": "string" + } + }, + "required": [ + "model" + ] + }, + { + "properties": { + "model_id": { + "type": "string" + } + }, + "required": [ + "model_id" + ] + } + ] + } + ], + "description": "One member of an ensemble panel. The member model is named either by `model` (its display name) or by `model_id` (its resource id); a member must carry at least one of the two.", + "properties": { + "model": { + "description": "Model alias for a direct model that receives one panel request. Read only when `model_id` is absent.", + "minLength": 1, + "type": "string" + }, + "model_id": { + "description": "Resource id of the direct model that receives one panel request. Present, it is authoritative and `model` is ignored: the id is resolved against the models in the current configuration, so renaming that model keeps this member pointing at it with no edit to this document. An id resolving to no model is a panel member that does not exist, exactly as a `model` naming no model is.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "seed": { + "description": "Sampling seed for this panel member.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "temperature": { + "description": "Sampling temperature for this panel member. Omit it to keep the request's temperature.", + "format": "float", + "minimum": 0.0, + "type": "number" + }, + "weight": { + "description": "Reserved for a future voting/quorum strategy. AISIX currently ignores this field.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + }, + "RateLimit": { + "additionalProperties": false, + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "rpd": { + "description": "Requests per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rph": { + "description": "Requests per 3,600-second window. There is no per-hour token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rpm": { + "description": "Requests per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rps": { + "description": "Requests per 1-second window. There is no per-second token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tpd": { + "description": "Tokens per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tpm": { + "description": "Tokens per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + }, + "Routing": { + "additionalProperties": false, + "properties": { + "fallback_on_statuses": { + "description": "Additional upstream HTTP status codes that participate in retries and failover. By default a non-429 4xx response is treated as a caller error and returned as-is; providers that use 4xx codes for transient conditions (model overload, queue full, quota exhaustion) can be listed here, for example `[408, 409]`. 5xx codes are already retryable, so listing them changes nothing. Authentication (`401`/`403`) and validation (`400`) codes should only be listed when the provider is known to use them for transient failures.", + "items": { + "format": "uint16", + "maximum": 599.0, + "minimum": 400.0, + "type": "integer" + }, + "type": "array" + }, + "hash_on": { + "description": "Where the `consistent_hash` hash key comes from: an ordered chain of sources, the first non-empty value winning. Defaults to the `x-aisix-routing-key` request header, falling back to the caller's API key id. Only valid with `strategy: consistent_hash`.", + "items": { + "$ref": "#/definitions/HashOnSource" + }, + "minItems": 1, + "type": "array" + }, + "max_fallbacks": { + "description": "Max number of later targets to attempt after the initial target fails permanently. When omitted, all later targets may be attempted.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "retries": { + "description": "Retry attempts on the current target before failing over, applied to every target that does not set its own `retries`. Absent falls back to the deployment-wide `upstream.retries` default.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "retry_on_429": { + "description": "Whether upstream 429 participates in retries and failover.", + "type": "boolean" + }, + "strategy": { + "allOf": [ + { + "$ref": "#/definitions/RoutingStrategy" + } + ], + "default": "failover", + "description": "Strategy used to select a target for each request." + }, + "targets": { + "description": "Ordered set of direct models available to this routing model.", + "items": { + "$ref": "#/definitions/RoutingTarget" + }, + "minItems": 1, + "type": "array" + }, + "when_all_unavailable": { + "allOf": [ + { + "$ref": "#/definitions/WhenAllUnavailablePolicy" + } + ], + "description": "Policy to apply when every target is unavailable because of runtime health or cooldown state." + } + }, + "required": [ + "targets" + ], + "type": "object" + }, + "RoutingStrategy": { + "oneOf": [ + { + "description": "Smooth weighted round-robin over target `weight`s. Equal (or absent) weights degrade to a plain declaration-order cycle.", + "enum": [ + "round_robin" + ], + "type": "string" + }, + { + "description": "Ketama-style consistent hashing of the request's hash key (see `hash_on`) over the targets, `weight` scaling each target's share of the ring. The same key keeps landing on the same target while it is healthy; on failure the walk follows the ring so only the failed target's keys move.", + "enum": [ + "consistent_hash" + ], + "type": "string" + }, + { + "description": "Always start with the first target and move to later targets only after failure.", + "enum": [ + "failover" + ], + "type": "string" + }, + { + "description": "Rank targets cheapest-first by the target model's resolved price (combined input+output per-1K), then fall forward. A target is priced by the `pricing` document its `pricing_key` names, or by its inline `cost` when it names none; a target with no price at all ranks last.", + "enum": [ + "least_cost" + ], + "type": "string" + }, + { + "description": "Rank targets fastest-first by a moving average of recent observed upstream latency (time-to-first-token for streaming), then fall forward. Targets with no samples yet rank first so they get probed.", + "enum": [ + "least_latency" + ], + "type": "string" + }, + { + "description": "Rank targets least-loaded-first by in-flight requests divided by target `weight` (the APISIX least_conn score), then fall forward.", + "enum": [ + "least_busy" + ], + "type": "string" + } + ] + }, + "RoutingTarget": { + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "model": { + "type": "string" + } + }, + "required": [ + "model" + ] + }, + { + "properties": { + "model_id": { + "type": "string" + } + }, + "required": [ + "model_id" + ] + } + ] + } + ], + "description": "One destination in a routing configuration. The target model is named either by `model` (its display name) or by `model_id` (its resource id); a target must carry at least one of the two.", + "properties": { + "model": { + "description": "Model alias for a direct model that can receive routed traffic. Read only when `model_id` is absent.\n\nDefaulted at the type level and required by the schemas instead — as one half of a \"`model` or `model_id`\" alternative, so a target naming its model by id alone validates while one naming it neither way is still rejected.", + "minLength": 1, + "type": "string" + }, + "model_id": { + "description": "Resource id of the direct model that can receive routed traffic. Present, it is authoritative and `model` is ignored: the id is resolved against the models in the current configuration and the name it resolves to is the target, so renaming that model keeps this target pointing at it with no edit to this document. An id resolving to no model is a target that does not exist, exactly as a `model` naming no model is.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "priority": { + "description": "Priority tier, default `0`; a higher value is preferred (the APISIX node-priority convention — give backup targets `-1`). The strategy orders targets within each tier; a lower tier is only tried when every higher-tier target failed or is unavailable.", + "format": "int32", + "type": "integer" + }, + "tags": { + "description": "Tags for tag/metadata-conditional routing. When a request carries routing tags, only targets whose tags intersect the request's are eligible; a target tagged `\"default\"` is the fallback used when nothing matches and for untagged requests. Absent/empty means the target opts out of tag filtering (eligible only via the default fallback once any sibling target is tagged). The configured strategy then orders whatever set survives.", + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "weight": { + "description": "Target weight, default `1`. Used by `round_robin` (rotation share), `consistent_hash` (share of the hash ring), and `least_busy` (in-flight divided by weight). `failover`, `least_cost`, and `least_latency` accept the field but do not use it.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + }, + "Semantic": { + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "default": { + "type": "string" + } + }, + "required": [ + "default" + ] + }, + { + "properties": { + "default_id": { + "type": "string" + } + }, + "required": [ + "default_id" + ] + } + ] + }, + { + "else": { + "allOf": [ + { + "anyOf": [ + { + "properties": { + "embedding_model": { + "type": "string" + } + }, + "required": [ + "embedding_model" + ] + }, + { + "properties": { + "embedding_model_id": { + "type": "string" + } + }, + "required": [ + "embedding_model_id" + ] + } + ] + } + ], + "properties": { + "match": { + "type": "object" + }, + "routes": { + "items": { + "required": [ + "examples" + ] + } + } + }, + "required": [ + "match" + ] + }, + "if": { + "properties": { + "classifier": { + "type": "object" + } + }, + "required": [ + "classifier" + ] + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "embedding_model" + ] + }, + { + "properties": { + "embedding_model_id": { + "type": "string" + } + }, + "required": [ + "embedding_model_id" + ] + }, + { + "required": [ + "embedding_timeout_ms" + ] + }, + { + "required": [ + "on_embedding_failure" + ] + }, + { + "required": [ + "match" + ] + } + ] + }, + "properties": { + "routes": { + "items": { + "not": { + "anyOf": [ + { + "required": [ + "examples" + ] + }, + { + "required": [ + "threshold" + ] + } + ] + }, + "required": [ + "description" + ] + } + } + } + } + } + ], + "description": "Semantic-routing config: pick a target by request meaning.", + "properties": { + "classifier": { + "allOf": [ + { + "$ref": "#/definitions/SemanticClassifier" + } + ], + "description": "Route selection by a hosted decision model instead of embeddings. When set, `embedding_model`, `embedding_model_id`, `embedding_timeout_ms`, `on_embedding_failure` and `match` are not accepted, every route needs a `description` and takes no `examples` or `threshold`. When omitted, routes are matched by embedding similarity." + }, + "default": { + "description": "Direct model alias used when no route clears its threshold. Read only when `default_id` is absent.", + "minLength": 1, + "type": "string" + }, + "default_id": { + "description": "Resource id of the direct model used when no route clears its threshold. Present, it is authoritative and `default` is ignored, so renaming that model keeps this router pointing at it with no edit to this document.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "embedding_model": { + "description": "Alias of an `embedding`-modality Model used to embed the request and, lazily on the first request that needs them, the route examples. Read only when `embedding_model_id` is absent. Required, under this name or as `embedding_model_id`, when there is no `classifier`.", + "minLength": 1, + "type": "string" + }, + "embedding_model_id": { + "description": "Resource id of the `embedding`-modality Model used to embed the request and the route examples. Present, it is authoritative and `embedding_model` is ignored: the id is resolved against the models in the current configuration, so renaming that model keeps this router pointing at it with no edit to this document. An id resolving to no model is an embedding model that does not exist, exactly as an `embedding_model` naming no model is — the router applies `on_embedding_failure`.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "embedding_timeout_ms": { + "description": "Per-call deadline for the embedding request in milliseconds. `0` or absent disables the embedding-specific deadline.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "match": { + "allOf": [ + { + "$ref": "#/definitions/SemanticMatch" + } + ], + "description": "Shared matching parameters (metric, aggregation, default threshold). Required when there is no `classifier`." + }, + "on_embedding_failure": { + "allOf": [ + { + "$ref": "#/definitions/OnEmbeddingFailure" + } + ], + "description": "Behavior when the embedding call fails or times out. Defaults to routing to `default`." + }, + "routes": { + "description": "Routes evaluated for each request. At least one is required.", + "items": { + "$ref": "#/definitions/SemanticRoute" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "routes" + ], + "type": "object" + }, + "SemanticClassifier": { + "additionalProperties": false, + "description": "Route selection by a hosted decision model. For each request the latest user message is sent to the model together with every route's `name` and `description`, and the model picks one route. A request goes to the picked route's target, or to the router's `default` when the model's confidence in its pick is below `min_confidence`. To catch requests that fit none of the routes, configure a route for them.", + "properties": { + "min_confidence": { + "default": 0.5, + "description": "Lowest confidence at which the picked route is used. A decision below it sends the request to `default`.", + "format": "double", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + "model": { + "default": "jev-latest", + "description": "Decision model to call.", + "minLength": 1, + "type": "string" + }, + "on_failure": { + "allOf": [ + { + "$ref": "#/definitions/OnEmbeddingFailure" + } + ], + "description": "Behavior when the decision call fails, times out, or returns an answer that names no route. Same values as `on_embedding_failure`." + }, + "provider_key_id": { + "description": "Resource id of the Provider Key that authenticates the decision calls. The key's `provider` must be `typesafe`; its API key is sent as the bearer token, and its `api_base`, when set, replaces the default endpoint `https://api.typesafe.ai`.", + "minLength": 1, + "type": "string" + }, + "timeout_ms": { + "default": 3000, + "description": "Deadline for the decision call, in milliseconds.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/ClassifierType" + } + ], + "description": "Decision model family. `jev` is the only supported value." + } + }, + "required": [ + "provider_key_id", + "type" + ], + "type": "object" + }, + "SemanticMatch": { + "additionalProperties": false, + "description": "Matching parameters shared across every route in a semantic router.", + "properties": { + "aggregation": { + "allOf": [ + { + "$ref": "#/definitions/Aggregation" + } + ], + "default": "max", + "description": "Per-example score aggregation. v1: max." + }, + "distance_metric": { + "allOf": [ + { + "$ref": "#/definitions/DistanceMetric" + } + ], + "default": "cosine", + "description": "Similarity metric. v1: cosine." + }, + "threshold": { + "description": "Default similarity threshold for routes that do not set their own `threshold`. Higher is stricter.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "threshold" + ], + "type": "object" + }, + "SemanticRoute": { + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "target": { + "type": "string" + } + }, + "required": [ + "target" + ] + }, + { + "properties": { + "target_id": { + "type": "string" + } + }, + "required": [ + "target_id" + ] + } + ] + } + ], + "description": "One semantic route: a labeled set of example utterances whose embeddings define the route. A request that scores high enough against them dispatches to `target`.", + "properties": { + "description": { + "description": "What requests this route handles. With a `classifier`, this is the text the decision model reads to pick the route, and it is required. Without one, it is documentation only: routes are matched on `examples`.", + "minLength": 1, + "type": "string" + }, + "examples": { + "description": "Example utterances that define this route. AISIX caches their vectors lazily. A cold route request passes its prompt and every distinct uncached example text as one logical embedding batch. Providers without batch support may issue multiple upstream calls (Bedrock Titan does). Required, with at least one example, when the router has no `classifier`; not accepted when it has one.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "name": { + "description": "Operator-facing route label. Surfaced in access logs (e.g. `prod-chat -> route:legal`), and in the `x-aisix-route` response header only when `proxy.expose_debug_headers` is enabled.", + "minLength": 1, + "type": "string" + }, + "target": { + "description": "Direct model alias that receives traffic matching this route. Read only when `target_id` is absent.", + "minLength": 1, + "type": "string" + }, + "target_id": { + "description": "Resource id of the direct model that receives traffic matching this route. Present, it is authoritative and `target` is ignored: the id is resolved against the models in the current configuration, so renaming that model keeps this route pointing at it with no edit to this document. An id resolving to no model is a route target that does not exist, exactly as a `target` naming no model is.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "threshold": { + "description": "Per-route similarity threshold. A request matches this route only when its aggregated score is `>=` this value. When omitted, the router-level threshold applies. Not accepted with a `classifier`.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "WhenAllUnavailablePolicy": { + "description": "Behavior when every routing target is unavailable because of runtime health or cooldown state.", + "oneOf": [ + { + "description": "Return `503` with a fixed `Retry-After` hint.", + "enum": [ + "fail" + ], + "type": "string" + }, + { + "description": "Try every target in declaration order even when all of them are currently unavailable because of health or cooldown status. Use only when maintaining availability is preferred over avoiding recently unhealthy targets.", + "enum": [ + "try_anyway" + ], + "type": "string" + } + ] + } + }, + "oneOf": [ + { + "not": { + "anyOf": [ + { + "required": [ + "provider" + ] + }, + { + "required": [ + "model_name" + ] + }, + { + "required": [ + "provider_key_id" + ] + }, + { + "required": [ + "background_model_check" + ] + }, + { + "required": [ + "cooldown" + ] + }, + { + "required": [ + "ensemble" + ] + }, + { + "required": [ + "semantic" + ] + }, + { + "required": [ + "embedding" + ] + }, + { + "required": [ + "retries" + ] + }, + { + "required": [ + "auto_prompt_caching" + ] + }, + { + "required": [ + "cost" + ] + }, + { + "required": [ + "pricing_key" + ] + }, + { + "required": [ + "effort_mapping" + ] + } + ] + }, + "required": [ + "routing" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "routing" + ] + }, + { + "required": [ + "ensemble" + ] + }, + { + "required": [ + "semantic" + ] + }, + { + "required": [ + "embedding", + "effort_mapping" + ] + } + ] + }, + "required": [ + "provider", + "model_name", + "provider_key_id" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "provider" + ] + }, + { + "required": [ + "model_name" + ] + }, + { + "required": [ + "provider_key_id" + ] + }, + { + "required": [ + "routing" + ] + }, + { + "required": [ + "background_model_check" + ] + }, + { + "required": [ + "cooldown" + ] + }, + { + "required": [ + "semantic" + ] + }, + { + "required": [ + "embedding" + ] + }, + { + "required": [ + "timeout" + ] + }, + { + "required": [ + "stream_timeout" + ] + }, + { + "required": [ + "retries" + ] + }, + { + "required": [ + "auto_prompt_caching" + ] + }, + { + "required": [ + "cost" + ] + }, + { + "required": [ + "pricing_key" + ] + }, + { + "required": [ + "effort_mapping" + ] + } + ] + }, + "required": [ + "ensemble" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "provider" + ] + }, + { + "required": [ + "model_name" + ] + }, + { + "required": [ + "provider_key_id" + ] + }, + { + "required": [ + "routing" + ] + }, + { + "required": [ + "ensemble" + ] + }, + { + "required": [ + "background_model_check" + ] + }, + { + "required": [ + "cooldown" + ] + }, + { + "required": [ + "embedding" + ] + }, + { + "required": [ + "auto_prompt_caching" + ] + }, + { + "required": [ + "cost" + ] + }, + { + "required": [ + "pricing_key" + ] + }, + { + "required": [ + "effort_mapping" + ] + } + ] + }, + "required": [ + "semantic" + ] + } + ], + "properties": { + "allowed_cidrs": { + "description": "Client IP allowlist in CIDR notation. Empty or absent allows all clients.", + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "auto_prompt_caching": { + "allOf": [ + { + "$ref": "#/definitions/AutoPromptCaching" + } + ], + "description": "Automatic prompt-cache breakpoint injection for Claude models on the Anthropic API, Amazon Bedrock and Vertex AI. Omit to leave injection off." + }, + "background_model_check": { + "allOf": [ + { + "$ref": "#/definitions/BackgroundModelCheck" + } + ], + "description": "Direct-model-only background health-check configuration." + }, + "cooldown": { + "allOf": [ + { + "$ref": "#/definitions/CooldownConfig" + } + ], + "description": "Direct-model-only request-path cooldown configuration. Cooldown is opt-in: omit this field, or leave `enabled` unset, and the model is never taken out of rotation by request-path failures." + }, + "cost": { + "allOf": [ + { + "$ref": "#/definitions/ModelCost" + } + ], + "description": "Per-token cost for budget tracking. Omit it when cost tracking is not needed." + }, + "display_name": { + "description": "Operator-facing unique label. Surfaces on `/v1/models`, `req.model` on chat completions, `ApiKey.allowed_models`, and the dashboard model list. `Resource::name()` returns this.", + "minLength": 1, + "type": "string" + }, + "effort_mapping": { + "additionalProperties": { + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "description": "Direct-model-only mapping from a client-requested reasoning effort to the value sent upstream. After resolving the final target the gateway looks the requested effort up once — the exact entry first, then the `*` entry — and never looks the result up again. A requested value that matches neither is sent unchanged.\n\nThree entries are reserved. The empty-string key matches a request that sets no effort at all, meaning the effort field is absent, `null`, or empty, and its value is added to the outbound request. The `*` key matches any other present value that has no entry of its own, and never matches a request that sets no effort. A `null` value removes the effort field from the outbound request so the provider's own default applies; mapping `*` to `null` therefore strips the effort from every request whose value has no entry of its own. The empty-string key may not be mapped to `null`, because a request that sets no effort has nothing to remove, and no entry may be mapped to an empty value.\n\nOn the Anthropic messages and token-counting endpoints only `output_config.effort` is read and rewritten. A `thinking` block is not an effort setting for this mapping, so a request that carries `thinking` but no `output_config.effort` sets no effort and the empty-string entry applies to it. When such a request is dispatched to a provider that does not accept the Anthropic protocol, the upstream reasoning effort is derived from the mapped `output_config.effort` when one is present and from `thinking` otherwise, except that an entry that removed the effort sends no effort at all. A request that sets `thinking.type: disabled` is never given an effort by this mapping: on the Anthropic protocol no entry writes a tier to it, though an entry that removes the effort still removes it, and on any other protocol it always sends the `none` effort.\n\nClaude families that reject `output_config.effort` (Haiku 4.5, Sonnet 4.5 and earlier) are the exception on those two endpoints when the model speaks the Anthropic protocol: an effort the empty-string entry would add to a request that sets none becomes `thinking: {type: enabled, budget_tokens}` instead (minimal and low 1024, medium 2048, high 4096, xhigh 8192, max 16384). The budget stays below the request's `max_tokens`, a request on the messages endpoint without `max_tokens` is sent the budget plus 4096, and a `max_tokens` of 1024 or less gets no thinking. A request that already carries a `thinking` block is sent as written, so the empty-string entry has no effect on it for these families, and a value with no budget is not sent at all.", + "properties": { + "": { + "description": "The entry for a request that sets no reasoning effort. Its value is sent upstream in place of the missing one, and may not be `null` — a request that sets no effort has no field to remove.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "embedding": { + "allOf": [ + { + "$ref": "#/definitions/EmbeddingConfig" + } + ], + "description": "Embedding-modality metadata. Present on direct Models that serve an OpenAI-compatible `/v1/embeddings` endpoint (and can be referenced by a semantic router's `embedding_model`)." + }, + "ensemble": { + "allOf": [ + { + "$ref": "#/definitions/EnsembleConfig" + } + ], + "description": "Ensemble configuration for panel calls and judge synthesis." + }, + "model_name": { + "description": "Upstream model identifier sent in provider requests. Routing and ensemble models leave this field unset.", + "minLength": 1, + "type": "string" + }, + "pricing_key": { + "description": "Name of a shared `pricing` document to take the per-token cost from, instead of setting `cost` on this model. The environment's own pricing documents are searched first and the shared catalog second; when the key matches neither, `cost` applies. Editing the pricing document repricies every model naming it, with no change to the models themselves.", + "maxLength": 255, + "minLength": 1, + "type": "string" + }, + "provider": { + "description": "Upstream vendor identity used for dispatch, compatibility checks, telemetry, and access logs. Routing and ensemble models leave this field unset.", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9][a-z0-9._-]*$", + "type": "string" + }, + "provider_key_id": { + "description": "Provider key resource ID used to authenticate upstream requests. Routing and ensemble models leave this field unset.", + "minLength": 1, + "type": "string" + }, + "rate_limit": { + "allOf": [ + { + "$ref": "#/definitions/RateLimit" + } + ], + "description": "Request, token, and concurrency limits for this model." + }, + "retries": { + "description": "Retry attempts against this model after a retryable upstream failure, before the request gives up (or, inside a model group, fails over to the next target). Absent falls back to the group's `routing.retries`, then to the deployment-wide `upstream.retries` default.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "routing": { + "allOf": [ + { + "$ref": "#/definitions/Routing" + } + ], + "description": "Virtual routing configuration. When set, the gateway selects a target from `routing.targets` and uses that target model's `provider`, `model_name`, and `provider_key_id` fields for upstream dispatch." + }, + "semantic": { + "allOf": [ + { + "$ref": "#/definitions/Semantic" + } + ], + "description": "Semantic-routing configuration. When set, the gateway embeds the request and dispatches to the route whose examples it matches best, using that route's target Model for upstream dispatch." + }, + "stream_timeout": { + "description": "Maximum gap in milliseconds between upstream streaming chunks. `0` or absent falls back to the group's `stream_timeout`, then to the model's (or group's) `timeout`, then to the deployment-wide `upstream.stream_timeout_ms` / `timeout_ms` defaults.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "timeout": { + "description": "End-to-end timeout in milliseconds for non-streaming upstream calls. Absent falls back to the group's `timeout`, then to the deployment-wide `upstream.timeout_ms` default. `0` disables the non-streaming timeout for this model.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "display_name" + ], + "title": "Model", + "type": "object" +} diff --git a/third_party/aisix/schemas/observability_exporter.schema.json b/third_party/aisix/schemas/observability_exporter.schema.json new file mode 100644 index 0000000..ca69301 --- /dev/null +++ b/third_party/aisix/schemas/observability_exporter.schema.json @@ -0,0 +1,645 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ObjectStoreAuthMode": { + "description": "How the gateway obtains credentials for the object-storage bucket.", + "oneOf": [ + { + "description": "Resolve `credential_ref` to static keys from gateway environment variables named `OBJSTORE_CRED__`.", + "enum": [ + "credential_ref" + ], + "type": "string" + }, + { + "description": "Use the gateway host's attached cloud identity. Supported for S3 and GCS only.", + "enum": [ + "cloud_identity" + ], + "type": "string" + } + ] + }, + "ObjectStoreCompression": { + "description": "File compression for object-storage uploads.", + "oneOf": [ + { + "description": "gzip compression as defined by RFC 1952. Accepted by Snowpipe and Auto Loader.", + "enum": [ + "gzip" + ], + "type": "string" + }, + { + "description": "No compression. Emits raw NDJSON.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "ObjectStoreProvider": { + "description": "Object-storage backend selector. The sink builds one backend client per variant. Batching, key layout, and retry behavior are shared.", + "enum": [ + "s3", + "gcs", + "azure_blob" + ], + "type": "string" + }, + "SlsContentMode": { + "description": "Content-capture mode for an observability exporter.", + "oneOf": [ + { + "description": "Operational metadata only. Prompt and response content are omitted.", + "enum": [ + "metadata_only" + ], + "type": "string" + }, + { + "description": "Metadata plus the captured request prompt and assembled response.", + "enum": [ + "full" + ], + "type": "string" + } + ] + } + }, + "description": "Telemetry exporter configuration.", + "oneOf": [ + { + "additionalProperties": false, + "description": "OTLP/HTTP trace exporter configuration.", + "properties": { + "content_max_bytes": { + "default": 131072, + "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`.", + "format": "uint32", + "maximum": 1048576.0, + "minimum": 1.0, + "type": "integer" + }, + "content_mode": { + "allOf": [ + { + "$ref": "#/definitions/SlsContentMode" + } + ], + "default": "metadata_only", + "description": "Controls whether spans include prompt and response content. `metadata_only` omits content. `full` includes content truncated by `content_max_bytes`." + }, + "enabled": { + "default": true, + "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", + "type": "boolean" + }, + "endpoint": { + "description": "Full `http://` or `https://` URL of the OTLP/HTTP traces endpoint. Include the receiver's expected path, such as `/v1/traces`.", + "pattern": "^https?://.+", + "type": "string" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Static headers attached to every export request, such as authorization or vendor-specific API-key headers.", + "type": "object" + }, + "kind": { + "enum": [ + "otlp_http" + ], + "type": "string" + }, + "name": { + "description": "Operator-facing label, surfaced in logs and dashboard lists. The etcd key UUID is the resource identity.", + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "request_headers": { + "description": "Request headers whose values this exporter records, by lowercase name. A header the request did not send is left out; a header sent more than once is joined with `, `. Values are stripped of control characters and capped at 256 characters. Credential headers (such as `authorization`, `x-api-key` and `cookie`) and duplicate names are refused. Recording a header never changes what is forwarded upstream. When omitted, no request header is recorded.", + "items": { + "not": { + "enum": [ + "api-key", + "authorization", + "cookie", + "proxy-authorization", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "x-aisix-api-key" + ] + }, + "pattern": "^[a-z0-9!#$%&'*+.^_`|~-]+$", + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "sample_rate": { + "description": "Fraction of requests exported as traces, from `0.0` to `1.0`.", + "format": "double", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + "upstream_response_headers": { + "description": "Upstream response headers whose values this exporter records for each attempt, by lowercase name, read off that attempt's upstream response. Values follow the same rules as `request_headers`. Credential headers, `set-cookie` and duplicate names are refused. When omitted, no upstream response header is recorded.", + "items": { + "not": { + "enum": [ + "api-key", + "authorization", + "cookie", + "proxy-authorization", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "set-cookie", + "x-aisix-api-key" + ] + }, + "pattern": "^[a-z0-9!#$%&'*+.^_`|~-]+$", + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "endpoint", + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Aliyun SLS PutLogs exporter configuration.", + "properties": { + "content_max_bytes": { + "default": 131072, + "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`.", + "format": "uint32", + "maximum": 1048576.0, + "minimum": 1.0, + "type": "integer" + }, + "content_mode": { + "allOf": [ + { + "$ref": "#/definitions/SlsContentMode" + } + ], + "default": "metadata_only", + "description": "Controls whether logs include prompt and response content. `metadata_only` omits content. `full` includes content truncated by `content_max_bytes`." + }, + "credential_ref": { + "description": "Credential reference resolved by the gateway at delivery time. The plaintext AccessKey is not stored in this resource.", + "minLength": 1, + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", + "type": "boolean" + }, + "endpoint": { + "description": "SLS regional endpoint host without a scheme, such as `ap-southeast-3.log.aliyuncs.com`. Signed requests are sent to this endpoint with the SLS project as the host prefix.", + "pattern": "^[a-z0-9][a-z0-9.-]*\\.aliyuncs\\.com$|^http://(mock-sls|127\\.0\\.0\\.1|localhost)(:[0-9]+)?$", + "type": "string" + }, + "kind": { + "enum": [ + "aliyun_sls" + ], + "type": "string" + }, + "logstore": { + "description": "SLS logstore that receives the request-event logs.", + "minLength": 1, + "type": "string" + }, + "name": { + "description": "Operator-facing label, surfaced in logs and dashboard lists. The etcd key UUID is the resource identity.", + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "project": { + "description": "SLS project that prefixes the regional endpoint in signed requests.", + "minLength": 1, + "type": "string" + }, + "request_headers": { + "description": "Request headers whose values this exporter records, by lowercase name. A header the request did not send is left out; a header sent more than once is joined with `, `. Values are stripped of control characters and capped at 256 characters. Credential headers (such as `authorization`, `x-api-key` and `cookie`) and duplicate names are refused. Recording a header never changes what is forwarded upstream. When omitted, no request header is recorded.", + "items": { + "not": { + "enum": [ + "api-key", + "authorization", + "cookie", + "proxy-authorization", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "x-aisix-api-key" + ] + }, + "pattern": "^[a-z0-9!#$%&'*+.^_`|~-]+$", + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "upstream_response_headers": { + "description": "Upstream response headers whose values this exporter records for each attempt, by lowercase name, read off that attempt's upstream response. Values follow the same rules as `request_headers`. Credential headers, `set-cookie` and duplicate names are refused. When omitted, no upstream response header is recorded.", + "items": { + "not": { + "enum": [ + "api-key", + "authorization", + "cookie", + "proxy-authorization", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "set-cookie", + "x-aisix-api-key" + ] + }, + "pattern": "^[a-z0-9!#$%&'*+.^_`|~-]+$", + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "credential_ref", + "endpoint", + "kind", + "logstore", + "project" + ], + "type": "object" + }, + { + "additionalProperties": false, + "allOf": [ + { + "else": { + "required": [ + "credential_ref" + ] + }, + "if": { + "properties": { + "auth_mode": { + "const": "cloud_identity" + } + }, + "required": [ + "auth_mode" + ] + }, + "then": { + "properties": { + "provider": { + "enum": [ + "s3", + "gcs" + ] + } + } + } + } + ], + "description": "Object-storage exporter configuration for S3, GCS, Azure Blob, and compatible S3 backends.", + "properties": { + "auth_mode": { + "allOf": [ + { + "$ref": "#/definitions/ObjectStoreAuthMode" + } + ], + "default": "credential_ref", + "description": "How the gateway authenticates to the bucket." + }, + "bucket": { + "description": "Bucket for S3 or GCS, or container for Azure Blob, that receives exported files.", + "minLength": 1, + "type": "string" + }, + "compression": { + "allOf": [ + { + "$ref": "#/definitions/ObjectStoreCompression" + } + ], + "default": "gzip", + "description": "Compression applied to each NDJSON file before upload." + }, + "credential_ref": { + "description": "Credential reference resolved by the gateway at delivery time. Required when `auth_mode` is `credential_ref`.", + "minLength": 1, + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", + "type": "boolean" + }, + "endpoint": { + "description": "Backend endpoint override, an `http://` or `https://` URL: an S3-compatible host such as MinIO, Aliyun OSS, or Cloudflare R2, a Cloud Storage XML API base URL such as a private endpoint, or an Azure Blob endpoint. A `gcs` endpoint must implement the Cloud Storage XML API and accept the percent-encoded object names the gateway sends; the stock fake-gcs-server image does not, so it cannot receive objects. When omitted, the provider's native endpoint is used.", + "pattern": "^https?://.+", + "type": "string" + }, + "kind": { + "enum": [ + "object_store" + ], + "type": "string" + }, + "name": { + "description": "Operator-facing label, surfaced in logs and dashboard lists. The etcd key UUID is the resource identity.", + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "prefix": { + "description": "Key prefix the partition path is appended to, e.g. `ai-gateway`. The full key is `/org=…/env=…/table=…/dt=…/hh=…/`.", + "minLength": 1, + "type": "string" + }, + "provider": { + "allOf": [ + { + "$ref": "#/definitions/ObjectStoreProvider" + } + ], + "description": "Which object-storage backend the bucket lives in." + }, + "region": { + "description": "AWS region for S3 SigV4 signature scope. Set this for S3 buckets outside `us-east-1`. Ignored for GCS and Azure Blob.", + "minLength": 1, + "type": "string" + }, + "request_headers": { + "description": "Request headers whose values this exporter records, by lowercase name. A header the request did not send is left out; a header sent more than once is joined with `, `. Values are stripped of control characters and capped at 256 characters. Credential headers (such as `authorization`, `x-api-key` and `cookie`) and duplicate names are refused. Recording a header never changes what is forwarded upstream. When omitted, no request header is recorded.", + "items": { + "not": { + "enum": [ + "api-key", + "authorization", + "cookie", + "proxy-authorization", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "x-aisix-api-key" + ] + }, + "pattern": "^[a-z0-9!#$%&'*+.^_`|~-]+$", + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "upstream_response_headers": { + "description": "Upstream response headers whose values this exporter records for each attempt, by lowercase name, read off that attempt's upstream response. Values follow the same rules as `request_headers`. Credential headers, `set-cookie` and duplicate names are refused. When omitted, no upstream response header is recorded.", + "items": { + "not": { + "enum": [ + "api-key", + "authorization", + "cookie", + "proxy-authorization", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "set-cookie", + "x-aisix-api-key" + ] + }, + "pattern": "^[a-z0-9!#$%&'*+.^_`|~-]+$", + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "bucket", + "kind", + "prefix", + "provider" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Datadog native Logs HTTP intake exporter configuration.", + "properties": { + "content_max_bytes": { + "default": 131072, + "description": "Maximum bytes per captured prompt or response field when `content_mode` is `full`. Keep this under Datadog intake limits to avoid delivery errors.", + "format": "uint32", + "maximum": 1048576.0, + "minimum": 1.0, + "type": "integer" + }, + "content_mode": { + "allOf": [ + { + "$ref": "#/definitions/SlsContentMode" + } + ], + "default": "metadata_only", + "description": "Controls whether logs include prompt and response content. `metadata_only` omits content. `full` includes content truncated by `content_max_bytes`." + }, + "credential_ref": { + "description": "Credential reference resolved by the gateway at delivery time. The plaintext Datadog API key is not stored in this resource.", + "minLength": 1, + "type": "string" + }, + "ddsource": { + "default": "aisix-ai-gateway", + "description": "Datadog `ddsource` reserved attribute. Identifies the integration or source.", + "minLength": 1, + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", + "type": "boolean" + }, + "kind": { + "enum": [ + "datadog" + ], + "type": "string" + }, + "name": { + "description": "Operator-facing label, surfaced in logs and dashboard lists. The etcd key UUID is the resource identity.", + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "request_headers": { + "description": "Request headers whose values this exporter records, by lowercase name. A header the request did not send is left out; a header sent more than once is joined with `, `. Values are stripped of control characters and capped at 256 characters. Credential headers (such as `authorization`, `x-api-key` and `cookie`) and duplicate names are refused. Recording a header never changes what is forwarded upstream. When omitted, no request header is recorded.", + "items": { + "not": { + "enum": [ + "api-key", + "authorization", + "cookie", + "proxy-authorization", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "x-aisix-api-key" + ] + }, + "pattern": "^[a-z0-9!#$%&'*+.^_`|~-]+$", + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "service": { + "description": "Datadog `service` reserved attribute. Every log from this exporter is tagged with this service name in Datadog Log Explorer.", + "minLength": 1, + "type": "string" + }, + "site": { + "description": "Datadog site, such as `datadoghq.com`, `us3.datadoghq.com`, or `datadoghq.eu`.", + "pattern": "^(datadoghq\\.com|us3\\.datadoghq\\.com|us5\\.datadoghq\\.com|datadoghq\\.eu|ap1\\.datadoghq\\.com|ap2\\.datadoghq\\.com|ddog-gov\\.com)$|^(mock-datadog|127\\.0\\.0\\.1|localhost)(:[0-9]+)?$", + "type": "string" + }, + "tags": { + "default": [], + "description": "Operator-defined tags rendered into Datadog's comma-joined `ddtags` reserved attribute. For example, `[\"team:platform\", \"tier:prod\"]` becomes `team:platform,tier:prod`. Leave empty when no tags should be sent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "upstream_response_headers": { + "description": "Upstream response headers whose values this exporter records for each attempt, by lowercase name, read off that attempt's upstream response. Values follow the same rules as `request_headers`. Credential headers, `set-cookie` and duplicate names are refused. When omitted, no upstream response header is recorded.", + "items": { + "not": { + "enum": [ + "api-key", + "authorization", + "cookie", + "proxy-authorization", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "set-cookie", + "x-aisix-api-key" + ] + }, + "pattern": "^[a-z0-9!#$%&'*+.^_`|~-]+$", + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "credential_ref", + "kind", + "service", + "site" + ], + "type": "object" + } + ], + "properties": { + "enabled": { + "default": true, + "description": "Whether this exporter is active. Disabled exporters remain configured but do not receive telemetry.", + "type": "boolean" + }, + "name": { + "description": "Operator-facing label, surfaced in logs and dashboard lists. The etcd key UUID is the resource identity.", + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "request_headers": { + "description": "Request headers whose values this exporter records, by lowercase name. A header the request did not send is left out; a header sent more than once is joined with `, `. Values are stripped of control characters and capped at 256 characters. Credential headers (such as `authorization`, `x-api-key` and `cookie`) and duplicate names are refused. Recording a header never changes what is forwarded upstream. When omitted, no request header is recorded.", + "items": { + "not": { + "enum": [ + "api-key", + "authorization", + "cookie", + "proxy-authorization", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "x-aisix-api-key" + ] + }, + "pattern": "^[a-z0-9!#$%&'*+.^_`|~-]+$", + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "upstream_response_headers": { + "description": "Upstream response headers whose values this exporter records for each attempt, by lowercase name, read off that attempt's upstream response. Values follow the same rules as `request_headers`. Credential headers, `set-cookie` and duplicate names are refused. When omitted, no upstream response header is recorded.", + "items": { + "not": { + "enum": [ + "api-key", + "authorization", + "cookie", + "proxy-authorization", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + "x-api-key", + "x-goog-api-key", + "set-cookie", + "x-aisix-api-key" + ] + }, + "pattern": "^[a-z0-9!#$%&'*+.^_`|~-]+$", + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "name" + ], + "title": "ObservabilityExporter", + "type": "object" +} diff --git a/third_party/aisix/schemas/oidc_provider.schema.json b/third_party/aisix/schemas/oidc_provider.schema.json new file mode 100644 index 0000000..0408e50 --- /dev/null +++ b/third_party/aisix/schemas/oidc_provider.schema.json @@ -0,0 +1,136 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "BoundClaimExpect": { + "anyOf": [ + { + "description": "The claim must equal (or, for array claims, contain) this value.", + "type": "string" + }, + { + "description": "The claim must equal (or, for array claims, contain) at least one of these values.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + ], + "description": "Expected value(s) for one bound claim: a single string, or a list matched as any-of." + }, + "OidcLogin": { + "additionalProperties": false, + "description": "The OAuth client the gateway signs users in with at this identity provider, for MCP clients that present no gateway credential.", + "properties": { + "client_id": { + "description": "Client identifier registered at the identity provider. Its redirect URI is the gateway's public address followed by `/mcp-oauth/login/callback`.", + "minLength": 1, + "type": "string" + }, + "client_secret": { + "description": "Client secret, sent to the token endpoint in the request body. Omit it for a public client.", + "minLength": 1, + "type": "string" + }, + "scopes": { + "description": "Scopes to request beside `openid` and the provider's `required_scopes`.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "client_id" + ], + "type": "object" + } + }, + "properties": { + "audiences": { + "description": "Accepted `aud` values. The token's audience (a string or an array) must contain at least one of these. Required unless `hmac_secret` is set; when a shared-secret provider omits it, the `aud` claim is ignored.\n\nAn empty list means exactly what omitting the field means — it carries no `minItems`, deliberately. The runtime reads \"no accepted audiences\" off `is_empty()`, so a schema that rejected `[]` would make the two planes disagree about what an empty list means and kill the whole row over a projection that spelled \"unset\" the other way. What stays rejected is a JWKS provider with no audiences at all, which `validate_semantics` owns.", + "items": { + "type": "string" + }, + "type": "array" + }, + "bound_claims": { + "additionalProperties": { + "$ref": "#/definitions/BoundClaimExpect" + }, + "description": "Additional claim requirements, all of which must hold. Keys name claims (read as one claim of exactly that name, then with dots traversing nested objects, e.g. `\"realm_access.roles\"`); each requirement is satisfied when the claim equals — or, for array claims, contains — one of the expected values.", + "type": "object" + }, + "enabled": { + "default": true, + "description": "Whether the provider participates in JWT authentication. A disabled provider is kept but ignored. Treated as `true` when omitted.", + "type": "boolean" + }, + "hmac_secret": { + "description": "Shared secret for HMAC-signed tokens, whose presence selects HMAC verification for this provider. Its UTF-8 bytes are the HMAC key as-is — nothing is base64-decoded or derived — and it must be at least 32 bytes long. Tokens are then accepted only under `HS256`, `HS384` or `HS512`, no signing keys are fetched, and `issuer` / `audiences` become optional. Omit it for a standard OIDC provider whose tokens are verified against a JWKS.", + "minLength": 1, + "type": "string" + }, + "identity_claim": { + "default": "sub", + "description": "Claim whose value selects the API key to act as: the request is bound to the key whose `jwt_subject` equals this claim's value. The value is first read as one claim of exactly this name, then with dots traversing nested objects (e.g. `\"resource_access.account\"`). Defaults to `sub`.", + "minLength": 1, + "type": "string" + }, + "issuer": { + "description": "Expected `iss` claim, compared byte-for-byte against the token's issuer. A JWT carrying this issuer is verified against this provider and no other. Required unless `hmac_secret` is set; when a shared-secret provider omits it, the token's `iss` claim is not consulted at all.", + "minLength": 1, + "type": "string" + }, + "jwks_uri": { + "description": "JWKS endpoint URL the signing keys are fetched from. When omitted, the endpoint is resolved once from the issuer's OIDC discovery document (`/.well-known/openid-configuration`) and cached. Must be absent when `hmac_secret` is set — a shared-secret provider fetches no keys.", + "minLength": 1, + "type": "string" + }, + "leeway_secs": { + "description": "Clock-skew allowance in seconds applied to time-based claims (`exp`, `nbf`). Defaults to 0.", + "format": "uint64", + "maximum": 300.0, + "minimum": 0.0, + "type": "integer" + }, + "login": { + "allOf": [ + { + "$ref": "#/definitions/OidcLogin" + } + ], + "description": "Signs users in at this provider when they connect an MCP client with no gateway credential to an MCP server with `auth_type: oauth2_authorization_code`. When exactly one enabled provider in the environment has it, every such server uses this sign-in; when none does, those servers keep relaying the client's sign-in and need a gateway credential. Usable only on an enabled provider with `issuer` and no `hmac_secret`, whose endpoints come from the issuer's OIDC discovery document. A second enabled provider with it, or one on a provider that cannot use it, makes sign-in fail with `mcp_oauth_misconfigured`. The gateway checks the ID token's audience against `client_id`, so `audiences` need not list it." + }, + "name": { + "description": "Human-readable provider name, unique within the environment (e.g. `\"corp-keycloak\"`).", + "minLength": 1, + "type": "string" + }, + "required_scopes": { + "description": "Scopes that must all be present in the token's `scope` claim (a space-delimited string or an array of strings). An empty list requires nothing.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tag_claims": { + "additionalProperties": { + "minLength": 1, + "type": "string" + }, + "description": "Cost-attribution tags read from the verified token, as tag key to claim path — for example `{\"department\": \"department\", \"subsidiary\": \"org.subsidiary\"}`. For each request this provider authenticates, the value a path yields takes precedence, per tag key, over the `tags` of the API key, its team and its member; a session from an MCP client's sign-in reads the claims recorded at sign-in. The token is not written back to any of those documents.\n\nA path is first read as one claim of exactly that name, then with dots traversing nested objects. A string is used as is and a number as its JSON text. An array of strings and numbers becomes its distinct values, sorted and joined with commas. Any other value, an empty array, or a value, array element or joined result that is not 1 to 128 characters, contains a control character, or starts or ends with whitespace yields nothing, and the tag key keeps the value the API key, team or member gives it.\n\nKeys match `^[a-z][a-z0-9_]{0,62}$`, paths are non-empty, and at most 32 entries are allowed. Omitted or empty means tags are not read from tokens.", + "maxProperties": 32, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]{0,62}$" + }, + "type": "object" + } + }, + "required": [ + "name" + ], + "title": "OidcProvider", + "type": "object" +} diff --git a/third_party/aisix/schemas/passthrough_route.schema.json b/third_party/aisix/schemas/passthrough_route.schema.json new file mode 100644 index 0000000..8e12576 --- /dev/null +++ b/third_party/aisix/schemas/passthrough_route.schema.json @@ -0,0 +1,570 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "required": [ + "path_prefix" + ], + "title": "Path-prefix match" + }, + { + "required": [ + "hosts" + ], + "title": "Host match" + } + ] + }, + { + "if": { + "required": [ + "path_prefix" + ] + }, + "then": { + "properties": { + "path_prefix": { + "minLength": 1, + "type": "string" + } + } + } + }, + { + "if": { + "required": [ + "hosts" + ] + }, + "then": { + "properties": { + "hosts": { + "items": { + "minLength": 1, + "pattern": "^(\\*\\.)?([A-Za-z0-9-]+\\.)+[A-Za-z0-9-]+$|^[A-Za-z0-9-]+$", + "type": "string" + }, + "minItems": 1, + "type": "array" + } + } + } + }, + { + "if": { + "not": { + "required": [ + "hosts" + ] + }, + "required": [ + "path_prefix" + ] + }, + "then": { + "properties": { + "path_prefix": { + "not": { + "pattern": "^/(v1|mcp|a2a|admin|livez|readyz|metrics)(/|$)" + } + } + } + } + }, + { + "oneOf": [ + { + "not": { + "properties": { + "preserve_host": { + "const": true, + "description": "Set when the route derives its target from the inbound Host instead of target_url." + } + }, + "required": [ + "preserve_host" + ] + }, + "required": [ + "target_url" + ], + "title": "Explicit target URL" + }, + { + "not": { + "required": [ + "target_url" + ] + }, + "properties": { + "preserve_host": { + "const": true, + "description": "Set when the route derives its target from the inbound Host instead of target_url." + } + }, + "required": [ + "preserve_host" + ], + "title": "Preserve inbound host" + } + ] + }, + { + "if": { + "properties": { + "preserve_host": { + "const": true + } + }, + "required": [ + "preserve_host" + ] + }, + "then": { + "required": [ + "hosts" + ] + } + }, + { + "if": { + "required": [ + "target_url" + ] + }, + "then": { + "properties": { + "target_url": { + "pattern": "^https?://", + "type": "string" + } + } + } + }, + { + "if": { + "required": [ + "auth_header_name" + ] + }, + "then": { + "properties": { + "auth_header_name": { + "not": { + "enum": [ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key" + ] + }, + "type": "string" + } + } + } + }, + { + "if": { + "required": [ + "identity_header" + ] + }, + "then": { + "properties": { + "identity_header": { + "not": { + "enum": [ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key" + ] + } + } + } + } + }, + { + "if": { + "properties": { + "auth_mode": { + "const": "header_key" + } + }, + "required": [ + "auth_mode" + ] + }, + "then": { + "properties": { + "auth_header_name": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "auth_header_name" + ] + } + }, + { + "if": { + "properties": { + "auth_mode": { + "const": "anonymous" + } + }, + "required": [ + "auth_mode" + ] + }, + "then": { + "properties": { + "anonymous_key_id": { + "minLength": 1, + "type": "string" + }, + "source_cidrs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "anonymous_key_id", + "source_cidrs" + ] + } + }, + { + "if": { + "anyOf": [ + { + "not": { + "required": [ + "auth_mode" + ] + }, + "title": "auth_mode omitted (defaults to gateway_key)" + }, + { + "properties": { + "auth_mode": { + "enum": [ + "gateway_key", + "anonymous" + ] + } + }, + "required": [ + "auth_mode" + ], + "title": "auth_mode: gateway_key or anonymous" + } + ] + }, + "then": { + "not": { + "required": [ + "auth_header_name" + ] + } + }, + "title": "auth_header_name only in header_key mode" + }, + { + "if": { + "anyOf": [ + { + "not": { + "required": [ + "auth_mode" + ] + }, + "title": "auth_mode omitted (defaults to gateway_key)" + }, + { + "properties": { + "auth_mode": { + "enum": [ + "gateway_key", + "header_key" + ] + } + }, + "required": [ + "auth_mode" + ], + "title": "auth_mode: gateway_key or header_key" + } + ] + }, + "then": { + "not": { + "required": [ + "anonymous_key_id" + ] + } + }, + "title": "anonymous_key_id only in anonymous mode" + }, + { + "if": { + "anyOf": [ + { + "not": { + "required": [ + "credential_mode" + ] + }, + "title": "credential_mode omitted (defaults to inject)" + }, + { + "properties": { + "credential_mode": { + "const": "inject" + } + }, + "required": [ + "credential_mode" + ], + "title": "credential_mode: inject" + } + ] + }, + "then": { + "properties": { + "provider_key_id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "provider_key_id" + ] + } + }, + { + "if": { + "properties": { + "credential_mode": { + "const": "forward_client" + } + }, + "required": [ + "credential_mode" + ] + }, + "then": { + "not": { + "required": [ + "provider_key_id" + ] + } + } + } + ], + "anyOf": [ + { + "required": [ + "name" + ], + "title": "name" + }, + { + "required": [ + "display_name" + ], + "title": "display_name" + } + ], + "definitions": { + "PassthroughAuthMode": { + "description": "How the gateway authenticates callers of a passthrough route.", + "oneOf": [ + { + "description": "Standard gateway auth: an API key or JWT in `Authorization: Bearer` or `x-api-key`, exactly like the typed endpoints.", + "enum": [ + "gateway_key" + ], + "title": "Standard gateway credential", + "type": "string" + }, + { + "description": "Gateway credential in the route's `auth_header_name` header; `Authorization` is left untouched for the upstream credential.", + "enum": [ + "header_key" + ], + "title": "Gateway credential in a dedicated header", + "type": "string" + }, + { + "description": "No inbound gateway credential. The request runs as the route's `anonymous_key_id` principal, restricted to `source_cidrs`.", + "enum": [ + "anonymous" + ], + "title": "Anonymous (bound principal)", + "type": "string" + } + ] + }, + "PassthroughCredentialMode": { + "description": "How the upstream credential of a passthrough route is produced.", + "oneOf": [ + { + "description": "Strip every inbound credential slot and inject the configured ProviderKey's secret. `forward_client_headers` puts back a slot it names exactly.", + "enum": [ + "inject" + ], + "title": "Inject the ProviderKey secret", + "type": "string" + }, + { + "description": "Forward the caller's own credential headers verbatim — bring-your-own-credential. The slot the gateway consumed to authenticate the caller is stripped by default so the gateway credential stays home, unless `forward_client_headers` names that slot exactly. To send its own `Authorization` under `auth_mode: gateway_key`, a caller carries its gateway key in `x-aisix-api-key`.", + "enum": [ + "forward_client" + ], + "title": "Forward the caller's own credential", + "type": "string" + } + ] + } + }, + "properties": { + "anonymous_key_id": { + "description": "The API key this route's traffic runs as when `auth_mode` is `anonymous`: its `allowed_routes`, rate limits, budget and usage attribution all apply, so anonymous traffic keeps a stable, governable principal. Required for `anonymous`; ignored otherwise.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "auth_header_name": { + "description": "Header carrying the gateway credential (API key or JWT) when `auth_mode` is `header_key`, e.g. `x-aisix-api-key`. Lets `Authorization` carry the caller's own upstream credential. The header is stripped before forwarding. Lowercase-only so the forbidden credential-slot list in the coupling is exhaustive (matching is case-insensitive on the wire regardless). Required for `header_key`; ignored otherwise.", + "minLength": 1, + "pattern": "^[!#$%&'*+.^_`|~0-9a-z-]+$", + "type": [ + "string", + "null" + ] + }, + "auth_mode": { + "allOf": [ + { + "$ref": "#/definitions/PassthroughAuthMode" + } + ], + "default": "gateway_key", + "description": "How the gateway authenticates the caller on this route." + }, + "credential_mode": { + "allOf": [ + { + "$ref": "#/definitions/PassthroughCredentialMode" + } + ], + "default": "inject", + "description": "How the upstream credential is produced." + }, + "display_name": { + "description": "Accepted as an alternative spelling of `name`. Provide the label under exactly one of the two names.", + "minLength": 1, + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether this route is active. A disabled route matches nothing.", + "type": "boolean" + }, + "forward_client_headers": { + "description": "Inbound client headers forwarded to the upstream even when this route would otherwise strip them, as single-`*` glob patterns matched case-insensitively against the header name (`\"authorization\"`, `\"x-trace-*\"`). Empty — the default — overrides no stripping.\n\nA header the caller sends more than once is forwarded with every value preserved.\n\nA route forwards the caller's headers by default, so this field only matters for the ones it removes, and `credential_mode` decides which credentials those are. The credential slots are `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256`.\n\nUnder `credential_mode: inject` the route removes every credential slot, whatever the ProviderKey's `strip_headers` lists and whatever the `auth_mode`, and removes the rest of `strip_headers` as well. Naming the slot the route injects into (`x-api-key` for an Anthropic ProviderKey, `authorization` otherwise) puts the caller's value on the upstream request in place of the injected one, never both — which is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged. Any other named slot rides beside the injected credential.\n\nUnder `credential_mode: forward_client` the caller's credentials are relayed without any pattern. The route removes only `proxy-authorization`, its own `identity_header`, and the slot the gateway consumed to authenticate the caller: `authorization` and `x-api-key` under `auth_mode: gateway_key`, unless the caller sent its gateway key in `x-aisix-api-key`, and `auth_header_name` under `header_key`.\n\nAmong the headers a route removes, a credential slot and `traceparent` / `tracestate` come back only when a pattern names them exactly. A glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry, so a broad pattern overrides the rest of the strip set and leaves those alone.\n\nThis route's own `auth_header_name` and `identity_header` are read the same way. Both are slots this route chose rather than ones the gateway owns — under `auth_mode: header_key` the first carries the gateway credential the caller authenticated with, and the second carries an end-user identity this route records and strips — so a glob does not sweep either, and a pattern that names one in full forwards it.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are stripped whatever the patterns say: `host`, `content-length`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace.", + "items": { + "type": "string" + }, + "type": "array" + }, + "hosts": { + "description": "Inbound `Host` values this route serves (the forward-proxy entry: a TLS-terminating device delivers plaintext traffic with the original host, e.g. `api.githubcopilot.com`). Matched case-insensitively, ignoring any `:port` suffix; a leading `*.` wildcard matches exactly one extra label (`*.githubcopilot.com` matches `proxy.githubcopilot.com`). Host-matched requests keep their full path (no prefix stripping unless `path_prefix` also matched).", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "identity_header": { + "description": "Optional header carrying the end-user identity injected by the upstream network device (e.g. `x-aisix-user`). Its value is recorded on the usage event for per-employee audit attribution and stripped before forwarding. Lowercase-only so the forbidden credential-slot list in the coupling is exhaustive; credential-bearing names (`authorization`, `cookie`, …) are rejected outright — their value on the usage event would be credential retention, not identity.", + "minLength": 1, + "pattern": "^[!#$%&'*+.^_`|~0-9a-z-]+$", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Operator-facing label, unique within the gateway. Referenced by API keys' `allowed_routes` globs and used for usage attribution.", + "minLength": 1, + "type": "string" + }, + "path_prefix": { + "description": "Gateway path prefix this route serves, e.g. `/passthrough/openai`. Must start with `/`.\n\nHow the prefix is treated depends on the target shape: a `target_url` route MOUNTS at the prefix, so it is stripped before the remainder is joined to the target base; a `preserve_host` route MIRRORS an upstream that owns its own path space, so the prefix only selects which requests the route claims and the complete path is forwarded unchanged.\n\nA route WITHOUT `hosts` must not claim a reserved gateway namespace (`/v1`, `/mcp`, `/a2a`, health probes) — the typed routes would shadow it. A route WITH `hosts` may use any prefix: host-matched requests dispatch ahead of the typed routes, which is what lets a forward proxy relay an upstream's own namespace (e.g. Copilot's `/mcp/...` on its chat host). At least one of `path_prefix` / `hosts` is required; when both are set the request must satisfy both.", + "minLength": 1, + "pattern": "^/", + "type": [ + "string", + "null" + ] + }, + "preserve_host": { + "description": "Derive the target from the request's own `Host` header (`https://`), for forward-proxy routes that fan one route out over several official hosts. Only legal when `hosts` is set — the matched allowlist is what makes the derived target non-attacker- controlled (SSRF guard).", + "type": "boolean" + }, + "provider_key_id": { + "description": "ProviderKey whose secret is injected upstream when `credential_mode` is `inject` (per-provider auth shape: `x-api-key` + `anthropic-version` for Anthropic, `Authorization: Bearer` otherwise; its `strip_headers` and TLS settings apply). Required for `inject`; forbidden for `forward_client`.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "source_cidrs": { + "description": "Client source CIDRs allowed to use this route. Required (non-empty) when `auth_mode` is `anonymous` — network reachability is the only gate left in front of the anonymous principal. Optional hardening for the other modes; unset means no route-level restriction.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "target_url": { + "description": "Explicit upstream base URL, e.g. `https://api.openai.com`. The matched request's remainder path and query are appended. Exactly one of `target_url` / `preserve_host` must be configured.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "timeout_ms": { + "description": "Maximum time, in milliseconds, for the upstream exchange. Bounds the response-header phase and any non-SSE body read. For SSE it bounds the wait for the first byte and every later no-byte gap, but not the total duration of a healthy relay (which ends with the upstream stream or the client hanging up). When omitted, the gateway default request timeout applies the same way.", + "format": "uint64", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [], + "title": "PassthroughRoute", + "type": "object" +} diff --git a/third_party/aisix/schemas/pricing.schema.json b/third_party/aisix/schemas/pricing.schema.json new file mode 100644 index 0000000..1174925 --- /dev/null +++ b/third_party/aisix/schemas/pricing.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "description": "A per-1,000-token price shared by every model that names it.\n\nA model refers to one of these with `pricing_key`. The gateway looks the price up in the environment's own pricing documents first and in the global catalog second; a model with no `pricing_key`, or one whose key matches no document, falls back to its inline `cost`.", + "properties": { + "input_per_1k": { + "description": "Prompt token price in USD per 1,000 tokens.", + "format": "double", + "minimum": 0.0, + "type": "number" + }, + "key": { + "description": "Value a model's `pricing_key` is matched against, compared as an exact string. Conventionally `/`, but the gateway attaches no meaning to its parts.", + "maxLength": 255, + "minLength": 1, + "type": "string" + }, + "output_per_1k": { + "description": "Completion token price in USD per 1,000 tokens.", + "format": "double", + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "input_per_1k", + "key", + "output_per_1k" + ], + "title": "Pricing", + "type": "object" +} diff --git a/third_party/aisix/schemas/provider_key.schema.json b/third_party/aisix/schemas/provider_key.schema.json new file mode 100644 index 0000000..e79de76 --- /dev/null +++ b/third_party/aisix/schemas/provider_key.schema.json @@ -0,0 +1,401 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "api_key" + ], + "title": "api_key" + }, + { + "required": [ + "secret" + ], + "title": "secret" + } + ], + "definitions": { + "Adapter": { + "description": "Upstream API protocol family used for provider dispatch.", + "enum": [ + "openai", + "anthropic", + "bedrock", + "vertex", + "azure-openai" + ], + "type": "string" + }, + "ApiEndpoint": { + "additionalProperties": false, + "description": "One declared entry point.", + "properties": { + "base": { + "description": "Base URL this surface is served at. Omit when it is the same one `api_base` names — an entry with no `base` still carries the declaration that the surface exists.\n\nDeliberately NOT length-constrained, matching `api_base`. The lenient read schema keeps every constraint but the open-object one, so a `minLength` here would make an empty string skip the whole Provider Key row — and with it every model that references the key — where the same empty string on `api_base` is the control plane's documented way to clear an override. An empty value is treated as \"no override\" at resolution time instead.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ParamConstraints": { + "additionalProperties": false, + "description": "Numeric range clamps applied to chat-completion request bodies.", + "properties": { + "temperature_max": { + "description": "Upper bound for `temperature`. Values above this are clamped to this value. If omitted, no upper bound is applied.", + "format": "double", + "type": [ + "number", + "null" + ] + }, + "temperature_min": { + "description": "Lower bound for `temperature`. Values below this are clamped to this value. If omitted, no lower bound is applied.", + "format": "double", + "type": [ + "number", + "null" + ] + } + }, + "type": "object" + }, + "ProviderApis": { + "additionalProperties": false, + "description": "Per-surface native entry points on one upstream.\n\nThe two surfaces resolve differently, because the evidence for them differs:\n\n- `messages` is **additive**. An `adapter: anthropic` key (or the `anthropic` vendor) speaks that wire by declaration and keeps serving `/v1/messages` natively whatever this map says; listing it here adds the route to a key whose adapter is something else. That is the DeepSeek/Zhipu shape — an OpenAI-compatible key whose vendor also fronts an Anthropic-compatible path. An entry's own `base` always decides WHERE the Anthropic wire lives, for the verbatim passthrough and for the bridge that translates into it alike; without one it is `api_base`. - `responses` is **authoritative**. Once this map exists, `/v1/responses` is served natively only if it is listed. The Responses API is a strict superset of chat completions rather than a rename, so an OpenAI-compatible endpoint serving one does not necessarily serve the other, and `adapter: openai` is not evidence either way. Leaving it out is how an operator says \"this endpoint has no `/v1/responses`\" and gets the request translated to chat completions instead of 404'd upstream.\n\nWith no map at all, each falls back to what the gateway inferred before this field existed: `/v1/messages` from the vendor id or the `anthropic` adapter, `/v1/responses` from the vendor id alone.", + "properties": { + "messages": { + "anyOf": [ + { + "$ref": "#/definitions/ApiEndpoint" + }, + { + "type": "null" + } + ], + "description": "Anthropic-wire `/v1/messages` (and `/v1/messages/count_tokens`)." + }, + "responses": { + "anyOf": [ + { + "$ref": "#/definitions/ApiEndpoint" + }, + { + "type": "null" + } + ], + "description": "OpenAI-wire `/v1/responses`." + } + }, + "type": "object" + }, + "ProviderKeyTls": { + "additionalProperties": false, + "description": "TLS settings for connections to one Provider Key's `api_base`.\n\nUse this when a single upstream endpoint needs trust settings that differ from the gateway's deployment-wide ones — typically a self-hosted model endpoint whose certificate is signed by a private certificate authority.\n\nThe certificate is supplied inline rather than as a file path, because the endpoint is declared here rather than in the gateway's own configuration file. For a certificate authority that applies to every upstream, prefer the gateway's `upstream.tls.ca_file` setting.", + "properties": { + "ca_cert": { + "description": "PEM-encoded certificate authority certificates trusted as issuers for this endpoint, in addition to the gateway's default trust store. A bundle containing several certificates is accepted.", + "type": [ + "string", + "null" + ] + }, + "verify": { + "default": true, + "description": "Whether the endpoint's certificate is verified. Setting this to `false` accepts any certificate, including one presented by an intercepting party, and is intended only for test environments.", + "type": "boolean" + } + }, + "type": "object" + }, + "RequestOverrides": { + "additionalProperties": false, + "description": "Per-`ProviderKey` request-shape overrides. Use these fields to rename request body parameters, clamp supported numeric parameters, add fallback outbound headers, or add fallback outbound body fields.", + "properties": { + "default_body_fields": { + "additionalProperties": true, + "description": "`apply_default_body_fields` input. Top-level body fields added when the caller did not set them. `serde_json::Map` preserves insertion order on serialize, matching the etcd round-trip.", + "type": "object" + }, + "default_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Top-level headers added to the outbound request when the caller did not set them. Values may reference the request context with `${...}` variables, such as `\"${request.api_key.team_id}\"`; a header whose variables do not all resolve is dropped rather than sent blank. See [`crate::header_template`] for the closed variable vocabulary.\n\n\"When the caller did not set them\" includes the gateway itself: an entry naming the slot this ProviderKey's credential occupies is not applied. Use `forward_client_headers` to put the caller's own credential there instead.", + "type": "object" + }, + "forward_client_headers": { + "description": "Inbound client headers forwarded to the upstream, as single-`*` glob patterns matched case-insensitively against the header name (`\"anthropic-beta\"`, `\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing, which is the behavior of every standard-protocol endpoint before AISIX-Cloud#1167.\n\nA header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded.\n\nA header named here reaches the upstream whatever the gateway would otherwise do with it. Naming a credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — hands the upstream the caller's own credential in place of the one this ProviderKey would inject there, never both. That is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged. Any OTHER header the gateway had already set is left alone: it selects how the exchange works, not who it is from.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nTwo cases where a named header still does not reach the upstream. A `default_headers` entry of the same name wins it for every name except a credential slot: both are operator configuration and the static one is the more specific choice, but in a credential slot the forwarded value is precisely the one that was asked for, so it takes the slot from the static entry. And on an AWS Bedrock provider the request signer owns `authorization`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token`, `x-amz-target` and `x-amzn-bedrock-accept`, and drops any supplied value — a value there would not authenticate anyone: it either loses to the signer or breaks the signature.\n\nNaming a credential slot needs a data plane new enough to honor it; an older one refuses those names outright, so the pattern has no effect there rather than a different one.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace. The headers describing a body this gateway re-serializes or a response shape it parses (`content-type`, `content-length`, `accept`, `anthropic-version`, `x-stainless-*`) are excluded for the same reason. `traceparent` and `tracestate` are forwarded only when a pattern names them exactly — a glob is not read as consent to graft the caller's trace onto the upstream's telemetry.", + "items": { + "type": "string" + }, + "type": "array" + }, + "param_constraints": { + "anyOf": [ + { + "$ref": "#/definitions/ParamConstraints" + }, + { + "type": "null" + } + ], + "description": "Parameter constraints applied to the outbound request. If omitted, no clamping is applied." + }, + "param_renames": { + "additionalProperties": { + "type": "string" + }, + "description": "`apply_param_renames` input. Top-level body keys named on the left are renamed to the right. Leave empty to preserve request parameter names.", + "type": "object" + } + }, + "type": "object" + }, + "ResponseOverrides": { + "additionalProperties": false, + "description": "Per-`ProviderKey` response-shape overrides. Use these fields to describe stream termination behavior, flatten list-style content when needed, select an error envelope strategy, or lift provider-specific reasoning content.", + "properties": { + "content_list_to_string": { + "default": false, + "description": "When `true`, the request-body `messages[*].content` array of text blocks gets flattened to a single string before dispatch.", + "type": "boolean" + }, + "error_envelope": { + "description": "Stored error-envelope preference for compatibility with control-plane configuration. The proxy does not currently apply this field.", + "type": [ + "string", + "null" + ] + }, + "reasoning_field": { + "description": "Path used to extract reasoning content from the provider response. If omitted or empty, no reasoning field is lifted. Example: `\"delta.reasoning_content\"`.", + "type": [ + "string", + "null" + ] + }, + "stream_done_marker": { + "anyOf": [ + { + "$ref": "#/definitions/StreamDoneMarker" + }, + { + "type": "null" + } + ], + "description": "Stream `[DONE]` terminator expectation. If omitted, either presence or absence of the terminator is accepted." + } + }, + "type": "object" + }, + "StreamDoneMarker": { + "description": "Stream `[DONE]` terminator policy for an SSE response. Values are `\"required\"`, `\"optional\"`, or `\"none\"`.", + "oneOf": [ + { + "description": "Upstream is expected to emit `data: [DONE]`. Absence is logged as a diagnostic warning.", + "enum": [ + "required" + ], + "type": "string" + }, + { + "description": "Either presence or absence is acceptable. Used when the upstream is OpenAI-compatible but does not require the terminator.", + "enum": [ + "optional" + ], + "type": "string" + }, + { + "description": "Upstream is expected to omit the marker and terminate on connection close.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "TelemetryKind": { + "description": "Provider-key category: `catalog` for curated providers, `byo` for bring-your-own.", + "enum": [ + "catalog", + "byo" + ], + "type": "string" + }, + "TelemetryTags": { + "additionalProperties": false, + "description": "Telemetry attribution tags emitted with requests routed through this provider key.", + "properties": { + "branded_provider": { + "description": "Branded provider slug for catalog entries, such as `\"openai\"` or `\"anthropic\"`. Bring-your-own providers leave this field unset.", + "type": [ + "string", + "null" + ] + }, + "byo_label": { + "description": "Operator-defined label for bring-your-own entries, such as an internal team name. Catalog entries leave this field unset.", + "type": [ + "string", + "null" + ] + }, + "featured": { + "default": false, + "description": "Whether this provider is surfaced in the featured list.", + "type": "boolean" + }, + "kind": { + "anyOf": [ + { + "$ref": "#/definitions/TelemetryKind" + }, + { + "type": "null" + } + ], + "description": "Provider-key category, such as `\"catalog\"` for curated providers or `\"byo\"` for bring-your-own providers." + }, + "pk_label": { + "description": "Operator-defined label for this provider key, such as `\"production\"` or `\"shared-test\"`.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "properties": { + "adapter": { + "anyOf": [ + { + "$ref": "#/definitions/Adapter" + }, + { + "type": "null" + } + ], + "description": "Upstream API protocol family used when provider-specific dispatch is unavailable." + }, + "api_base": { + "description": "Override base URL for the upstream provider. Required for custom or OpenAI-compatible providers that should not use a built-in vendor endpoint.", + "type": [ + "string", + "null" + ] + }, + "api_key": { + "description": "Upstream provider's API key. The gateway receives plaintext so it can authenticate to the upstream provider. Protect the configuration store and transport accordingly.", + "minLength": 1, + "type": "string" + }, + "apis": { + "anyOf": [ + { + "$ref": "#/definitions/ProviderApis" + }, + { + "type": "null" + } + ], + "description": "API surfaces this upstream serves natively, beyond the one its `adapter` already implies, and the base URL each one lives at.\n\nOne upstream account often exposes more than one protocol, on different paths of the same host — an OpenAI-compatible `/v1/chat/completions` under `…/v1` and an Anthropic-compatible `/v1/messages` under `…/anthropic`, both authenticated by the same credential. `api_base` can only name one of them, so without this field every request the declared path cannot serve gets translated instead — losing whatever the target protocol carries that the canonical chat shape does not (prompt-cache breakpoints, thinking blocks). Declaring the second entry here lets each inbound protocol reach its own native path under the one credential.\n\nEach surface resolves on its own terms; see [`ProviderApis`]. Surfaces this map has no key for — embeddings, audio, images, videos, files/batches/fine-tuning, rerank — always use `api_base`, exactly as before." + }, + "display_name": { + "description": "Operator-facing label, unique within the gateway. Surfaces in the Admin API list view and in dashboard UIs that wrap this resource.", + "minLength": 1, + "type": "string" + }, + "provider": { + "default": "", + "description": "Upstream provider identifier, such as `\"deepseek\"`, `\"openai\"`, or a model catalog ID. The gateway uses this value for provider-specific dispatch and base URL validation.", + "type": "string" + }, + "request": { + "anyOf": [ + { + "$ref": "#/definitions/RequestOverrides" + }, + { + "type": "null" + } + ], + "description": "Per-key request-shape overrides applied by supported provider paths before dispatch to the upstream provider." + }, + "resolve_addresses": { + "description": "IP addresses the gateway connects to for this key's `api_base` host, instead of resolving that host through DNS. Each entry is an IPv4 or IPv6 address literal, without a port and without brackets.\n\nUse this when the upstream is reached over a private link that has no DNS entry, while the provider still requires its own hostname in the request. Only the connection target changes: the `Host` header, the HTTP/2 `:authority`, the TLS server name and the certificate check all keep using the hostname from `api_base`, and the port and scheme keep coming from `api_base` too.\n\nSeveral addresses are tried in the order given, as a resolver's answer would be: the next one is attempted when a connection cannot be established, which is how a private link that terminates on one address per availability zone stays reachable when one is down.\n\nScoped to the `api_base` hostname and nothing else. An `apis` entry that serves a second protocol from the same host is reached over the same link, because it is the same hostname; one that names a different host is resolved normally. A key with no `api_base`, or whose `api_base` is already an address literal, has no hostname to override and is dispatched unchanged.\n\nHonoured on every surface that dispatches through the Provider Key's own client: chat completions, completions, embeddings, images, audio, `/v1/messages` (and `count_tokens`), `/v1/responses`, rerank, videos, the files/batches/fine-tuning surface and `/passthrough/*`. Not honoured for Amazon Bedrock or `/v1/realtime`, which connect on their own transports — the same two that `tls` does not reach.\n\nNot applicable when the gateway reaches its upstreams through a forward proxy (`HTTPS_PROXY` / `ALL_PROXY` in the gateway's environment): the proxy is given the hostname and resolves it itself, so nothing here is consulted.", + "items": { + "format": "ip", + "pattern": "^[0-9A-Fa-f:.]+$", + "type": "string" + }, + "minItems": 1, + "type": [ + "array", + "null" + ] + }, + "response": { + "anyOf": [ + { + "$ref": "#/definitions/ResponseOverrides" + }, + { + "type": "null" + } + ], + "description": "Per-key response-shape overrides applied by provider bridges that support response transformation." + }, + "secret": { + "description": "Accepted as an alternative spelling of `api_key`. Provide the credential under exactly one of the two names.", + "minLength": 1, + "type": "string" + }, + "strip_headers": { + "default": [ + "authorization", + "cookie", + "set-cookie", + "x-api-key" + ], + "description": "Inbound headers removed before passthrough forwarding. A passthrough route with `credential_mode: inject` also removes every credential slot, listed here or not.", + "items": { + "type": "string" + }, + "type": "array" + }, + "telemetry_tags": { + "allOf": [ + { + "$ref": "#/definitions/TelemetryTags" + } + ], + "default": { + "featured": false + }, + "description": "Telemetry tags carried alongside the key for metric and log emission." + }, + "tls": { + "anyOf": [ + { + "$ref": "#/definitions/ProviderKeyTls" + }, + { + "type": "null" + } + ], + "description": "TLS settings for connections to this key's `api_base`. Omit to use the gateway's deployment-wide trust settings." + } + }, + "required": [ + "display_name" + ], + "title": "ProviderKey", + "type": "object" +} diff --git a/third_party/aisix/schemas/rate_limit.schema.json b/third_party/aisix/schemas/rate_limit.schema.json new file mode 100644 index 0000000..e95faa9 --- /dev/null +++ b/third_party/aisix/schemas/rate_limit.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpd": { + "description": "Requests per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rph": { + "description": "Requests per 3,600-second window. There is no per-hour token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rpm": { + "description": "Requests per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "rps": { + "description": "Requests per 1-second window. There is no per-second token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "tpd": { + "description": "Tokens per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "tpm": { + "description": "Tokens per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "RateLimit", + "type": "object" +} diff --git a/third_party/aisix/schemas/rate_limit_policy.schema.json b/third_party/aisix/schemas/rate_limit_policy.schema.json new file mode 100644 index 0000000..650cc57 --- /dev/null +++ b/third_party/aisix/schemas/rate_limit_policy.schema.json @@ -0,0 +1,467 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "ConditionGroup": { + "additionalProperties": false, + "description": "A group node combining child nodes under an explicit AND/OR.", + "properties": { + "children": { + "items": { + "$ref": "#/definitions/ConditionNode" + }, + "type": "array" + }, + "logic": { + "$ref": "#/definitions/ConditionLogic" + }, + "negate": { + "type": "boolean" + } + }, + "required": [ + "children", + "logic" + ], + "type": "object" + }, + "ConditionLogic": { + "description": "Group combinator — lua-resty-expr `AND`/`OR` (with `negate` for `!AND`/`!OR`).", + "enum": [ + "and", + "or" + ], + "type": "string" + }, + "ConditionNode": { + "anyOf": [ + { + "$ref": "#/definitions/PolicyCondition" + }, + { + "$ref": "#/definitions/ConditionGroup" + } + ], + "description": "A slot in a condition list: leaf or nested group. Untagged — the shapes are disjoint (a leaf requires `dimension`/`operator`/`value`, a group `logic`/`children`), and the write contract rejects unknown fields inside either variant, because an unknown field placed there is otherwise ignored without being reported (same reasoning as `OnEmbeddingFailure` in the model schema)." + }, + "ConditionOperator": { + "description": "Condition leaf operator — lua-resty-expr tokens, verbatim.", + "enum": [ + "==", + "~=", + "~~", + "~*", + "in", + "has", + ">", + ">=", + "<", + "<=", + "ipmatch" + ], + "type": "string" + }, + "ConditionValue": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "A leaf's comparison value: `in` (and the reserved list operators) carry a string list, every scalar operator a single string." + }, + "GroupByDimension": { + "description": "Dimension a policy's counters split on (`group_by`). The subset of the condition dimensions with a stable per-request value to key a counter on — `model_name` is excluded (it duplicates `model` as a counter identity, less precisely).", + "oneOf": [ + { + "enum": [ + "team", + "member", + "api_key", + "model", + "provider" + ], + "type": "string" + }, + { + "description": "`tag:` — one counter per effective value of the cost-attribution tag `` (on a request authenticated by a JWT, what its provider's `tag_claims` reads from the token; else the API key's own value, else its team's, else its member's). A matched request with no value for the key is not subject to the policy, exactly like `team` on a key that belongs to no team.", + "pattern": "^tag:[a-z][a-z0-9_]{0,62}$", + "type": "string" + } + ] + }, + "PolicyAction": { + "description": "What the policy does past its limits. v1 has the single `reject` (429); the enum reserves the field for `fallback`/`queue`/`alert`.", + "enum": [ + "reject" + ], + "type": "string" + }, + "PolicyCondition": { + "additionalProperties": false, + "description": "One condition leaf: `dimension operator value`, with `negate` as the lua-resty-expr `!` prefix.", + "properties": { + "dimension": { + "$ref": "#/definitions/PolicyDimension" + }, + "negate": { + "type": "boolean" + }, + "operator": { + "$ref": "#/definitions/ConditionOperator" + }, + "value": { + "$ref": "#/definitions/ConditionValue" + } + }, + "required": [ + "dimension", + "operator", + "value" + ], + "type": "object" + }, + "PolicyDimension": { + "description": "Request dimension a condition leaf matches on.", + "oneOf": [ + { + "description": "`ApiKey.team_id` (UUID).", + "enum": [ + "team" + ], + "type": "string" + }, + { + "description": "`ApiKey.user_id` (UUID).", + "enum": [ + "member" + ], + "type": "string" + }, + { + "description": "Authenticated api_key entry id (UUID).", + "enum": [ + "api_key" + ], + "type": "string" + }, + { + "description": "Model entry id (UUID). Matches the dispatched model; on a Model-Group / semantic-router / ensemble dispatch it also matches the requested parent entry, so a group's own id selects every request addressed to that group.", + "enum": [ + "model" + ], + "type": "string" + }, + { + "description": "Model display name — the string dimension for regex/prefix matching (\"every gpt-4-family alias\"). Matches the dispatched model's name, and on a virtual-parent dispatch also the requested parent's name.", + "enum": [ + "model_name" + ], + "type": "string" + }, + { + "description": "Dispatched model's `provider` (models.dev catalog id).", + "enum": [ + "provider" + ], + "type": "string" + }, + { + "description": "`tag:` — the request's effective value of the cost-attribution tag ``: on a request authenticated by a JWT, the value its provider's `tag_claims` reads from the token; else the API key's own `tags[]`, else that of the team its `team_id` names, else that of the member its `user_id` names. A string dimension: it admits `==`, `~=`, `in`, `~~` and `~*`. A request with no value for the key matches neither the condition nor its negation, exactly like `team` on a key that belongs to no team.", + "pattern": "^tag:[a-z][a-z0-9_]{0,62}$", + "type": "string" + } + ] + }, + "PolicySchedule": { + "additionalProperties": false, + "description": "One recurring wall-clock window during which the owning policy is suspended (not enforced). Days are selected by `days_of_week` OR by an explicit `dates` list (exactly one selector; the JSON Schema's injected `oneOf` enforces this — see [`crate::models::schema::rate_limit_policy_root_schema`]), evaluated in `timezone`. Time bounds compare as wall-clock minutes: `start_time < end_time` is a same-day window; `start_time > end_time` crosses midnight and belongs to its **start** day (`days_of_week: [fri], 22:00 → 09:00` covers Friday 22:00 through Saturday 09:00); equal times are an empty window that never matches.", + "oneOf": [ + { + "required": [ + "days_of_week" + ] + }, + { + "required": [ + "dates" + ] + } + ], + "properties": { + "dates": { + "description": "Explicit calendar dates (`YYYY-MM-DD`, in `timezone`) the window opens on — for holidays and other irregular days.", + "items": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "days_of_week": { + "description": "Recurring weekly selector: the window opens on each listed day.", + "items": { + "$ref": "#/definitions/ScheduleWeekday" + }, + "minItems": 1, + "type": "array" + }, + "end_time": { + "description": "Window end, `HH:MM` wall clock (exclusive); `24:00` = end of day. An end before the start crosses into the following day; an end equal to the start is an empty window that never matches.", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$|^24:00$", + "type": "string" + }, + "start_time": { + "description": "Window start, `HH:MM` wall clock (inclusive).", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", + "type": "string" + }, + "timezone": { + "description": "IANA timezone the window's wall-clock fields are interpreted in (e.g. `Asia/Shanghai`).", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "end_time", + "start_time", + "timezone" + ], + "type": "object" + }, + "PolicyScope": { + "description": "Subject a [`RateLimitPolicy`] targets, paired with `scope_ref`.", + "enum": [ + "api_key", + "model", + "team", + "member", + "team_member" + ], + "type": "string" + }, + "PolicyWindow": { + "description": "Fixed-window length a [`RateLimitPolicy`] applies its limits over.", + "enum": [ + "second", + "minute", + "hour", + "day" + ], + "type": "string" + }, + "RateLimit": { + "additionalProperties": false, + "properties": { + "concurrency": { + "description": "Max concurrent in-flight requests.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "rpd": { + "description": "Requests per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rph": { + "description": "Requests per 3,600-second window. There is no per-hour token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rpm": { + "description": "Requests per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "rps": { + "description": "Requests per 1-second window. There is no per-second token limit field.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tpd": { + "description": "Tokens per 86,400-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "tpm": { + "description": "Tokens per 60-second window.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + }, + "ScheduleWeekday": { + "description": "Day-of-week selector for a [`PolicySchedule`], in the schedule's timezone.", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "type": "string" + } + }, + "oneOf": [ + { + "anyOf": [ + { + "required": [ + "max_requests" + ] + }, + { + "required": [ + "max_tokens" + ] + } + ], + "not": { + "anyOf": [ + { + "required": [ + "conditions" + ] + }, + { + "required": [ + "group_by" + ] + }, + { + "required": [ + "limits" + ] + }, + { + "required": [ + "action" + ] + } + ] + }, + "required": [ + "scope", + "scope_ref", + "window" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "scope" + ] + }, + { + "required": [ + "scope_ref" + ] + }, + { + "required": [ + "window" + ] + }, + { + "required": [ + "max_requests" + ] + }, + { + "required": [ + "max_tokens" + ] + } + ] + }, + "required": [ + "limits" + ] + } + ], + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/definitions/PolicyAction" + } + ], + "description": "Over-limit action; v1 only `reject` (429), absent = `reject`." + }, + "conditions": { + "description": "Condition node tree the request must satisfy (implicit AND across the top level; `[]`/absent = every request in the env).", + "items": { + "$ref": "#/definitions/ConditionNode" + }, + "type": "array" + }, + "group_by": { + "description": "Dimensions the counters split on; `[]`/absent = one shared bucket for every matched request. A matched request missing a `group_by` dimension — a `team` or `member` its key does not carry, or a `tag:` it has no value for — is not subject to the policy (mirrors `team_member` only applying to keys that carry a `user_id`).", + "items": { + "$ref": "#/definitions/GroupByDimension" + }, + "type": "array" + }, + "limits": { + "allOf": [ + { + "$ref": "#/definitions/RateLimit" + } + ], + "description": "Full 7-field limits (`rps/rpm/rph/rpd/tpm/tpd/concurrency`) — same shape and storage semantics as the inline model/api_key rate limits. Present on every conditional row (it is the form discriminator) and carries at least one field." + }, + "max_requests": { + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "max_tokens": { + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "schedules": { + "description": "Recurring windows during which this policy is suspended — the quota gate skips it while `now` falls in any listed window and enforcement resumes automatically afterwards (AISIX-Cloud#1104). The counters' bucket key never changes, so suspension does not reset the surrounding window's counts. Empty/absent = always enforced. cp-api omits the field when empty, keeping schedule-less rows parseable by pre-`schedules` strict data planes.", + "items": { + "$ref": "#/definitions/PolicySchedule" + }, + "type": "array" + }, + "scope": { + "$ref": "#/definitions/PolicyScope" + }, + "scope_ref": { + "minLength": 1, + "type": "string" + }, + "window": { + "$ref": "#/definitions/PolicyWindow" + } + }, + "required": [ + "name" + ], + "title": "RateLimitPolicy", + "type": "object" +} diff --git a/third_party/aisix/schemas/routing.schema.json b/third_party/aisix/schemas/routing.schema.json new file mode 100644 index 0000000..efdfff2 --- /dev/null +++ b/third_party/aisix/schemas/routing.schema.json @@ -0,0 +1,288 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "HashOnSource": { + "additionalProperties": false, + "description": "One source for the `consistent_hash` hash key. Sources are tried in order; the first one that yields a non-empty value wins.", + "properties": { + "name": { + "description": "The header or cookie name to read. Required for `header` and `cookie` sources; not accepted for `api_key` or `client_ip`.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/HashOnType" + } + ], + "description": "Which request attribute supplies the hash key." + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "HashOnType": { + "description": "Which request attribute a [`HashOnSource`] reads the hash key from.", + "oneOf": [ + { + "description": "A request header, named by `name`.", + "enum": [ + "header" + ], + "type": "string" + }, + { + "description": "A cookie from the request's `Cookie` header, named by `name`.", + "enum": [ + "cookie" + ], + "type": "string" + }, + { + "description": "The caller's API key id.", + "enum": [ + "api_key" + ], + "type": "string" + }, + { + "description": "The caller's resolved client IP (honouring the trusted-proxy configuration).", + "enum": [ + "client_ip" + ], + "type": "string" + } + ] + }, + "RoutingStrategy": { + "oneOf": [ + { + "description": "Smooth weighted round-robin over target `weight`s. Equal (or absent) weights degrade to a plain declaration-order cycle.", + "enum": [ + "round_robin" + ], + "type": "string" + }, + { + "description": "Ketama-style consistent hashing of the request's hash key (see `hash_on`) over the targets, `weight` scaling each target's share of the ring. The same key keeps landing on the same target while it is healthy; on failure the walk follows the ring so only the failed target's keys move.", + "enum": [ + "consistent_hash" + ], + "type": "string" + }, + { + "description": "Always start with the first target and move to later targets only after failure.", + "enum": [ + "failover" + ], + "type": "string" + }, + { + "description": "Rank targets cheapest-first by the target model's resolved price (combined input+output per-1K), then fall forward. A target is priced by the `pricing` document its `pricing_key` names, or by its inline `cost` when it names none; a target with no price at all ranks last.", + "enum": [ + "least_cost" + ], + "type": "string" + }, + { + "description": "Rank targets fastest-first by a moving average of recent observed upstream latency (time-to-first-token for streaming), then fall forward. Targets with no samples yet rank first so they get probed.", + "enum": [ + "least_latency" + ], + "type": "string" + }, + { + "description": "Rank targets least-loaded-first by in-flight requests divided by target `weight` (the APISIX least_conn score), then fall forward.", + "enum": [ + "least_busy" + ], + "type": "string" + } + ] + }, + "RoutingTarget": { + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "model": { + "type": "string" + } + }, + "required": [ + "model" + ] + }, + { + "properties": { + "model_id": { + "type": "string" + } + }, + "required": [ + "model_id" + ] + } + ] + } + ], + "description": "One destination in a routing configuration. The target model is named either by `model` (its display name) or by `model_id` (its resource id); a target must carry at least one of the two.", + "properties": { + "model": { + "description": "Model alias for a direct model that can receive routed traffic. Read only when `model_id` is absent.\n\nDefaulted at the type level and required by the schemas instead — as one half of a \"`model` or `model_id`\" alternative, so a target naming its model by id alone validates while one naming it neither way is still rejected.", + "minLength": 1, + "type": "string" + }, + "model_id": { + "description": "Resource id of the direct model that can receive routed traffic. Present, it is authoritative and `model` is ignored: the id is resolved against the models in the current configuration and the name it resolves to is the target, so renaming that model keeps this target pointing at it with no edit to this document. An id resolving to no model is a target that does not exist, exactly as a `model` naming no model is.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "priority": { + "description": "Priority tier, default `0`; a higher value is preferred (the APISIX node-priority convention — give backup targets `-1`). The strategy orders targets within each tier; a lower tier is only tried when every higher-tier target failed or is unavailable.", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "tags": { + "description": "Tags for tag/metadata-conditional routing. When a request carries routing tags, only targets whose tags intersect the request's are eligible; a target tagged `\"default\"` is the fallback used when nothing matches and for untagged requests. Absent/empty means the target opts out of tag filtering (eligible only via the default fallback once any sibling target is tagged). The configured strategy then orders whatever set survives.", + "items": { + "minLength": 1, + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "weight": { + "description": "Target weight, default `1`. Used by `round_robin` (rotation share), `consistent_hash` (share of the hash ring), and `least_busy` (in-flight divided by weight). `failover`, `least_cost`, and `least_latency` accept the field but do not use it.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "WhenAllUnavailablePolicy": { + "description": "Behavior when every routing target is unavailable because of runtime health or cooldown state.", + "oneOf": [ + { + "description": "Return `503` with a fixed `Retry-After` hint.", + "enum": [ + "fail" + ], + "type": "string" + }, + { + "description": "Try every target in declaration order even when all of them are currently unavailable because of health or cooldown status. Use only when maintaining availability is preferred over avoiding recently unhealthy targets.", + "enum": [ + "try_anyway" + ], + "type": "string" + } + ] + } + }, + "properties": { + "fallback_on_statuses": { + "description": "Additional upstream HTTP status codes that participate in retries and failover. By default a non-429 4xx response is treated as a caller error and returned as-is; providers that use 4xx codes for transient conditions (model overload, queue full, quota exhaustion) can be listed here, for example `[408, 409]`. 5xx codes are already retryable, so listing them changes nothing. Authentication (`401`/`403`) and validation (`400`) codes should only be listed when the provider is known to use them for transient failures.", + "items": { + "format": "uint16", + "maximum": 599.0, + "minimum": 400.0, + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "hash_on": { + "description": "Where the `consistent_hash` hash key comes from: an ordered chain of sources, the first non-empty value winning. Defaults to the `x-aisix-routing-key` request header, falling back to the caller's API key id. Only valid with `strategy: consistent_hash`.", + "items": { + "$ref": "#/definitions/HashOnSource" + }, + "minItems": 1, + "type": [ + "array", + "null" + ] + }, + "max_fallbacks": { + "description": "Max number of later targets to attempt after the initial target fails permanently. When omitted, all later targets may be attempted.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "retries": { + "description": "Retry attempts on the current target before failing over, applied to every target that does not set its own `retries`. Absent falls back to the deployment-wide `upstream.retries` default.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "retry_on_429": { + "description": "Whether upstream 429 participates in retries and failover.", + "type": [ + "boolean", + "null" + ] + }, + "strategy": { + "allOf": [ + { + "$ref": "#/definitions/RoutingStrategy" + } + ], + "default": "failover", + "description": "Strategy used to select a target for each request." + }, + "targets": { + "description": "Ordered set of direct models available to this routing model.", + "items": { + "$ref": "#/definitions/RoutingTarget" + }, + "minItems": 1, + "type": "array" + }, + "when_all_unavailable": { + "anyOf": [ + { + "$ref": "#/definitions/WhenAllUnavailablePolicy" + }, + { + "type": "null" + } + ], + "description": "Policy to apply when every target is unavailable because of runtime health or cooldown state." + } + }, + "required": [ + "targets" + ], + "title": "Routing", + "type": "object" +} diff --git a/third_party/aisix/schemas/semantic.schema.json b/third_party/aisix/schemas/semantic.schema.json new file mode 100644 index 0000000..386477a --- /dev/null +++ b/third_party/aisix/schemas/semantic.schema.json @@ -0,0 +1,484 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "default": { + "type": "string" + } + }, + "required": [ + "default" + ] + }, + { + "properties": { + "default_id": { + "type": "string" + } + }, + "required": [ + "default_id" + ] + } + ] + }, + { + "else": { + "allOf": [ + { + "anyOf": [ + { + "properties": { + "embedding_model": { + "type": "string" + } + }, + "required": [ + "embedding_model" + ] + }, + { + "properties": { + "embedding_model_id": { + "type": "string" + } + }, + "required": [ + "embedding_model_id" + ] + } + ] + } + ], + "properties": { + "match": { + "type": "object" + }, + "routes": { + "items": { + "required": [ + "examples" + ] + } + } + }, + "required": [ + "match" + ] + }, + "if": { + "properties": { + "classifier": { + "type": "object" + } + }, + "required": [ + "classifier" + ] + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "embedding_model" + ] + }, + { + "properties": { + "embedding_model_id": { + "type": "string" + } + }, + "required": [ + "embedding_model_id" + ] + }, + { + "required": [ + "embedding_timeout_ms" + ] + }, + { + "required": [ + "on_embedding_failure" + ] + }, + { + "required": [ + "match" + ] + } + ] + }, + "properties": { + "routes": { + "items": { + "not": { + "anyOf": [ + { + "required": [ + "examples" + ] + }, + { + "required": [ + "threshold" + ] + } + ] + }, + "required": [ + "description" + ] + } + } + } + } + } + ], + "definitions": { + "Aggregation": { + "description": "How a request's per-example similarity scores collapse into one score for the route. v1 uses `max` (the single best-matching example), chosen for explainability — the UI surfaces \"the top matching example\" — over semantic-router's default sum/mean-over-top_k.", + "oneOf": [ + { + "description": "Route score = the highest cosine across its examples.", + "enum": [ + "max" + ], + "type": "string" + } + ] + }, + "ClassifierType": { + "description": "Hosted decision model a semantic router can use in place of embeddings.", + "oneOf": [ + { + "description": "TypeSafe's jev decision model.", + "enum": [ + "jev" + ], + "type": "string" + } + ] + }, + "DistanceMetric": { + "description": "Distance metric used to compare the request embedding against route example embeddings. v1 supports cosine only.", + "oneOf": [ + { + "description": "Cosine similarity — higher is more similar.", + "enum": [ + "cosine" + ], + "type": "string" + } + ] + }, + "EmbeddingFailureMode": { + "description": "Mode for an enum-only failure policy: route to the router's `default`, or fail the request with `503`.", + "oneOf": [ + { + "description": "Route to the semantic router's `default` model.", + "enum": [ + "default" + ], + "type": "string" + }, + { + "description": "Reject the request with `503`.", + "enum": [ + "fail" + ], + "type": "string" + } + ] + }, + "OnEmbeddingFailure": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/definitions/EmbeddingFailureMode" + } + ], + "description": "`\"default\"` or `\"fail\"`." + }, + { + "additionalProperties": false, + "description": "`{ \"target\": \"\" }` (or `{ \"target_id\": \"\" }`) — route to a specific safe model.", + "properties": { + "target": { + "description": "Direct-model alias to route to when embedding fails. Read only when `target_id` is absent.", + "minLength": 1, + "type": "string" + }, + "target_id": { + "description": "Resource id of the direct model to route to when embedding fails. Present, it is authoritative and `target` is ignored, so renaming that model keeps this fallback pointing at it with no edit to this document.", + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + ], + "description": "What the router does when the embedding call errors or times out. Use `\"default\"` to route to the router's default model, `\"fail\"` to reject the request with `503`, or `{ \"target\": \"\" }` to route to a specific fallback model." + }, + "SemanticClassifier": { + "additionalProperties": false, + "description": "Route selection by a hosted decision model. For each request the latest user message is sent to the model together with every route's `name` and `description`, and the model picks one route. A request goes to the picked route's target, or to the router's `default` when the model's confidence in its pick is below `min_confidence`. To catch requests that fit none of the routes, configure a route for them.", + "properties": { + "min_confidence": { + "default": 0.5, + "description": "Lowest confidence at which the picked route is used. A decision below it sends the request to `default`.", + "format": "double", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + "model": { + "default": "jev-latest", + "description": "Decision model to call.", + "minLength": 1, + "type": "string" + }, + "on_failure": { + "allOf": [ + { + "$ref": "#/definitions/OnEmbeddingFailure" + } + ], + "description": "Behavior when the decision call fails, times out, or returns an answer that names no route. Same values as `on_embedding_failure`." + }, + "provider_key_id": { + "description": "Resource id of the Provider Key that authenticates the decision calls. The key's `provider` must be `typesafe`; its API key is sent as the bearer token, and its `api_base`, when set, replaces the default endpoint `https://api.typesafe.ai`.", + "minLength": 1, + "type": "string" + }, + "timeout_ms": { + "default": 3000, + "description": "Deadline for the decision call, in milliseconds.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/ClassifierType" + } + ], + "description": "Decision model family. `jev` is the only supported value." + } + }, + "required": [ + "provider_key_id", + "type" + ], + "type": "object" + }, + "SemanticMatch": { + "additionalProperties": false, + "description": "Matching parameters shared across every route in a semantic router.", + "properties": { + "aggregation": { + "allOf": [ + { + "$ref": "#/definitions/Aggregation" + } + ], + "default": "max", + "description": "Per-example score aggregation. v1: max." + }, + "distance_metric": { + "allOf": [ + { + "$ref": "#/definitions/DistanceMetric" + } + ], + "default": "cosine", + "description": "Similarity metric. v1: cosine." + }, + "threshold": { + "description": "Default similarity threshold for routes that do not set their own `threshold`. Higher is stricter.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + } + }, + "required": [ + "threshold" + ], + "type": "object" + }, + "SemanticRoute": { + "additionalProperties": false, + "allOf": [ + { + "anyOf": [ + { + "properties": { + "target": { + "type": "string" + } + }, + "required": [ + "target" + ] + }, + { + "properties": { + "target_id": { + "type": "string" + } + }, + "required": [ + "target_id" + ] + } + ] + } + ], + "description": "One semantic route: a labeled set of example utterances whose embeddings define the route. A request that scores high enough against them dispatches to `target`.", + "properties": { + "description": { + "description": "What requests this route handles. With a `classifier`, this is the text the decision model reads to pick the route, and it is required. Without one, it is documentation only: routes are matched on `examples`.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "examples": { + "description": "Example utterances that define this route. AISIX caches their vectors lazily. A cold route request passes its prompt and every distinct uncached example text as one logical embedding batch. Providers without batch support may issue multiple upstream calls (Bedrock Titan does). Required, with at least one example, when the router has no `classifier`; not accepted when it has one.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "name": { + "description": "Operator-facing route label. Surfaced in access logs (e.g. `prod-chat -> route:legal`), and in the `x-aisix-route` response header only when `proxy.expose_debug_headers` is enabled.", + "minLength": 1, + "type": "string" + }, + "target": { + "description": "Direct model alias that receives traffic matching this route. Read only when `target_id` is absent.", + "minLength": 1, + "type": "string" + }, + "target_id": { + "description": "Resource id of the direct model that receives traffic matching this route. Present, it is authoritative and `target` is ignored: the id is resolved against the models in the current configuration, so renaming that model keeps this route pointing at it with no edit to this document. An id resolving to no model is a route target that does not exist, exactly as a `target` naming no model is.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "threshold": { + "description": "Per-route similarity threshold. A request matches this route only when its aggregated score is `>=` this value. When omitted, the router-level threshold applies. Not accepted with a `classifier`.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "description": "Semantic-routing config: pick a target by request meaning.", + "properties": { + "classifier": { + "anyOf": [ + { + "$ref": "#/definitions/SemanticClassifier" + }, + { + "type": "null" + } + ], + "description": "Route selection by a hosted decision model instead of embeddings. When set, `embedding_model`, `embedding_model_id`, `embedding_timeout_ms`, `on_embedding_failure` and `match` are not accepted, every route needs a `description` and takes no `examples` or `threshold`. When omitted, routes are matched by embedding similarity." + }, + "default": { + "description": "Direct model alias used when no route clears its threshold. Read only when `default_id` is absent.", + "minLength": 1, + "type": "string" + }, + "default_id": { + "description": "Resource id of the direct model used when no route clears its threshold. Present, it is authoritative and `default` is ignored, so renaming that model keeps this router pointing at it with no edit to this document.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "embedding_model": { + "description": "Alias of an `embedding`-modality Model used to embed the request and, lazily on the first request that needs them, the route examples. Read only when `embedding_model_id` is absent. Required, under this name or as `embedding_model_id`, when there is no `classifier`.", + "minLength": 1, + "type": "string" + }, + "embedding_model_id": { + "description": "Resource id of the `embedding`-modality Model used to embed the request and the route examples. Present, it is authoritative and `embedding_model` is ignored: the id is resolved against the models in the current configuration, so renaming that model keeps this router pointing at it with no edit to this document. An id resolving to no model is an embedding model that does not exist, exactly as an `embedding_model` naming no model is — the router applies `on_embedding_failure`.", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "embedding_timeout_ms": { + "description": "Per-call deadline for the embedding request in milliseconds. `0` or absent disables the embedding-specific deadline.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "match": { + "anyOf": [ + { + "$ref": "#/definitions/SemanticMatch" + }, + { + "type": "null" + } + ], + "description": "Shared matching parameters (metric, aggregation, default threshold). Required when there is no `classifier`." + }, + "on_embedding_failure": { + "allOf": [ + { + "$ref": "#/definitions/OnEmbeddingFailure" + } + ], + "description": "Behavior when the embedding call fails or times out. Defaults to routing to `default`." + }, + "routes": { + "description": "Routes evaluated for each request. At least one is required.", + "items": { + "$ref": "#/definitions/SemanticRoute" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "routes" + ], + "title": "Semantic", + "type": "object" +} diff --git a/third_party/aisix/schemas/team.schema.json b/third_party/aisix/schemas/team.schema.json new file mode 100644 index 0000000..c2afb33 --- /dev/null +++ b/third_party/aisix/schemas/team.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "description": "The display name and cost-attribution tags of a team that API keys belong to.\n\nThe document's id is the value API keys carry in `team_id`. The gateway reads the name only to label telemetry, as the `team_name` metric label, and the tags only to attribute requests and to match `tag:` rate-limit policy dimensions; it never uses either for authentication or routing.", + "properties": { + "name": { + "description": "Display name of the team. An empty name is reported as `unknown`.", + "type": "string" + }, + "tags": { + "additionalProperties": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "description": "Cost-attribution tags of this team, as tag key to tag value. Each request made with an API key whose `team_id` names this team is attributed to them, per tag key, unless the key itself or a JWT's `tag_claims` sets the same key; they take precedence over the owning member's tags. Keys match `^[a-z][a-z0-9_]{0,62}$`, values are 1 to 128 characters, and at most 32 entries are allowed. Omitted or empty means the team adds no tags.", + "maxProperties": 32, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]{0,62}$" + }, + "type": "object" + } + }, + "required": [ + "name" + ], + "title": "Team", + "type": "object" +} diff --git a/third_party/aisix/schemas/user.schema.json b/third_party/aisix/schemas/user.schema.json new file mode 100644 index 0000000..2b3b13a --- /dev/null +++ b/third_party/aisix/schemas/user.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "description": "The display name and cost-attribution tags of an organization member who owns API keys.\n\nThe document's id is the value API keys carry in `user_id`. The gateway reads the name only to label telemetry, as the `user_name` metric label, where it takes precedence over the key's own `user_name`, and the tags only to attribute requests and to match `tag:` rate-limit policy dimensions; it never uses either for authentication or routing.", + "properties": { + "name": { + "description": "Display name of the member. An empty name is reported as `unknown`.", + "type": "string" + }, + "tags": { + "additionalProperties": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "description": "Cost-attribution tags of this member, as tag key to tag value. Each request made with an API key whose `user_id` names this member is attributed to them, per tag key, unless the key itself, the key's team or a JWT's `tag_claims` sets the same key. Keys match `^[a-z][a-z0-9_]{0,62}$`, values are 1 to 128 characters, and at most 32 entries are allowed. Omitted or empty means the member adds no tags.", + "maxProperties": 32, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]{0,62}$" + }, + "type": "object" + } + }, + "required": [ + "name" + ], + "title": "User", + "type": "object" +} From 24eb3f6dcef0b5a6689b39f745207eff64eb5090 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:10:41 +0545 Subject: [PATCH 03/14] feat: generate structural CRD schemas from the aisix schemas --- cmd/crdgen/main.go | 51 ++ config/crd/apikeys.aisix.api7.ai.yaml | 311 +++++++++ config/crd/kustomization.yaml | 6 + config/crd/models.aisix.api7.ai.yaml | 694 +++++++++++++++++++++ config/crd/providerkeys.aisix.api7.ai.yaml | 329 ++++++++++ internal/schema/convert.go | 532 ++++++++++++++++ internal/schema/crd.go | 326 ++++++++++ internal/schema/generate.go | 37 ++ internal/schema/schema_test.go | 252 ++++++++ 9 files changed, 2538 insertions(+) create mode 100644 cmd/crdgen/main.go create mode 100644 config/crd/apikeys.aisix.api7.ai.yaml create mode 100644 config/crd/kustomization.yaml create mode 100644 config/crd/models.aisix.api7.ai.yaml create mode 100644 config/crd/providerkeys.aisix.api7.ai.yaml create mode 100644 internal/schema/convert.go create mode 100644 internal/schema/crd.go create mode 100644 internal/schema/generate.go create mode 100644 internal/schema/schema_test.go diff --git a/cmd/crdgen/main.go b/cmd/crdgen/main.go new file mode 100644 index 0000000..5ded24f --- /dev/null +++ b/cmd/crdgen/main.go @@ -0,0 +1,51 @@ +// Command crdgen writes the CRDs generated from the vendored AISIX schemas. +// +// go run ./cmd/crdgen -schemas third_party/aisix -out config/crd +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/api7/aisix-controller/internal/schema" +) + +func main() { + src := flag.String("schemas", "third_party/aisix", "vendored AISIX schemas (with VERSION and schemas/)") + out := flag.String("out", "config/crd", "output directory") + flag.Parse() + + if err := run(*src, *out); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(src, out string) error { + files, err := schema.Generate(src) + if err != nil { + return err + } + if err := os.MkdirAll(out, 0o755); err != nil { + return err + } + var names []string + for _, f := range files { + if err := os.WriteFile(filepath.Join(out, f.Name), f.Body, 0o644); err != nil { + return err + } + names = append(names, f.Name) + } + kustomization := "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n" + for _, n := range names { + kustomization += " - " + n + "\n" + } + if err := os.WriteFile(filepath.Join(out, "kustomization.yaml"), []byte(kustomization), 0o644); err != nil { + return err + } + fmt.Printf("wrote %s\n", strings.Join(names, ", ")) + return nil +} diff --git a/config/crd/apikeys.aisix.api7.ai.yaml b/config/crd/apikeys.aisix.api7.ai.yaml new file mode 100644 index 0000000..bfc5484 --- /dev/null +++ b/config/crd/apikeys.aisix.api7.ai.yaml @@ -0,0 +1,311 @@ +# Generated by cmd/crdgen from api7/aisix@v1.9.0 schemas. Do not edit. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: apikeys.aisix.api7.ai +spec: + group: aisix.api7.ai + names: + categories: + - aisix + kind: APIKey + listKind: APIKeyList + plural: apikeys + shortNames: + - aikey + singular: apikey + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .status.conditions[?(@.type=="Programmed")].status + name: Programmed + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: AISIX APIKey resource. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + allowed_agents: + description: 'A2A agents this key may reach, named by their registered + names. Entries are matched as single-`*` globs, mirroring `allowed_models`: + `"*"` grants every agent and an entry without a `*` matches one + agent exactly. When omitted, set to `null`, or set to an empty list, + the key has no A2A agent access — access is granted explicitly.' + items: + type: string + nullable: true + type: array + allowed_models: + description: Model names this key may use, matched as single-`*` globs. + Read only when `allowed_model_ids` is absent; ignored entirely when + it is present. When both are omitted the key may use no model — + model access is granted explicitly. + items: + type: string + type: array + allowed_routes: + description: 'Passthrough routes this key may use, named by their + registered names. Entries are matched as single-`*` globs, mirroring + `allowed_models`: `"*"` grants every route and an entry without + a `*` matches one route exactly. When omitted, set to `null`, or + set to an empty list, the key may use no passthrough route — access + is granted explicitly.' + items: + type: string + nullable: true + type: array + disabled: + description: Administratively disabled. A disabled key is rejected + with `401` until it is enabled again; the key itself is preserved. + Treated as `false` when omitted. + type: boolean + display_name: + description: Operator-facing label for this key, as shown in the dashboard. + Read by the `${request.api_key.name}` header template (AISIX-Cloud#1112) + and as the `api_key_name` metric label; never used for authentication + or routing. Defaults to `metadata.name`. + nullable: true + type: string + expires_at: + description: RFC 3339 timestamp after which the key stops authenticating. + Requests presenting an expired key are rejected with `401`. When + omitted or set to `null`, the key never expires. + nullable: true + type: string + jwt_provider: + description: Name of the `oidc_providers` entry permitted to assert + this key's `jwt_subject`. A subject is only ever resolved for the + trust provider named here, so a second trusted provider cannot mint + a token impersonating this provider's identity of the same name. + Required whenever `jwt_subject` is set; ignored otherwise. + minLength: 1 + nullable: true + type: string + jwt_subject: + description: External identity bound to this key for JWT authentication. + When a request presents a valid JWT issued by the `oidc_providers` + entry named in `jwt_provider`, the value of that provider's `identity_claim` + selects the key whose `jwt_subject` equals it, and the request proceeds + with this key's permissions, rate limits, and budget. The `(jwt_provider, + jwt_subject)` pair is unique within the environment. When omitted, + the key is never selected by JWT authentication. + minLength: 1 + nullable: true + type: string + key_secret_ref: + description: Secret key holding the caller's plaintext API key. The + controller writes only its SHA-256 hash. + properties: + key: + description: Key within the Secret. + minLength: 1 + type: string + name: + description: Secret name, in the resource's namespace. + minLength: 1 + type: string + required: + - name + - key + type: object + mcp_access: + description: 'This key''s own layer of the MCP tool ACL, as namespaced + `__` glob patterns, or as `allow_ids` / `deny_ids` + entries naming the server by resource id. It is intersected with + the environment and team MCP access policies: every present layer + must allow a tool and no layer may deny it. When omitted the key + adds no constraint of its own — but with no layer present anywhere + the grant is empty, so MCP access is always granted explicitly.' + nullable: true + properties: + allow: + description: |- + Namespaced `__` patterns this key allows, intersected with the environment and team layers. Same single-`*` glob matching a policy's `allow` uses; an empty list leaves the key no MCP access, and `["*"]` narrows nothing (useful with `deny` alone). + + Required on the write path and defaulted by the runtime loader, for the reason given on [`McpPolicy::allow`]. Read only when `allow_ids` is absent; ignored entirely when it is present. + items: + type: string + type: array + deny: + description: Namespaced `__` patterns subtracted + from this key's effective grant, using the same single-`*` glob + matching as `allow`. Read only when `deny_ids` is absent. + items: + type: string + type: array + type: object + mcp_rate_limits: + additionalProperties: + description: |- + Limits for one API key's calls to one MCP server (`ApiKey::mcp_rate_limits`). + + Carries only the request-count dimensions of [`RateLimit`]: an MCP `tools/call` commits no tokens, so a token-rate cap here would never be consumed — the shape leaves `tpm`/`tpd` out rather than accepting a knob that is silently inert. + properties: + concurrency: + description: Max concurrent in-flight tool calls. + minimum: 0 + nullable: true + type: integer + rpd: + description: Tool calls per 86,400-second window. + minimum: 0 + nullable: true + type: integer + rph: + description: Tool calls per 3,600-second window. + minimum: 0 + nullable: true + type: integer + rpm: + description: Tool calls per 60-second window. + minimum: 0 + nullable: true + type: integer + rps: + description: Tool calls per 1-second window. + minimum: 0 + nullable: true + type: integer + type: object + description: |- + Per-MCP-server limits for this key, keyed by the registered MCP server name — the `` half of the `__` names the gateway exposes. A `tools/call` is metered against the entry for the server it targets **and** the key's own `rate_limit`, each in its own counter, so a burst against one server never consumes another's budget. A server with no entry here is bounded by `rate_limit` alone. Only tool calls are metered; the `initialize` / `tools/list` handshake is not. + + Read only when `mcp_rate_limits_by_id` is absent; ignored entirely when it is present. + nullable: true + type: object + rate_limit: + description: Request, token, and concurrency limits for this key. + nullable: true + properties: + concurrency: + description: Max concurrent in-flight requests. + minimum: 0 + type: integer + rpd: + description: Requests per 86,400-second window. + minimum: 0 + type: integer + rph: + description: Requests per 3,600-second window. There is no per-hour + token limit field. + minimum: 0 + type: integer + rpm: + description: Requests per 60-second window. + minimum: 0 + type: integer + rps: + description: Requests per 1-second window. There is no per-second + token limit field. + minimum: 0 + type: integer + tpd: + description: Tokens per 86,400-second window. + minimum: 0 + type: integer + tpm: + description: Tokens per 60-second window. + minimum: 0 + type: integer + type: object + tags: + additionalProperties: + maxLength: 128 + minLength: 1 + type: string + description: 'Cost-attribution tags, as tag key to tag value — for + example `{"department": "rnd", "subsidiary": "emea"}`. Every request + this key authenticates is attributed to one value per tag key, resolved + when the request is made with the nearest setting winning: this + key''s own value, else the value in the `tags` of the team `team_id` + names, else the value in the `tags` of the member `user_id` names. + Different keys combine, so a request can carry tags from all three. + A request authenticated by a JWT takes the value its identity provider''s + `tag_claims` reads from the verified token ahead of all three; nothing + else in a request sets a tag. Keys match `^[a-z][a-z0-9_]{0,62}$`, + values are 1 to 128 characters, and at most 32 entries are allowed. + Omitted or empty means the key adds no tags of its own.' + maxProperties: 32 + type: object + team_id: + description: Team this API key belongs to. Used for matching team-scope + rate limit policies. + minLength: 1 + nullable: true + type: string + user_id: + description: Org member who owns this key. Used for matching member-scope + rate limit policies. + minLength: 1 + nullable: true + type: string + user_name: + description: Readable display name of the owning member. Used only + for telemetry labels alongside `user_id`; never used for authentication + or routing. Read only when no `users` document exists for `user_id`; + when one does, its name is used instead. When neither provides a + name, telemetry reports the user name as `"unknown"`. + nullable: true + type: string + required: + - key_secret_ref + type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + format: date-time + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + reason: + type: string + status: + enum: + - "True" + - "False" + - Unknown + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml new file mode 100644 index 0000000..db7ebea --- /dev/null +++ b/config/crd/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - providerkeys.aisix.api7.ai.yaml + - models.aisix.api7.ai.yaml + - apikeys.aisix.api7.ai.yaml diff --git a/config/crd/models.aisix.api7.ai.yaml b/config/crd/models.aisix.api7.ai.yaml new file mode 100644 index 0000000..70e38d7 --- /dev/null +++ b/config/crd/models.aisix.api7.ai.yaml @@ -0,0 +1,694 @@ +# Generated by cmd/crdgen from api7/aisix@v1.9.0 schemas. Do not edit. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: models.aisix.api7.ai +spec: + group: aisix.api7.ai + names: + categories: + - aisix + kind: Model + listKind: ModelList + plural: models + shortNames: + - aimodel + singular: model + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .status.conditions[?(@.type=="Programmed")].status + name: Programmed + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: AISIX Model resource. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + allowed_cidrs: + description: Client IP allowlist in CIDR notation. Empty or absent + allows all clients. + items: + minLength: 1 + type: string + type: array + auto_prompt_caching: + description: Automatic prompt-cache breakpoint injection for Claude + models on the Anthropic API, Amazon Bedrock and Vertex AI. Omit + to leave injection off. + properties: + enabled: + description: Whether automatic prompt-cache injection is active + for this model. + type: boolean + ttl: + description: 'Cache lifetime for injected breakpoints: `5m` (default + when omitted) or `1h`. A `1h` cache write costs 2x the base + input rate versus 1.25x for `5m`, so it pays off only when the + cached prefix is reused across a longer session. Some Claude + models on Amazon Bedrock accept only the 5-minute cache, and + Bedrock rejects a request to such a model that asks for `1h`.' + enum: + - 5m + - 1h + type: string + required: + - enabled + type: object + background_model_check: + description: Direct-model-only background health-check configuration. + properties: + enabled: + description: Whether background health checks are enabled for + this model. + type: boolean + ignore_statuses: + description: Upstream status codes to ignore when evaluating background + check failures. + items: + maximum: 599 + minimum: 100 + type: integer + type: array + interval_seconds: + description: 'Seconds between background health checks. Minimum: + 5.' + minimum: 5 + type: integer + max_tokens: + description: Maximum completion tokens requested during each background + health check. + minimum: 1 + type: integer + prompt: + description: Prompt sent to the model during each background health + check. + minLength: 1 + type: string + stale_after_seconds: + description: Seconds after which the last completed background + check is considered stale. + minimum: 1 + type: integer + timeout_seconds: + description: 'Request timeout in seconds for each background health + check. Minimum: 1.' + minimum: 1 + type: integer + required: + - enabled + - interval_seconds + - max_tokens + - prompt + - stale_after_seconds + - timeout_seconds + type: object + cooldown: + description: 'Direct-model-only request-path cooldown configuration. + Cooldown is opt-in: omit this field, or leave `enabled` unset, and + the model is never taken out of rotation by request-path failures.' + properties: + default_seconds: + description: Cooldown TTL in seconds when the upstream did not + supply a `Retry-After` header or `honor_retry_after` is `false`. + minimum: 0 + type: integer + enabled: + description: Whether cooldown is active for this model. Cooldown + is off unless this is set to `true`, so a model that omits it + stays in rotation regardless of upstream failures. + type: boolean + honor_retry_after: + description: Whether to use the upstream's `Retry-After` header + as the cooldown TTL when it contains seconds. + type: boolean + max_seconds: + description: Upper bound on cooldown TTL when `Retry-After` is + used. + minimum: 1 + type: integer + trigger_on_timeout: + description: Whether request-path timeouts trigger cooldown. + type: boolean + trigger_on_transport: + description: Whether transport, decode, or stream-abort errors + trigger cooldown. + type: boolean + trigger_statuses: + description: Status codes that trigger cooldown, covering authentication + failures, rate limits, and transient server errors. Caller-side + validation errors such as `400`, `403`, and `422` are excluded. + items: + maximum: 599 + minimum: 100 + type: integer + type: array + type: object + cost: + description: Per-token cost for budget tracking. Omit it when cost + tracking is not needed. + properties: + input_per_1k: + description: Prompt token cost in USD per 1,000 tokens. + minimum: 0 + type: number + output_per_1k: + description: Completion token cost in USD per 1,000 tokens. + minimum: 0 + type: number + required: + - input_per_1k + - output_per_1k + type: object + display_name: + description: Operator-facing unique label. Surfaces on `/v1/models`, + `req.model` on chat completions, `ApiKey.allowed_models`, and the + dashboard model list. `Resource::name()` returns this. Defaults + to `metadata.name`. + minLength: 1 + type: string + effort_mapping: + additionalProperties: + minLength: 1 + nullable: true + type: string + description: |- + Direct-model-only mapping from a client-requested reasoning effort to the value sent upstream. After resolving the final target the gateway looks the requested effort up once — the exact entry first, then the `*` entry — and never looks the result up again. A requested value that matches neither is sent unchanged. + + Three entries are reserved. The empty-string key matches a request that sets no effort at all, meaning the effort field is absent, `null`, or empty, and its value is added to the outbound request. The `*` key matches any other present value that has no entry of its own, and never matches a request that sets no effort. A `null` value removes the effort field from the outbound request so the provider's own default applies; mapping `*` to `null` therefore strips the effort from every request whose value has no entry of its own. The empty-string key may not be mapped to `null`, because a request that sets no effort has nothing to remove, and no entry may be mapped to an empty value. + + On the Anthropic messages and token-counting endpoints only `output_config.effort` is read and rewritten. A `thinking` block is not an effort setting for this mapping, so a request that carries `thinking` but no `output_config.effort` sets no effort and the empty-string entry applies to it. When such a request is dispatched to a provider that does not accept the Anthropic protocol, the upstream reasoning effort is derived from the mapped `output_config.effort` when one is present and from `thinking` otherwise, except that an entry that removed the effort sends no effort at all. A request that sets `thinking.type: disabled` is never given an effort by this mapping: on the Anthropic protocol no entry writes a tier to it, though an entry that removes the effort still removes it, and on any other protocol it always sends the `none` effort. + + Claude families that reject `output_config.effort` (Haiku 4.5, Sonnet 4.5 and earlier) are the exception on those two endpoints when the model speaks the Anthropic protocol: an effort the empty-string entry would add to a request that sets none becomes `thinking: {type: enabled, budget_tokens}` instead (minimal and low 1024, medium 2048, high 4096, xhigh 8192, max 16384). The budget stays below the request's `max_tokens`, a request on the messages endpoint without `max_tokens` is sent the budget plus 4096, and a `max_tokens` of 1024 or less gets no thinking. A request that already carries a `thinking` block is sent as written, so the empty-string entry has no effect on it for these families, and a value with no budget is not sent at all. + type: object + embedding: + description: Embedding-modality metadata. Present on direct Models + that serve an OpenAI-compatible `/v1/embeddings` endpoint (and can + be referenced by a semantic router's `embedding_model`). + properties: + dimensions: + description: Output vector dimensionality. Used to validate vectors, + key the example-vector cache, and (for endpoints that support + it) request a reduced output size. + minimum: 1 + type: integer + normalize: + description: Whether the endpoint already returns L2-normalized + vectors. When `false`, the gateway normalizes before computing + cosine similarity. Defaults to `true`. + type: boolean + required: + - dimensions + type: object + ensemble: + description: Ensemble configuration for panel calls and judge synthesis. + properties: + judge: + description: Direct model that combines successful panel responses. + properties: + model: + description: Model alias for the direct model that synthesizes + panel responses. Read only when `model_id` is absent. + minLength: 1 + type: string + synthesis_prompt: + description: Override for the built-in synthesis prompt template. + minLength: 1 + type: string + type: object + min_responses: + description: Minimum successful panel responses required before + judge synthesis. When omitted, the gateway requires the smaller + of 2 and the panel size. + minimum: 1 + type: integer + panel: + description: Direct models called concurrently for each ensemble + request. + items: + description: One member of an ensemble panel. The member model + is named either by `model` (its display name) or by `model_id` + (its resource id); a member must carry at least one of the + two. + properties: + model: + description: Model alias for a direct model that receives + one panel request. Read only when `model_id` is absent. + minLength: 1 + type: string + seed: + description: Sampling seed for this panel member. + minimum: 0 + type: integer + temperature: + description: Sampling temperature for this panel member. + Omit it to keep the request's temperature. + minimum: 0 + type: number + weight: + description: Reserved for a future voting/quorum strategy. + AISIX currently ignores this field. + minimum: 0 + type: integer + type: object + minItems: 1 + type: array + timeout_ms: + description: Per-call upstream deadline applied to each panel + member and the judge. Set `0` or omit it to disable the ensemble-level + deadline. + minimum: 0 + type: integer + required: + - judge + - panel + type: object + model_name: + description: Upstream model identifier sent in provider requests. + Routing and ensemble models leave this field unset. + minLength: 1 + type: string + pricing_key: + description: Name of a shared `pricing` document to take the per-token + cost from, instead of setting `cost` on this model. The environment's + own pricing documents are searched first and the shared catalog + second; when the key matches neither, `cost` applies. Editing the + pricing document repricies every model naming it, with no change + to the models themselves. + maxLength: 255 + minLength: 1 + type: string + provider: + description: Upstream vendor identity used for dispatch, compatibility + checks, telemetry, and access logs. Routing and ensemble models + leave this field unset. + maxLength: 64 + minLength: 1 + pattern: ^[a-z0-9][a-z0-9._-]*$ + type: string + provider_key_ref: + description: ProviderKey, in the same namespace, this model authenticates + with. Direct models need it; routing, ensemble and semantic models + leave it unset. + properties: + name: + description: ProviderKey `metadata.name`. + minLength: 1 + type: string + required: + - name + type: object + rate_limit: + description: Request, token, and concurrency limits for this model. + properties: + concurrency: + description: Max concurrent in-flight requests. + minimum: 0 + type: integer + rpd: + description: Requests per 86,400-second window. + minimum: 0 + type: integer + rph: + description: Requests per 3,600-second window. There is no per-hour + token limit field. + minimum: 0 + type: integer + rpm: + description: Requests per 60-second window. + minimum: 0 + type: integer + rps: + description: Requests per 1-second window. There is no per-second + token limit field. + minimum: 0 + type: integer + tpd: + description: Tokens per 86,400-second window. + minimum: 0 + type: integer + tpm: + description: Tokens per 60-second window. + minimum: 0 + type: integer + type: object + retries: + description: Retry attempts against this model after a retryable upstream + failure, before the request gives up (or, inside a model group, + fails over to the next target). Absent falls back to the group's + `routing.retries`, then to the deployment-wide `upstream.retries` + default. + minimum: 0 + type: integer + routing: + description: Virtual routing configuration. When set, the gateway + selects a target from `routing.targets` and uses that target model's + `provider`, `model_name`, and `provider_key_id` fields for upstream + dispatch. + properties: + fallback_on_statuses: + description: Additional upstream HTTP status codes that participate + in retries and failover. By default a non-429 4xx response is + treated as a caller error and returned as-is; providers that + use 4xx codes for transient conditions (model overload, queue + full, quota exhaustion) can be listed here, for example `[408, + 409]`. 5xx codes are already retryable, so listing them changes + nothing. Authentication (`401`/`403`) and validation (`400`) + codes should only be listed when the provider is known to use + them for transient failures. + items: + maximum: 599 + minimum: 400 + type: integer + type: array + hash_on: + description: 'Where the `consistent_hash` hash key comes from: + an ordered chain of sources, the first non-empty value winning. + Defaults to the `x-aisix-routing-key` request header, falling + back to the caller''s API key id. Only valid with `strategy: + consistent_hash`.' + items: + description: One source for the `consistent_hash` hash key. + Sources are tried in order; the first one that yields a non-empty + value wins. + properties: + name: + description: The header or cookie name to read. Required + for `header` and `cookie` sources; not accepted for `api_key` + or `client_ip`. + minLength: 1 + type: string + type: + description: Which request attribute supplies the hash key. + enum: + - header + - cookie + - api_key + - client_ip + type: string + required: + - type + type: object + minItems: 1 + type: array + max_fallbacks: + description: Max number of later targets to attempt after the + initial target fails permanently. When omitted, all later targets + may be attempted. + minimum: 0 + type: integer + retries: + description: Retry attempts on the current target before failing + over, applied to every target that does not set its own `retries`. + Absent falls back to the deployment-wide `upstream.retries` + default. + minimum: 0 + type: integer + retry_on_429: + description: Whether upstream 429 participates in retries and + failover. + type: boolean + strategy: + description: Strategy used to select a target for each request. + enum: + - round_robin + - consistent_hash + - failover + - least_cost + - least_latency + - least_busy + type: string + targets: + description: Ordered set of direct models available to this routing + model. + items: + description: One destination in a routing configuration. The + target model is named either by `model` (its display name) + or by `model_id` (its resource id); a target must carry at + least one of the two. + properties: + model: + description: |- + Model alias for a direct model that can receive routed traffic. Read only when `model_id` is absent. + + Defaulted at the type level and required by the schemas instead — as one half of a "`model` or `model_id`" alternative, so a target naming its model by id alone validates while one naming it neither way is still rejected. + minLength: 1 + type: string + priority: + description: Priority tier, default `0`; a higher value + is preferred (the APISIX node-priority convention — give + backup targets `-1`). The strategy orders targets within + each tier; a lower tier is only tried when every higher-tier + target failed or is unavailable. + type: integer + tags: + description: Tags for tag/metadata-conditional routing. + When a request carries routing tags, only targets whose + tags intersect the request's are eligible; a target tagged + `"default"` is the fallback used when nothing matches + and for untagged requests. Absent/empty means the target + opts out of tag filtering (eligible only via the default + fallback once any sibling target is tagged). The configured + strategy then orders whatever set survives. + items: + minLength: 1 + type: string + type: array + weight: + description: Target weight, default `1`. Used by `round_robin` + (rotation share), `consistent_hash` (share of the hash + ring), and `least_busy` (in-flight divided by weight). + `failover`, `least_cost`, and `least_latency` accept the + field but do not use it. + minimum: 0 + type: integer + type: object + minItems: 1 + type: array + when_all_unavailable: + description: Policy to apply when every target is unavailable + because of runtime health or cooldown state. + enum: + - fail + - try_anyway + type: string + required: + - targets + type: object + semantic: + description: Semantic-routing configuration. When set, the gateway + embeds the request and dispatches to the route whose examples it + matches best, using that route's target Model for upstream dispatch. + properties: + classifier: + description: Route selection by a hosted decision model instead + of embeddings. When set, `embedding_model`, `embedding_model_id`, + `embedding_timeout_ms`, `on_embedding_failure` and `match` are + not accepted, every route needs a `description` and takes no + `examples` or `threshold`. When omitted, routes are matched + by embedding similarity. + properties: + min_confidence: + description: Lowest confidence at which the picked route is + used. A decision below it sends the request to `default`. + maximum: 1 + minimum: 0 + type: number + model: + description: Decision model to call. + minLength: 1 + type: string + on_failure: + description: Behavior when the decision call fails, times + out, or returns an answer that names no route. Same values + as `on_embedding_failure`. + x-kubernetes-preserve-unknown-fields: true + timeout_ms: + description: Deadline for the decision call, in milliseconds. + minimum: 1 + type: integer + type: + description: Decision model family. `jev` is the only supported + value. + enum: + - jev + type: string + required: + - type + type: object + default: + description: Direct model alias used when no route clears its + threshold. Read only when `default_id` is absent. + minLength: 1 + type: string + embedding_model: + description: Alias of an `embedding`-modality Model used to embed + the request and, lazily on the first request that needs them, + the route examples. Read only when `embedding_model_id` is absent. + Required, under this name or as `embedding_model_id`, when there + is no `classifier`. + minLength: 1 + type: string + embedding_timeout_ms: + description: Per-call deadline for the embedding request in milliseconds. + `0` or absent disables the embedding-specific deadline. + minimum: 0 + type: integer + match: + description: Shared matching parameters (metric, aggregation, + default threshold). Required when there is no `classifier`. + properties: + aggregation: + description: 'Per-example score aggregation. v1: max.' + enum: + - max + type: string + distance_metric: + description: 'Similarity metric. v1: cosine.' + enum: + - cosine + type: string + threshold: + description: Default similarity threshold for routes that + do not set their own `threshold`. Higher is stricter. + maximum: 1 + minimum: 0 + type: number + required: + - threshold + type: object + on_embedding_failure: + description: Behavior when the embedding call fails or times out. + Defaults to routing to `default`. + x-kubernetes-preserve-unknown-fields: true + routes: + description: Routes evaluated for each request. At least one is + required. + items: + description: 'One semantic route: a labeled set of example utterances + whose embeddings define the route. A request that scores high + enough against them dispatches to `target`.' + properties: + description: + description: 'What requests this route handles. With a `classifier`, + this is the text the decision model reads to pick the + route, and it is required. Without one, it is documentation + only: routes are matched on `examples`.' + minLength: 1 + type: string + examples: + description: Example utterances that define this route. + AISIX caches their vectors lazily. A cold route request + passes its prompt and every distinct uncached example + text as one logical embedding batch. Providers without + batch support may issue multiple upstream calls (Bedrock + Titan does). Required, with at least one example, when + the router has no `classifier`; not accepted when it has + one. + items: + minLength: 1 + type: string + minItems: 1 + type: array + name: + description: Operator-facing route label. Surfaced in access + logs (e.g. `prod-chat -> route:legal`), and in the `x-aisix-route` + response header only when `proxy.expose_debug_headers` + is enabled. + minLength: 1 + type: string + target: + description: Direct model alias that receives traffic matching + this route. Read only when `target_id` is absent. + minLength: 1 + type: string + threshold: + description: Per-route similarity threshold. A request matches + this route only when its aggregated score is `>=` this + value. When omitted, the router-level threshold applies. + Not accepted with a `classifier`. + maximum: 1 + minimum: 0 + type: number + required: + - name + type: object + minItems: 1 + type: array + required: + - routes + type: object + stream_timeout: + description: Maximum gap in milliseconds between upstream streaming + chunks. `0` or absent falls back to the group's `stream_timeout`, + then to the model's (or group's) `timeout`, then to the deployment-wide + `upstream.stream_timeout_ms` / `timeout_ms` defaults. + minimum: 0 + type: integer + timeout: + description: End-to-end timeout in milliseconds for non-streaming + upstream calls. Absent falls back to the group's `timeout`, then + to the deployment-wide `upstream.timeout_ms` default. `0` disables + the non-streaming timeout for this model. + minimum: 0 + type: integer + type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + format: date-time + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + reason: + type: string + status: + enum: + - "True" + - "False" + - Unknown + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/providerkeys.aisix.api7.ai.yaml b/config/crd/providerkeys.aisix.api7.ai.yaml new file mode 100644 index 0000000..f1bfe21 --- /dev/null +++ b/config/crd/providerkeys.aisix.api7.ai.yaml @@ -0,0 +1,329 @@ +# Generated by cmd/crdgen from api7/aisix@v1.9.0 schemas. Do not edit. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: providerkeys.aisix.api7.ai +spec: + group: aisix.api7.ai + names: + categories: + - aisix + kind: ProviderKey + listKind: ProviderKeyList + plural: providerkeys + shortNames: + - aipk + singular: providerkey + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .status.conditions[?(@.type=="Programmed")].status + name: Programmed + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: AISIX ProviderKey resource. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + adapter: + description: Upstream API protocol family used when provider-specific + dispatch is unavailable. + enum: + - openai + - anthropic + - bedrock + - vertex + - azure-openai + nullable: true + type: string + api_base: + description: Override base URL for the upstream provider. Required + for custom or OpenAI-compatible providers that should not use a + built-in vendor endpoint. + nullable: true + type: string + api_key_secret_ref: + description: Secret key holding the upstream provider credential. + The controller writes it as `api_key`. + properties: + key: + description: Key within the Secret. + minLength: 1 + type: string + name: + description: Secret name, in the resource's namespace. + minLength: 1 + type: string + required: + - name + - key + type: object + apis: + description: |- + API surfaces this upstream serves natively, beyond the one its `adapter` already implies, and the base URL each one lives at. + + One upstream account often exposes more than one protocol, on different paths of the same host — an OpenAI-compatible `/v1/chat/completions` under `…/v1` and an Anthropic-compatible `/v1/messages` under `…/anthropic`, both authenticated by the same credential. `api_base` can only name one of them, so without this field every request the declared path cannot serve gets translated instead — losing whatever the target protocol carries that the canonical chat shape does not (prompt-cache breakpoints, thinking blocks). Declaring the second entry here lets each inbound protocol reach its own native path under the one credential. + + Each surface resolves on its own terms; see [`ProviderApis`]. Surfaces this map has no key for — embeddings, audio, images, videos, files/batches/fine-tuning, rerank — always use `api_base`, exactly as before. + nullable: true + properties: + messages: + description: Anthropic-wire `/v1/messages` (and `/v1/messages/count_tokens`). + properties: + base: + description: |- + Base URL this surface is served at. Omit when it is the same one `api_base` names — an entry with no `base` still carries the declaration that the surface exists. + + Deliberately NOT length-constrained, matching `api_base`. The lenient read schema keeps every constraint but the open-object one, so a `minLength` here would make an empty string skip the whole Provider Key row — and with it every model that references the key — where the same empty string on `api_base` is the control plane's documented way to clear an override. An empty value is treated as "no override" at resolution time instead. + type: string + type: object + responses: + description: OpenAI-wire `/v1/responses`. + properties: + base: + description: |- + Base URL this surface is served at. Omit when it is the same one `api_base` names — an entry with no `base` still carries the declaration that the surface exists. + + Deliberately NOT length-constrained, matching `api_base`. The lenient read schema keeps every constraint but the open-object one, so a `minLength` here would make an empty string skip the whole Provider Key row — and with it every model that references the key — where the same empty string on `api_base` is the control plane's documented way to clear an override. An empty value is treated as "no override" at resolution time instead. + type: string + type: object + type: object + display_name: + description: Operator-facing label, unique within the gateway. Surfaces + in the Admin API list view and in dashboard UIs that wrap this resource. + Defaults to `metadata.name`. + minLength: 1 + type: string + provider: + description: Upstream provider identifier, such as `"deepseek"`, `"openai"`, + or a model catalog ID. The gateway uses this value for provider-specific + dispatch and base URL validation. + type: string + request: + description: Per-key request-shape overrides applied by supported + provider paths before dispatch to the upstream provider. + nullable: true + properties: + default_body_fields: + additionalProperties: + x-kubernetes-preserve-unknown-fields: true + description: '`apply_default_body_fields` input. Top-level body + fields added when the caller did not set them. `serde_json::Map` + preserves insertion order on serialize, matching the etcd round-trip.' + type: object + default_headers: + additionalProperties: + type: string + description: |- + Top-level headers added to the outbound request when the caller did not set them. Values may reference the request context with `${...}` variables, such as `"${request.api_key.team_id}"`; a header whose variables do not all resolve is dropped rather than sent blank. See [`crate::header_template`] for the closed variable vocabulary. + + "When the caller did not set them" includes the gateway itself: an entry naming the slot this ProviderKey's credential occupies is not applied. Use `forward_client_headers` to put the caller's own credential there instead. + type: object + forward_client_headers: + description: |- + Inbound client headers forwarded to the upstream, as single-`*` glob patterns matched case-insensitively against the header name (`"anthropic-beta"`, `"x-trace-*"`, `"authorization"`). Empty — the default — forwards nothing, which is the behavior of every standard-protocol endpoint before AISIX-Cloud#1167. + + A header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded. + + A header named here reaches the upstream whatever the gateway would otherwise do with it. Naming a credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — hands the upstream the caller's own credential in place of the one this ProviderKey would inject there, never both. That is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged. Any OTHER header the gateway had already set is left alone: it selects how the exchange works, not who it is from. + + A credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `"*"` or `"x-*"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry. + + Two cases where a named header still does not reach the upstream. A `default_headers` entry of the same name wins it for every name except a credential slot: both are operator configuration and the static one is the more specific choice, but in a credential slot the forwarded value is precisely the one that was asked for, so it takes the slot from the static entry. And on an AWS Bedrock provider the request signer owns `authorization`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token`, `x-amz-target` and `x-amzn-bedrock-accept`, and drops any supplied value — a value there would not authenticate anyone: it either loses to the signer or breaks the signature. + + Naming a credential slot needs a data plane new enough to honor it; an older one refuses those names outright, so the pattern has no effect there rather than a different one. + + Headers whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace. The headers describing a body this gateway re-serializes or a response shape it parses (`content-type`, `content-length`, `accept`, `anthropic-version`, `x-stainless-*`) are excluded for the same reason. `traceparent` and `tracestate` are forwarded only when a pattern names them exactly — a glob is not read as consent to graft the caller's trace onto the upstream's telemetry. + items: + type: string + type: array + param_constraints: + description: Parameter constraints applied to the outbound request. + If omitted, no clamping is applied. + properties: + temperature_max: + description: Upper bound for `temperature`. Values above this + are clamped to this value. If omitted, no upper bound is + applied. + type: number + temperature_min: + description: Lower bound for `temperature`. Values below this + are clamped to this value. If omitted, no lower bound is + applied. + type: number + type: object + param_renames: + additionalProperties: + type: string + description: '`apply_param_renames` input. Top-level body keys + named on the left are renamed to the right. Leave empty to preserve + request parameter names.' + type: object + type: object + resolve_addresses: + description: |- + IP addresses the gateway connects to for this key's `api_base` host, instead of resolving that host through DNS. Each entry is an IPv4 or IPv6 address literal, without a port and without brackets. + + Use this when the upstream is reached over a private link that has no DNS entry, while the provider still requires its own hostname in the request. Only the connection target changes: the `Host` header, the HTTP/2 `:authority`, the TLS server name and the certificate check all keep using the hostname from `api_base`, and the port and scheme keep coming from `api_base` too. + + Several addresses are tried in the order given, as a resolver's answer would be: the next one is attempted when a connection cannot be established, which is how a private link that terminates on one address per availability zone stays reachable when one is down. + + Scoped to the `api_base` hostname and nothing else. An `apis` entry that serves a second protocol from the same host is reached over the same link, because it is the same hostname; one that names a different host is resolved normally. A key with no `api_base`, or whose `api_base` is already an address literal, has no hostname to override and is dispatched unchanged. + + Honoured on every surface that dispatches through the Provider Key's own client: chat completions, completions, embeddings, images, audio, `/v1/messages` (and `count_tokens`), `/v1/responses`, rerank, videos, the files/batches/fine-tuning surface and `/passthrough/*`. Not honoured for Amazon Bedrock or `/v1/realtime`, which connect on their own transports — the same two that `tls` does not reach. + + Not applicable when the gateway reaches its upstreams through a forward proxy (`HTTPS_PROXY` / `ALL_PROXY` in the gateway's environment): the proxy is given the hostname and resolves it itself, so nothing here is consulted. + items: + pattern: ^[0-9A-Fa-f:.]+$ + type: string + minItems: 1 + nullable: true + type: array + response: + description: Per-key response-shape overrides applied by provider + bridges that support response transformation. + nullable: true + properties: + content_list_to_string: + description: When `true`, the request-body `messages[*].content` + array of text blocks gets flattened to a single string before + dispatch. + type: boolean + error_envelope: + description: Stored error-envelope preference for compatibility + with control-plane configuration. The proxy does not currently + apply this field. + type: string + reasoning_field: + description: 'Path used to extract reasoning content from the + provider response. If omitted or empty, no reasoning field is + lifted. Example: `"delta.reasoning_content"`.' + type: string + stream_done_marker: + description: Stream `[DONE]` terminator expectation. If omitted, + either presence or absence of the terminator is accepted. + enum: + - required + - optional + - none + type: string + type: object + strip_headers: + description: 'Inbound headers removed before passthrough forwarding. + A passthrough route with `credential_mode: inject` also removes + every credential slot, listed here or not.' + items: + type: string + type: array + telemetry_tags: + description: Telemetry tags carried alongside the key for metric and + log emission. + properties: + branded_provider: + description: Branded provider slug for catalog entries, such as + `"openai"` or `"anthropic"`. Bring-your-own providers leave + this field unset. + nullable: true + type: string + byo_label: + description: Operator-defined label for bring-your-own entries, + such as an internal team name. Catalog entries leave this field + unset. + nullable: true + type: string + featured: + description: Whether this provider is surfaced in the featured + list. + type: boolean + kind: + description: Provider-key category, such as `"catalog"` for curated + providers or `"byo"` for bring-your-own providers. + enum: + - catalog + - byo + nullable: true + type: string + pk_label: + description: Operator-defined label for this provider key, such + as `"production"` or `"shared-test"`. + nullable: true + type: string + type: object + tls: + description: TLS settings for connections to this key's `api_base`. + Omit to use the gateway's deployment-wide trust settings. + nullable: true + properties: + ca_cert: + description: PEM-encoded certificate authority certificates trusted + as issuers for this endpoint, in addition to the gateway's default + trust store. A bundle containing several certificates is accepted. + type: string + verify: + description: Whether the endpoint's certificate is verified. Setting + this to `false` accepts any certificate, including one presented + by an intercepting party, and is intended only for test environments. + type: boolean + type: object + required: + - api_key_secret_ref + type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + format: date-time + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + reason: + type: string + status: + enum: + - "True" + - "False" + - Unknown + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/schema/convert.go b/internal/schema/convert.go new file mode 100644 index 0000000..1945167 --- /dev/null +++ b/internal/schema/convert.go @@ -0,0 +1,532 @@ +// Package schema turns AISIX's strict draft-07 resource schemas into +// structural OpenAPI v3 schemas a CRD accepts. +// +// The conversion keeps types, required fields, enums, ranges, lengths and +// patterns. It drops what a structural schema cannot carry (exclusive +// oneOf branches, if/then, not, defaults, formats). The gateway still +// enforces those: the controller validates every resources file with the +// aisix binary before writing it. +package schema + +import ( + "encoding/json" + "sort" + "strconv" + "strings" +) + +type obj = map[string]any + +const ( + preserveKey = "x-kubernetes-preserve-unknown-fields" + intOrStringKey = "x-kubernetes-int-or-string" +) + +// Keywords copied as-is onto a structural node. +var kept = []string{ + "description", "enum", "minimum", "maximum", "minLength", "maxLength", + "pattern", "minItems", "maxItems", "minProperties", "maxProperties", "required", +} + +func preserve(description any) obj { + m := obj{preserveKey: true} + if description != nil { + m["description"] = description + } + return m +} + +func clone(m obj) obj { + out := make(obj, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// ToStructural converts a draft-07 schema, with its root definitions, to a +// structural schema. +func ToStructural(root obj) obj { + defs, _ := root["definitions"].(obj) + out := convert(root, defs, nil) + delete(out, preserveKey) + out["type"] = "object" + return out +} + +// isConstraintOnly: a composition branch that only constrains values and +// adds no shape of its own. +func isConstraintOnly(branch any) bool { + m, ok := branch.(obj) + if !ok { + return false + } + for k := range m { + switch k { + case "required", "not", "title", "description", "if", "then", "else": + default: + return false + } + } + return true +} + +func isNullBranch(branch any) bool { + m, ok := branch.(obj) + return ok && m["type"] == "null" +} + +func contains(stack []string, s string) bool { + for _, x := range stack { + if x == s { + return true + } + } + return false +} + +// flatten resolves $ref and single-branch allOf into one schema object. The +// referring node's own keywords win. +func flatten(node obj, defs obj, stack []string) (obj, bool) { + out := clone(node) + if r, ok := out["$ref"]; ok { + delete(out, "$ref") + s, _ := r.(string) + name, ok := strings.CutPrefix(s, "#/definitions/") + if !ok || contains(stack, name) { + return nil, false + } + target, ok := defs[name].(obj) + if !ok { + return nil, false + } + merged, ok := flatten(target, defs, append(stack, name)) + if !ok { + return nil, false + } + for k, v := range out { + merged[k] = v + } + out = merged + } + if all, ok := out["allOf"].([]any); ok { + var shaped []any + for _, b := range all { + if !isConstraintOnly(b) { + shaped = append(shaped, b) + } + } + if len(shaped) == 1 { + delete(out, "allOf") + branch, ok := shaped[0].(obj) + if !ok { + return nil, false + } + merged, ok := flatten(branch, defs, stack) + if !ok { + return nil, false + } + for k, v := range out { + merged[k] = v + } + out = merged + } + } + return out, true +} + +func intOrString(types []string) bool { + return len(types) == 2 && + ((types[0] == "integer" && types[1] == "string") || (types[0] == "string" && types[1] == "integer")) +} + +func open(description any, nullable, intOrStr bool) obj { + p := preserve(description) + if intOrStr { + delete(p, preserveKey) + p[intOrStringKey] = true + } + if nullable { + p["nullable"] = true + } + return p +} + +func convert(node any, defs obj, stack []string) obj { + m0, ok := node.(obj) + if !ok || len(m0) == 0 { + return preserve(nil) + } + m, ok := flatten(m0, defs, stack) + if !ok { + return preserve(m0["description"]) + } + + nullable := false + + for _, key := range []string{"anyOf", "oneOf", "allOf"} { + branches, ok := m[key].([]any) + if !ok { + continue + } + delete(m, key) + var shaped []any + for _, b := range branches { + if isNullBranch(b) { + nullable = true + } else if !isConstraintOnly(b) { + shaped = append(shaped, b) + } + } + if len(shaped) == 0 { + continue + } + converted := make([]obj, 0, len(shaped)) + for _, b := range shaped { + converted = append(converted, convert(b, defs, stack)) + } + merged, ok := mergeBranches(converted, key == "allOf") + if !ok { + var types []string + for _, c := range converted { + if t, ok := c["type"].(string); ok { + types = append(types, t) + } + } + return open(m["description"], nullable, len(converted) == 2 && intOrString(types)) + } + for k, v := range merged { + if k == "description" { + if _, has := m["description"]; has { + continue + } + } + if k == "properties" { + dst, _ := m["properties"].(obj) + if dst == nil { + dst = obj{} + } else { + dst = clone(dst) + } + for pk, pv := range v.(obj) { + if _, has := dst[pk]; !has { + dst[pk] = pv + } + } + m["properties"] = dst + continue + } + if _, has := m[k]; !has { + m[k] = v + } + } + } + + var ty string + switch t := m["type"].(type) { + case string: + if t == "null" { + nullable = true + } else { + ty = t + } + case []any: + var rest []string + for _, x := range t { + s, _ := x.(string) + switch s { + case "null": + nullable = true + case "": + default: + rest = append(rest, s) + } + } + switch len(rest) { + case 0: + case 1: + ty = rest[0] + default: + return open(m["description"], nullable, intOrString(rest)) + } + } + delete(m, "type") + + out := obj{} + for _, k := range kept { + if v, ok := m[k]; ok { + out[k] = v + } + } + if c, ok := m["const"]; ok { + out["enum"] = []any{c} + } + // draft-07 exclusive bounds are numbers; OpenAPI v3.0 uses booleans. + for _, pair := range [][2]string{{"exclusiveMinimum", "minimum"}, {"exclusiveMaximum", "maximum"}} { + if n, ok := m[pair[0]]; ok && isNumber(n) { + out[pair[1]] = n + out[pair[0]] = true + } + } + if values, ok := out["enum"].([]any); ok { + var kept []any + for _, v := range values { + if v == nil { + nullable = true + continue + } + kept = append(kept, v) + } + if len(kept) == 0 { + delete(out, "enum") + } else { + out["enum"] = kept + } + } + + props, hasProps := m["properties"].(obj) + additional, hasAdditional := m["additionalProperties"] + items, hasItems := m["items"] + + if ty == "" { + switch { + case hasProps || hasAdditional: + ty = "object" + case hasItems: + ty = "array" + default: + if values, ok := out["enum"].([]any); ok { + ty = inferEnumType(values) + } + } + } + + switch ty { + case "object": + var additionalSchema obj + switch a := additional.(type) { + case obj: + if len(a) > 0 { + additionalSchema = convert(a, defs, stack) + } else { + additionalSchema = preserve(nil) + } + case bool: + if a { + additionalSchema = preserve(nil) + } + } + // A CRD property name cannot be empty, so a "" entry is left to + // additionalProperties. + converted := obj{} + for k, v := range props { + if k != "" { + converted[k] = convert(v, defs, stack) + } + } + switch { + case len(converted) > 0: + out["properties"] = converted + case additionalSchema != nil: + out["additionalProperties"] = additionalSchema + default: + out[preserveKey] = true + } + out["type"] = "object" + pruneRequired(out) + case "array": + item := preserve(nil) + if i, ok := items.(obj); ok { + item = convert(i, defs, stack) + } + out["items"] = item + out["type"] = "array" + delete(out, "required") + case "string", "integer", "number", "boolean": + out["type"] = ty + delete(out, "required") + if values, ok := out["enum"].([]any); ok { + for _, v := range values { + if !matchesType(v, ty) { + delete(out, "enum") + break + } + } + } + default: + return open(m["description"], nullable, false) + } + if nullable { + out["nullable"] = true + } + return out +} + +// pruneRequired keeps only required entries that name a declared property. +func pruneRequired(out obj) { + req, ok := out["required"].([]any) + if !ok { + return + } + props, _ := out["properties"].(obj) + var keep []any + for _, r := range req { + if s, ok := r.(string); ok { + if _, declared := props[s]; declared { + keep = append(keep, s) + } + } + } + if len(keep) == 0 { + delete(out, "required") + } else { + out["required"] = keep + } +} + +func isNumber(v any) bool { + switch v.(type) { + case json.Number, float64, int, int64: + return true + } + return false +} + +func isInteger(v any) bool { + switch n := v.(type) { + case json.Number: + _, err := strconv.ParseInt(n.String(), 10, 64) + return err == nil + case int, int64: + return true + case float64: + return n == float64(int64(n)) + } + return false +} + +func matchesType(v any, t string) bool { + switch t { + case "string": + _, ok := v.(string) + return ok + case "integer": + return isInteger(v) + case "number": + return isNumber(v) + case "boolean": + _, ok := v.(bool) + return ok + } + return false +} + +func inferEnumType(values []any) string { + if len(values) == 0 { + return "" + } + for _, t := range []string{"string", "integer", "number", "boolean"} { + all := true + for _, v := range values { + if !matchesType(v, t) { + all = false + break + } + } + if all { + return t + } + } + return "" +} + +// mergeBranches merges converted branches into one shape. It fails when +// their types differ. allOf keeps the union of required; anyOf/oneOf drop it. +func mergeBranches(branches []obj, all bool) (obj, bool) { + ty := "" + for i, b := range branches { + t, ok := b["type"].(string) + if !ok || (i > 0 && t != ty) { + return nil, false + } + ty = t + } + out := obj{"type": ty} + switch ty { + case "object": + props := obj{} + required := map[string]bool{} + anyOpen := false + for _, b := range branches { + if p, ok := b["properties"].(obj); ok { + for k, v := range p { + if _, has := props[k]; !has { + props[k] = v + } + } + } + _, hasAdditional := b["additionalProperties"] + if b[preserveKey] == true || hasAdditional { + anyOpen = true + } + if all { + if r, ok := b["required"].([]any); ok { + for _, x := range r { + if s, ok := x.(string); ok { + required[s] = true + } + } + } + } + } + if anyOpen { + // Branch shapes disagree on openness; keep everything. + out[preserveKey] = true + } + if len(props) > 0 { + out["properties"] = props + } + if len(required) > 0 { + names := make([]string, 0, len(required)) + for s := range required { + names = append(names, s) + } + sort.Strings(names) + req := make([]any, len(names)) + for i, s := range names { + req[i] = s + } + out["required"] = req + } + case "array": + out["items"] = preserve(nil) + default: + var values []any + allEnum := true + for _, b := range branches { + e, ok := b["enum"].([]any) + if !ok { + allEnum = false + continue + } + for _, v := range e { + if !containsValue(values, v) { + values = append(values, v) + } + } + } + if allEnum && len(values) > 0 { + out["enum"] = values + } + } + return out, true +} + +func containsValue(values []any, v any) bool { + for _, x := range values { + if x == v { + return true + } + } + return false +} diff --git a/internal/schema/crd.go b/internal/schema/crd.go new file mode 100644 index 0000000..528164b --- /dev/null +++ b/internal/schema/crd.go @@ -0,0 +1,326 @@ +package schema + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + "github.com/api7/aisix-controller/api/v1alpha1" +) + +// idFormFields are the id-form references of a kind. The resources file +// refuses them in favor of the name form (aisix v1.9.0 +// crates/aisix-core/src/filesource/mod.rs, model_ref_id_fields and +// mcp_ref_id_fields). +var idFormFields = map[v1alpha1.Kind][]string{ + v1alpha1.KindModel: {"model_id", "target_id", "embedding_model_id", "default_id"}, + v1alpha1.KindAPIKey: {"allowed_model_ids", "mcp_rate_limits_by_id", "allow_ids", "deny_ids"}, +} + +// removedFields are dropped from a kind's spec at any depth. +func removedFields(k v1alpha1.Kind) map[string]bool { + out := map[string]bool{} + switch k { + case v1alpha1.KindProviderKey: + // `secret` is an alias of `api_key`. + out["api_key"] = true + out["secret"] = true + case v1alpha1.KindModel: + out["provider_key_id"] = true + case v1alpha1.KindAPIKey: + out["key_hash"] = true + } + for _, f := range idFormFields[k] { + out[f] = true + } + return out +} + +func secretKeyRef(description string) obj { + return obj{ + "type": "object", + "description": description, + "required": []any{"name", "key"}, + "properties": obj{ + "name": obj{"type": "string", "minLength": 1, "description": "Secret name, in the resource's namespace."}, + "key": obj{"type": "string", "minLength": 1, "description": "Key within the Secret."}, + }, + } +} + +type addedField struct { + name string + schema obj + required bool +} + +// addedFields are the reference fields the controller resolves. +func addedFields(k v1alpha1.Kind) []addedField { + switch k { + case v1alpha1.KindProviderKey: + return []addedField{{ + v1alpha1.APIKeySecretRef, + secretKeyRef("Secret key holding the upstream provider credential. The controller writes it as `api_key`."), + true, + }} + case v1alpha1.KindModel: + return []addedField{{ + v1alpha1.ProviderKeyRef, + obj{ + "type": "object", + "description": "ProviderKey, in the same namespace, this model authenticates with. Direct models need it; routing, ensemble and semantic models leave it unset.", + "required": []any{"name"}, + "properties": obj{ + "name": obj{"type": "string", "minLength": 1, "description": "ProviderKey `metadata.name`."}, + }, + }, + false, + }} + case v1alpha1.KindAPIKey: + return []addedField{{ + v1alpha1.KeySecretRef, + secretKeyRef("Secret key holding the caller's plaintext API key. The controller writes only its SHA-256 hash."), + true, + }} + } + return nil +} + +// LoadSchema reads a vendored AISIX schema. +func LoadSchema(dir string, k v1alpha1.Kind) (obj, error) { + b, err := os.ReadFile(filepath.Join(dir, k.SchemaName()+".schema.json")) + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + var out obj + if err := dec.Decode(&out); err != nil { + return nil, fmt.Errorf("%s: %w", k.SchemaName(), err) + } + return out, nil +} + +// Shape adapts an AISIX schema for Kubernetes. The result is still draft-07. +func Shape(k v1alpha1.Kind, root obj) obj { + root = deepCopy(root).(obj) + stripFields(root, removedFields(k)) + + // Root compositions only encode cross-field rules, which the gateway + // pipeline enforces. + for _, key := range []string{"anyOf", "oneOf", "allOf", "if", "then", "else", "not"} { + delete(root, key) + } + + var required []any + if r, ok := root["required"].([]any); ok { + for _, v := range r { + if v != v1alpha1.IdentityField { + required = append(required, v) + } + } + } + props, _ := root["properties"].(obj) + if props == nil { + props = obj{} + root["properties"] = props + } + if identity, ok := props[v1alpha1.IdentityField].(obj); ok { + base, _ := identity["description"].(string) + identity["description"] = strings.TrimSpace(base + " Defaults to `metadata.name`.") + } + for _, f := range addedFields(k) { + props[f.name] = f.schema + if f.required { + required = append(required, f.name) + } + } + if len(required) == 0 { + delete(root, "required") + } else { + root["required"] = required + } + return root +} + +func stripFields(node any, removed map[string]bool) { + switch n := node.(type) { + case obj: + if props, ok := n["properties"].(obj); ok { + for k := range props { + if removed[k] { + delete(props, k) + } + } + } + if req, ok := n["required"].([]any); ok { + var keep []any + for _, v := range req { + if s, ok := v.(string); !ok || !removed[s] { + keep = append(keep, v) + } + } + n["required"] = keep + } + for _, v := range n { + stripFields(v, removed) + } + case []any: + for _, v := range n { + stripFields(v, removed) + } + } +} + +func deepCopy(v any) any { + switch n := v.(type) { + case obj: + out := make(obj, len(n)) + for k, x := range n { + out[k] = deepCopy(x) + } + return out + case []any: + out := make([]any, len(n)) + for i, x := range n { + out[i] = deepCopy(x) + } + return out + } + return v +} + +func statusSchema() obj { + return obj{ + "type": "object", + "properties": obj{ + "observedGeneration": obj{"type": "integer", "format": "int64"}, + "conditions": obj{ + "type": "array", + "x-kubernetes-list-type": "map", + "x-kubernetes-list-map-keys": []any{"type"}, + "items": obj{ + "type": "object", + "required": []any{"type", "status", "lastTransitionTime", "reason", "message"}, + "properties": obj{ + "type": obj{"type": "string"}, + "status": obj{"type": "string", "enum": []any{"True", "False", "Unknown"}}, + "observedGeneration": obj{"type": "integer", "format": "int64"}, + "lastTransitionTime": obj{"type": "string", "format": "date-time"}, + "reason": obj{"type": "string"}, + "message": obj{"type": "string"}, + }, + }, + }, + }, + } +} + +// ResourceSchema is the full structural schema of a kind's custom resource. +func ResourceSchema(k v1alpha1.Kind, aisixSchema obj) obj { + return obj{ + "type": "object", + "description": fmt.Sprintf("AISIX %s resource.", k), + "required": []any{"spec"}, + "properties": obj{ + "apiVersion": obj{"type": "string"}, + "kind": obj{"type": "string"}, + "metadata": obj{"type": "object"}, + "spec": ToStructural(Shape(k, aisixSchema)), + "status": statusSchema(), + }, + } +} + +func conditionColumn(name string) apiextensionsv1.CustomResourceColumnDefinition { + return apiextensionsv1.CustomResourceColumnDefinition{ + Name: name, + Type: "string", + JSONPath: fmt.Sprintf(`.status.conditions[?(@.type=="%s")].status`, name), + } +} + +// CRD builds a kind's CustomResourceDefinition. +func CRD(k v1alpha1.Kind, aisixSchema obj) (*apiextensionsv1.CustomResourceDefinition, error) { + raw, err := json.Marshal(ResourceSchema(k, aisixSchema)) + if err != nil { + return nil, err + } + var props apiextensionsv1.JSONSchemaProps + if err := json.Unmarshal(raw, &props); err != nil { + return nil, fmt.Errorf("%s: structural schema: %w", k, err) + } + group := v1alpha1.GroupVersion.Group + return &apiextensionsv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{Name: k.Plural() + "." + group}, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + Group: group, + Names: apiextensionsv1.CustomResourceDefinitionNames{ + Kind: string(k), + ListKind: string(k) + "List", + Plural: k.Plural(), + Singular: strings.ToLower(string(k)), + ShortNames: []string{k.ShortName()}, + Categories: []string{"aisix"}, + }, + Scope: apiextensionsv1.NamespaceScoped, + Versions: []apiextensionsv1.CustomResourceDefinitionVersion{{ + Name: v1alpha1.GroupVersion.Version, + Served: true, + Storage: true, + Schema: &apiextensionsv1.CustomResourceValidation{OpenAPIV3Schema: &props}, + Subresources: &apiextensionsv1.CustomResourceSubresources{ + Status: &apiextensionsv1.CustomResourceSubresourceStatus{}, + }, + AdditionalPrinterColumns: []apiextensionsv1.CustomResourceColumnDefinition{ + conditionColumn("Accepted"), + conditionColumn("Programmed"), + {Name: "Age", Type: "date", JSONPath: ".metadata.creationTimestamp"}, + }, + }}, + }, + }, nil +} + +// FileName is the file a kind's CRD is written to. +func FileName(k v1alpha1.Kind) string { + return k.Plural() + "." + v1alpha1.GroupVersion.Group + ".yaml" +} + +// CRDYAML renders a kind's CRD manifest. +func CRDYAML(k v1alpha1.Kind, aisixSchema obj, aisixVersion string) ([]byte, error) { + crd, err := CRD(k, aisixSchema) + if err != nil { + return nil, err + } + raw, err := json.Marshal(crd) + if err != nil { + return nil, err + } + var m obj + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + // Drop server-populated fields. + delete(m, "status") + if md, ok := m["metadata"].(obj); ok { + delete(md, "creationTimestamp") + } + body, err := yaml.Marshal(m) + if err != nil { + return nil, err + } + header := fmt.Sprintf("# Generated by cmd/crdgen from api7/aisix@%s schemas. Do not edit.\n", aisixVersion) + return append([]byte(header), body...), nil +} diff --git a/internal/schema/generate.go b/internal/schema/generate.go new file mode 100644 index 0000000..eefa3a2 --- /dev/null +++ b/internal/schema/generate.go @@ -0,0 +1,37 @@ +package schema + +import ( + "os" + "path/filepath" + "strings" + + "github.com/api7/aisix-controller/api/v1alpha1" +) + +// File is one generated CRD manifest. +type File struct { + Name string + Body []byte +} + +// Generate renders every kind's CRD from a vendored schema directory +// (holding VERSION and schemas/). +func Generate(dir string) ([]File, error) { + version, err := os.ReadFile(filepath.Join(dir, "VERSION")) + if err != nil { + return nil, err + } + var out []File + for _, k := range v1alpha1.Kinds { + s, err := LoadSchema(filepath.Join(dir, "schemas"), k) + if err != nil { + return nil, err + } + body, err := CRDYAML(k, s, strings.TrimSpace(string(version))) + if err != nil { + return nil, err + } + out = append(out, File{Name: FileName(k), Body: body}) + } + return out, nil +} diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go new file mode 100644 index 0000000..c164896 --- /dev/null +++ b/internal/schema/schema_test.go @@ -0,0 +1,252 @@ +package schema + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/api7/aisix-controller/api/v1alpha1" +) + +const vendored = "../../third_party/aisix" + +func specSchema(t *testing.T, k v1alpha1.Kind) obj { + t.Helper() + s, err := LoadSchema(filepath.Join(vendored, "schemas"), k) + if err != nil { + t.Fatal(err) + } + return ResourceSchema(k, s)["properties"].(obj)["spec"].(obj) +} + +func walk(node obj, path string, f func(string, obj)) { + f(path, node) + if p, ok := node["properties"].(obj); ok { + for k, v := range p { + walk(v.(obj), path+"."+k, f) + } + } + if i, ok := node["items"].(obj); ok { + walk(i, path+"[]", f) + } + if a, ok := node["additionalProperties"].(obj); ok { + walk(a, path+"{}", f) + } +} + +// assertStructural checks the apiextensions structural rules a generator can +// break. +func assertStructural(t *testing.T, s obj) { + t.Helper() + forbidden := []string{"$ref", "$schema", "definitions", "oneOf", "anyOf", "allOf", "not", + "if", "then", "else", "const", "default", "title", "propertyNames", + "patternProperties", "uniqueItems", "examples"} + walk(s, "spec", func(path string, m obj) { + for _, k := range forbidden { + if _, ok := m[k]; ok { + t.Errorf("%s: forbidden keyword %s", path, k) + } + } + open := m[preserveKey] == true || m[intOrStringKey] == true + if _, ok := m["type"]; !ok && !open { + t.Errorf("%s: node without type", path) + } + props, hasProps := m["properties"].(obj) + _, hasAdditional := m["additionalProperties"] + if hasProps && hasAdditional { + t.Errorf("%s: properties with additionalProperties", path) + } + if hasProps && m["type"] != "object" { + t.Errorf("%s: properties on a non-object", path) + } + // An object with nothing declared prunes every key it is given. + if m["type"] == "object" && !open && len(props) == 0 && !hasAdditional { + t.Errorf("%s: object that prunes all of its fields", path) + } + if m["type"] == "array" { + if _, ok := m["items"]; !ok { + t.Errorf("%s: array without items", path) + } + } + if _, ok := m["additionalProperties"].(bool); ok { + t.Errorf("%s: boolean additionalProperties", path) + } + if ex, ok := m["exclusiveMinimum"]; ok { + if _, isBool := ex.(bool); !isBool { + t.Errorf("%s: numeric exclusiveMinimum", path) + } + } + }) +} + +func hasPropertyAnywhere(s obj, name string) bool { + found := false + walk(s, "", func(_ string, m obj) { + if p, ok := m["properties"].(obj); ok { + if _, ok := p[name]; ok { + found = true + } + } + }) + return found +} + +func required(s obj) []any { + r, _ := s["required"].([]any) + return r +} + +func has(list []any, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} + +func TestEverySpecIsStructural(t *testing.T) { + for _, k := range v1alpha1.Kinds { + t.Run(string(k), func(t *testing.T) { assertStructural(t, specSchema(t, k)) }) + } +} + +func TestCredentialsNeverAppearInline(t *testing.T) { + pk := specSchema(t, v1alpha1.KindProviderKey) + for _, f := range []string{"api_key", "secret"} { + if hasPropertyAnywhere(pk, f) { + t.Errorf("ProviderKey still has %s", f) + } + } + if !has(required(pk), v1alpha1.APIKeySecretRef) { + t.Errorf("ProviderKey does not require %s", v1alpha1.APIKeySecretRef) + } + + key := specSchema(t, v1alpha1.KindAPIKey) + if hasPropertyAnywhere(key, "key_hash") { + t.Error("APIKey still has key_hash") + } + if !has(required(key), v1alpha1.KeySecretRef) { + t.Errorf("APIKey does not require %s", v1alpha1.KeySecretRef) + } +} + +func TestIDFormReferencesAreRemoved(t *testing.T) { + model := specSchema(t, v1alpha1.KindModel) + for _, f := range []string{"provider_key_id", "model_id", "target_id", "embedding_model_id", "default_id"} { + if hasPropertyAnywhere(model, f) { + t.Errorf("Model still has %s", f) + } + } + if _, ok := model["properties"].(obj)[v1alpha1.ProviderKeyRef]; !ok { + t.Errorf("Model lacks %s", v1alpha1.ProviderKeyRef) + } + if !hasPropertyAnywhere(model, "routing") { + t.Error("Model lost routing") + } + + key := specSchema(t, v1alpha1.KindAPIKey) + for _, f := range []string{"allowed_model_ids", "mcp_rate_limits_by_id", "allow_ids", "deny_ids"} { + if hasPropertyAnywhere(key, f) { + t.Errorf("APIKey still has %s", f) + } + } + if !hasPropertyAnywhere(key, "allowed_models") { + t.Error("APIKey lost allowed_models") + } +} + +func TestIdentityIsOptional(t *testing.T) { + for _, k := range v1alpha1.Kinds { + s := specSchema(t, k) + if has(required(s), v1alpha1.IdentityField) { + t.Errorf("%s requires %s", k, v1alpha1.IdentityField) + } + if _, ok := s["properties"].(obj)[v1alpha1.IdentityField]; !ok { + t.Errorf("%s lacks %s", k, v1alpha1.IdentityField) + } + } +} + +func decode(t *testing.T, s string) obj { + t.Helper() + dec := json.NewDecoder(bytes.NewReader([]byte(s))) + dec.UseNumber() + var out obj + if err := dec.Decode(&out); err != nil { + t.Fatal(err) + } + return out +} + +func asJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +func TestConversionRules(t *testing.T) { + root := decode(t, `{ + "definitions": {"Inner": {"type": "object", "properties": {"a": {"type": "integer", "minimum": 0.0}}}}, + "type": "object", + "properties": { + "opt": {"type": ["string", "null"]}, + "inner": {"anyOf": [{"$ref": "#/definitions/Inner"}, {"type": "null"}]}, + "wrapped": {"allOf": [{"$ref": "#/definitions/Inner"}], "description": "kept"}, + "mixed": {"anyOf": [{"type": "string"}, {"type": "object", "properties": {"x": {"type": "string"}}}]}, + "ios": {"type": ["integer", "string"]}, + "c": {"const": "fixed"}, + "map": {"type": "object", "additionalProperties": {"type": "string"}}, + "empty_key_map": {"type": "object", "properties": {"": {"type": "string"}}, "additionalProperties": {"type": "string"}}, + "free": {"type": "object"}, + "ex": {"type": "number", "exclusiveMinimum": 0.0} + } + }`) + s := ToStructural(root) + p := s["properties"].(obj) + cases := map[string]string{ + "opt": `{"nullable":true,"type":"string"}`, + "c": `{"enum":["fixed"],"type":"string"}`, + "map": `{"additionalProperties":{"type":"string"},"type":"object"}`, + "empty_key_map": `{"additionalProperties":{"type":"string"},"type":"object"}`, + "free": fmt.Sprintf(`{"type":"object","%s":true}`, preserveKey), + "mixed": fmt.Sprintf(`{"%s":true}`, preserveKey), + "ios": fmt.Sprintf(`{"%s":true}`, intOrStringKey), + } + for field, want := range cases { + if got := asJSON(p[field]); got != want { + t.Errorf("%s: got %s, want %s", field, got, want) + } + } + inner := p["inner"].(obj) + if inner["nullable"] != true || inner["properties"].(obj)["a"].(obj)["type"] != "integer" { + t.Errorf("inner: %s", asJSON(inner)) + } + wrapped := p["wrapped"].(obj) + if wrapped["description"] != "kept" || asJSON(wrapped["properties"].(obj)["a"].(obj)["minimum"]) != "0.0" { + t.Errorf("wrapped: %s", asJSON(wrapped)) + } + if ex := p["ex"].(obj); ex["exclusiveMinimum"] != true { + t.Errorf("ex: %s", asJSON(ex)) + } + assertStructural(t, s) +} + +func TestCommittedCRDsMatchGenerator(t *testing.T) { + files, err := Generate(vendored) + if err != nil { + t.Fatal(err) + } + for _, f := range files { + path := filepath.Join("../../config/crd", f.Name) + committed, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v; run `make crds`", path, err) + } + if !bytes.Equal(committed, f.Body) { + t.Errorf("%s is stale; run `make crds`", path) + } + } +} From c4a1317591b7d0d8db3f1a2a30dd8956d382c1e2 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:15:01 +0545 Subject: [PATCH 04/14] feat: render custom resources into resources-file entries --- internal/render/render.go | 229 +++++++++++++++++++++++++++++++++ internal/render/render_test.go | 176 +++++++++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..6c2759e --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,229 @@ +// Package render turns custom resources and Secrets into entries of the +// gateway's resources file, and assembles them into one validated document. +package render + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/api7/aisix-controller/api/v1alpha1" +) + +// Source is one custom resource, reduced to what rendering needs. +type Source struct { + Kind v1alpha1.Kind + Name string + Generation int64 + // Created orders duplicate identities: the older resource wins. + Created time.Time + Fields map[string]any +} + +// Key identifies a custom resource. +type Key struct { + Kind v1alpha1.Kind + Name string +} + +func (k Key) String() string { return fmt.Sprintf("%s/%s", k.Kind, k.Name) } + +func (s Source) Key() Key { return Key{s.Kind, s.Name} } + +// Identity is the entry's name in the resources file: display_name, or +// metadata.name when unset. +func (s Source) Identity() string { + if v, ok := s.Fields[v1alpha1.IdentityField].(string); ok && v != "" { + return v + } + return s.Name +} + +// Secrets holds Secret data by Secret name, then key. +type Secrets map[string]map[string][]byte + +// Entry is a rendered resources-file entry, already escaped for ${VAR} +// interpolation. +type Entry = map[string]any + +// Rendered is one source and its entry, or the reason it has none. +type Rendered struct { + Source Source + Entry Entry + Err string +} + +// sortSources orders sources the way the file lists them: by kind, then +// creation time, then name. +func sortSources(sources []Source) []Source { + out := append([]Source(nil), sources...) + sort.SliceStable(out, func(i, j int) bool { + a, b := out[i], out[j] + if a.Kind.Order() != b.Kind.Order() { + return a.Kind.Order() < b.Kind.Order() + } + if !a.Created.Equal(b.Created) { + return a.Created.Before(b.Created) + } + return a.Name < b.Name + }) + return out +} + +// All renders every source, in file order. +func All(sources []Source, secrets Secrets) []Rendered { + ordered := sortSources(sources) + + providerIdentities := map[string]string{} + for _, s := range ordered { + if s.Kind == v1alpha1.KindProviderKey { + providerIdentities[s.Name] = s.Identity() + } + } + + type identity struct { + kind v1alpha1.Kind + id string + } + seen := map[identity]string{} + out := make([]Rendered, 0, len(ordered)) + for _, s := range ordered { + r := Rendered{Source: s} + id := identity{s.Kind, s.Identity()} + if owner, dup := seen[id]; dup { + r.Err = fmt.Sprintf("%s %q is already used by %s/%s", v1alpha1.IdentityField, s.Identity(), s.Kind, owner) + } else { + seen[id] = s.Name + entry, err := one(s, secrets, providerIdentities) + if err != nil { + r.Err = err.Error() + } else { + r.Entry = entry + } + } + out = append(out, r) + } + return out +} + +func one(s Source, secrets Secrets, providerIdentities map[string]string) (Entry, error) { + fields := make(Entry, len(s.Fields)+1) + for k, v := range s.Fields { + fields[k] = v + } + fields[v1alpha1.IdentityField] = s.Identity() + + switch s.Kind { + case v1alpha1.KindProviderKey: + for _, inline := range []string{"api_key", "secret"} { + if _, ok := fields[inline]; ok { + return nil, fmt.Errorf("set the credential through %s, not `%s`", v1alpha1.APIKeySecretRef, inline) + } + } + ref, ok := fields[v1alpha1.APIKeySecretRef] + if !ok { + return nil, fmt.Errorf("%s is required", v1alpha1.APIKeySecretRef) + } + delete(fields, v1alpha1.APIKeySecretRef) + value, err := secretValue(ref, secrets, v1alpha1.APIKeySecretRef) + if err != nil { + return nil, err + } + fields["api_key"] = value + case v1alpha1.KindModel: + if _, ok := fields["provider_key_id"]; ok { + return nil, fmt.Errorf("reference the provider key through %s", v1alpha1.ProviderKeyRef) + } + if ref, ok := fields[v1alpha1.ProviderKeyRef]; ok { + delete(fields, v1alpha1.ProviderKeyRef) + m, _ := ref.(map[string]any) + name, _ := m["name"].(string) + if name == "" { + return nil, fmt.Errorf("%s.name is required", v1alpha1.ProviderKeyRef) + } + identity, ok := providerIdentities[name] + if !ok { + return nil, fmt.Errorf("%s names ProviderKey %q, which does not exist", v1alpha1.ProviderKeyRef, name) + } + fields["provider_key"] = identity + } + case v1alpha1.KindAPIKey: + if _, ok := fields["key_hash"]; ok { + return nil, fmt.Errorf("set the key through %s, not `key_hash`", v1alpha1.KeySecretRef) + } + ref, ok := fields[v1alpha1.KeySecretRef] + if !ok { + return nil, fmt.Errorf("%s is required", v1alpha1.KeySecretRef) + } + delete(fields, v1alpha1.KeySecretRef) + plaintext, err := secretValue(ref, secrets, v1alpha1.KeySecretRef) + if err != nil { + return nil, err + } + fields["key_hash"] = HashKey(plaintext) + } + + return EscapeDollars(fields).(Entry), nil +} + +// HashKey is how the gateway stores a caller key: SHA-256, lowercase hex +// (aisix ApiKey::hash_bearer). +func HashKey(plaintext string) string { + sum := sha256.Sum256([]byte(plaintext)) + return hex.EncodeToString(sum[:]) +} + +func secretValue(ref any, secrets Secrets, field string) (string, error) { + m, _ := ref.(map[string]any) + name, _ := m["name"].(string) + key, _ := m["key"].(string) + if name == "" { + return "", fmt.Errorf("%s.name is required", field) + } + if key == "" { + return "", fmt.Errorf("%s.key is required", field) + } + data, ok := secrets[name] + if !ok { + return "", fmt.Errorf("%s: Secret %q not found", field, name) + } + raw, ok := data[key] + if !ok { + return "", fmt.Errorf("%s: Secret %q has no key %q", field, name, key) + } + value := strings.TrimRight(string(raw), "\r\n") + if !utf8.ValidString(value) { + return "", fmt.Errorf("%s: Secret %q key %q is not UTF-8", field, name, key) + } + if value == "" { + return "", fmt.Errorf("%s: Secret %q key %q is empty", field, name, key) + } + return value, nil +} + +// EscapeDollars writes every `$` as `$$`. The resources file interpolates +// ${VAR} in every string scalar, and values from custom resources are +// literal. +func EscapeDollars(v any) any { + switch n := v.(type) { + case string: + return strings.ReplaceAll(n, "$", "$$") + case []any: + out := make([]any, len(n)) + for i, x := range n { + out[i] = EscapeDollars(x) + } + return out + case map[string]any: + out := make(map[string]any, len(n)) + for k, x := range n { + out[k] = EscapeDollars(x) + } + return out + } + return v +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..e04fc12 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,176 @@ +package render + +import ( + "strings" + "testing" + "time" + + "github.com/api7/aisix-controller/api/v1alpha1" +) + +var t0 = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + +func src(kind v1alpha1.Kind, name string, fields map[string]any) Source { + return Source{Kind: kind, Name: name, Generation: 1, Created: t0, Fields: fields} +} + +func testSecrets() Secrets { + return Secrets{"creds": { + "openai": []byte("sk-$abc\n"), + "caller": []byte("caller-key"), + "empty": {}, + }} +} + +func ref(name, key string) map[string]any { return map[string]any{"name": name, "key": key} } + +func byKey(rs []Rendered) map[string]Rendered { + out := map[string]Rendered{} + for _, r := range rs { + out[r.Source.Key().String()] = r + } + return out +} + +func TestProviderKeyTakesCredentialFromSecretAndEscapesIt(t *testing.T) { + out := byKey(All([]Source{src(v1alpha1.KindProviderKey, "openai", map[string]any{ + "provider": "openai", + "api_base": "http://x/${not_env}", + v1alpha1.APIKeySecretRef: ref("creds", "openai"), + })}, testSecrets())) + r := out["ProviderKey/openai"] + if r.Err != "" { + t.Fatal(r.Err) + } + if r.Entry["api_key"] != "sk-$$abc" { + t.Errorf("api_key = %v", r.Entry["api_key"]) + } + if r.Entry["api_base"] != "http://x/$${not_env}" { + t.Errorf("api_base = %v", r.Entry["api_base"]) + } + if r.Entry[v1alpha1.IdentityField] != "openai" { + t.Errorf("display_name = %v", r.Entry[v1alpha1.IdentityField]) + } + if _, ok := r.Entry[v1alpha1.APIKeySecretRef]; ok { + t.Error("secret ref leaked into the entry") + } +} + +func TestModelReferenceResolvesToProviderIdentity(t *testing.T) { + out := byKey(All([]Source{ + src(v1alpha1.KindProviderKey, "pk", map[string]any{ + v1alpha1.IdentityField: "Provider One", + v1alpha1.APIKeySecretRef: ref("creds", "openai"), + }), + src(v1alpha1.KindModel, "m", map[string]any{ + v1alpha1.IdentityField: "openai/gpt", + v1alpha1.ProviderKeyRef: map[string]any{"name": "pk"}, + }), + }, testSecrets())) + m := out["Model/m"] + if m.Err != "" { + t.Fatal(m.Err) + } + if m.Entry["provider_key"] != "Provider One" || m.Entry[v1alpha1.IdentityField] != "openai/gpt" { + t.Errorf("entry = %v", m.Entry) + } +} + +func TestAPIKeyIsHashed(t *testing.T) { + out := byKey(All([]Source{src(v1alpha1.KindAPIKey, "k", map[string]any{ + v1alpha1.KeySecretRef: ref("creds", "caller"), + })}, testSecrets())) + k := out["APIKey/k"] + if k.Err != "" { + t.Fatal(k.Err) + } + if k.Entry["key_hash"] != HashKey("caller-key") { + t.Errorf("key_hash = %v", k.Entry["key_hash"]) + } + // sha256("password"), as the gateway computes it. + if HashKey("password") != "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" { + t.Error("HashKey does not match the gateway") + } + if strings.Contains(k.Entry["key_hash"].(string), "caller-key") { + t.Error("plaintext leaked") + } +} + +func TestMissingReferencesAreErrors(t *testing.T) { + out := byKey(All([]Source{ + src(v1alpha1.KindProviderKey, "no-secret", map[string]any{v1alpha1.APIKeySecretRef: ref("nope", "x")}), + src(v1alpha1.KindProviderKey, "no-key", map[string]any{v1alpha1.APIKeySecretRef: ref("creds", "nope")}), + src(v1alpha1.KindProviderKey, "empty", map[string]any{v1alpha1.APIKeySecretRef: ref("creds", "empty")}), + src(v1alpha1.KindProviderKey, "unset", map[string]any{}), + src(v1alpha1.KindModel, "dangling", map[string]any{v1alpha1.ProviderKeyRef: map[string]any{"name": "ghost"}}), + src(v1alpha1.KindAPIKey, "keyless", map[string]any{}), + }, testSecrets())) + want := map[string]string{ + "ProviderKey/no-secret": `Secret "nope" not found`, + "ProviderKey/no-key": `has no key "nope"`, + "ProviderKey/empty": "is empty", + "ProviderKey/unset": "api_key_secret_ref is required", + "Model/dangling": `ProviderKey "ghost", which does not exist`, + "APIKey/keyless": "key_secret_ref is required", + } + for k, substr := range want { + if !strings.Contains(out[k].Err, substr) { + t.Errorf("%s: err %q lacks %q", k, out[k].Err, substr) + } + } +} + +func TestInlineCredentialsAreRefused(t *testing.T) { + out := byKey(All([]Source{ + src(v1alpha1.KindProviderKey, "a", map[string]any{"api_key": "x", v1alpha1.APIKeySecretRef: ref("creds", "openai")}), + src(v1alpha1.KindProviderKey, "b", map[string]any{"secret": "x", v1alpha1.APIKeySecretRef: ref("creds", "openai")}), + src(v1alpha1.KindModel, "c", map[string]any{"provider_key_id": "x"}), + src(v1alpha1.KindAPIKey, "d", map[string]any{"key_hash": "x", v1alpha1.KeySecretRef: ref("creds", "caller")}), + }, testSecrets())) + for _, k := range []string{"ProviderKey/a", "ProviderKey/b", "Model/c", "APIKey/d"} { + if out[k].Err == "" { + t.Errorf("%s was accepted", k) + } + } +} + +func TestDuplicateIdentityLosesToOlderResource(t *testing.T) { + older := src(v1alpha1.KindModel, "zzz", map[string]any{v1alpha1.IdentityField: "same"}) + newer := src(v1alpha1.KindModel, "aaa", map[string]any{v1alpha1.IdentityField: "same"}) + newer.Created = t0.Add(time.Minute) + out := byKey(All([]Source{newer, older}, testSecrets())) + if out["Model/zzz"].Err != "" { + t.Errorf("older lost: %s", out["Model/zzz"].Err) + } + if !strings.Contains(out["Model/aaa"].Err, "already used by Model/zzz") { + t.Errorf("newer: %q", out["Model/aaa"].Err) + } +} + +func TestOrderIsKindThenAgeThenName(t *testing.T) { + b := src(v1alpha1.KindModel, "b", map[string]any{}) + a := src(v1alpha1.KindModel, "a", map[string]any{}) + late := src(v1alpha1.KindModel, "0", map[string]any{}) + late.Created = t0.Add(time.Hour) + key := src(v1alpha1.KindAPIKey, "k", map[string]any{}) + pk := src(v1alpha1.KindProviderKey, "p", map[string]any{}) + var got []string + for _, r := range All([]Source{key, late, b, a, pk}, testSecrets()) { + got = append(got, r.Source.Key().String()) + } + want := "ProviderKey/p Model/a Model/b Model/0 APIKey/k" + if strings.Join(got, " ") != want { + t.Errorf("order = %v", got) + } +} + +func TestEscapeDollarsReachesEveryString(t *testing.T) { + in := map[string]any{"a": "$", "b": []any{"x$y", 1}, "c": map[string]any{"d": "$$"}} + out := EscapeDollars(in).(map[string]any) + if out["a"] != "$$" || out["b"].([]any)[0] != "x$$y" || out["c"].(map[string]any)["d"] != "$$$$" { + t.Errorf("escaped = %v", out) + } + if in["a"] != "$" { + t.Error("input was mutated") + } +} From 5fb610e2075c1f60c3a3b2ee07847da569fadee9 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:15:01 +0545 Subject: [PATCH 05/14] feat: validate resources files with the aisix binary --- hack/test-with-aisix.sh | 23 ++++ internal/validate/validate.go | 163 +++++++++++++++++++++++++++++ internal/validate/validate_test.go | 92 ++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100755 hack/test-with-aisix.sh create mode 100644 internal/validate/validate.go create mode 100644 internal/validate/validate_test.go diff --git a/hack/test-with-aisix.sh b/hack/test-with-aisix.sh new file mode 100755 index 0000000..882e05b --- /dev/null +++ b/hack/test-with-aisix.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Run the Go tests with the real aisix binary (AISIX_BIN) inside a Linux +# container, since the binary is built for the gateway image. +# +# hack/test-with-aisix.sh [go test args] +set -euo pipefail +cd "$(dirname "$0")/.." + +AISIX_IMAGE=${AISIX_IMAGE:-docker.io/api7/aisix:$(sed 's/^v//' third_party/aisix/VERSION)} +GO_IMAGE=${GO_IMAGE:-golang:1.26-trixie} + +mkdir -p bin +docker run --rm --entrypoint cat "$AISIX_IMAGE" /usr/local/bin/aisix > bin/aisix-linux +chmod +x bin/aisix-linux + +MODCACHE=$(go env GOMODCACHE 2>/dev/null || echo "$HOME/go/pkg/mod") +mkdir -p "$MODCACHE" +docker run --rm \ + -v "$PWD:/src" -w /src \ + -v "$MODCACHE:/go/pkg/mod" \ + -e AISIX_BIN=/src/bin/aisix-linux \ + -e GOFLAGS=-buildvcs=false \ + "$GO_IMAGE" go test "${@:-./...}" diff --git a/internal/validate/validate.go b/internal/validate/validate.go new file mode 100644 index 0000000..439551c --- /dev/null +++ b/internal/validate/validate.go @@ -0,0 +1,163 @@ +// Package validate checks resources files with the aisix binary, which runs +// the same pipeline the gateway runs at boot and on reload. +package validate + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// LoadError is one problem the gateway pipeline reported. Scope is +// `models[2] ("gpt-4o")` for an entry, `provider_keys[0]` when the entry has +// no identity yet, or `(file)`. +type LoadError struct { + Scope string + Message string +} + +func (e LoadError) String() string { return e.Scope + ": " + e.Message } + +// Validator runs the gateway's resources-file pipeline over a document. A +// non-nil error means the check itself could not run. +type Validator interface { + Validate(ctx context.Context, document []byte) ([]LoadError, error) +} + +// Func adapts a function to Validator. +type Func func(ctx context.Context, document []byte) ([]LoadError, error) + +func (f Func) Validate(ctx context.Context, document []byte) ([]LoadError, error) { + return f(ctx, document) +} + +// Binary validates with `aisix validate --resources `. +type Binary struct { + Path string + TempDir string + Timeout time.Duration +} + +var _ Validator = (*Binary)(nil) + +// Check validates an empty resources file, so a binary that cannot run +// fails at startup. +func (b *Binary) Check() error { + info, err := os.Stat(b.Path) + if err != nil { + return fmt.Errorf("aisix binary: %w", err) + } + if info.IsDir() || info.Mode()&0o111 == 0 { + return fmt.Errorf("aisix binary %s is not executable", b.Path) + } + errs, err := b.Validate(context.Background(), []byte(`{"_format_version": "1"}`)) + if err != nil { + return err + } + if len(errs) > 0 { + return fmt.Errorf("aisix binary %s rejects an empty resources file: %s", b.Path, errs[0]) + } + return nil +} + +func (b *Binary) Validate(ctx context.Context, document []byte) ([]LoadError, error) { + f, err := os.CreateTemp(b.TempDir, "resources-*.yaml") + if err != nil { + return nil, err + } + defer func() { _ = os.Remove(f.Name()) }() + if _, err := f.Write(document); err != nil { + _ = f.Close() + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + + timeout := b.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, b.Path, "validate", "--resources", f.Name()) + // An empty environment: ${VAR} must never resolve from the controller's. + cmd.Env = []string{} + cmd.Stderr = &stderr + err = cmd.Run() + if err == nil { + return nil, nil + } + var exit *exec.ExitError + if !errors.As(err, &exit) || ctx.Err() != nil { + return nil, fmt.Errorf("running aisix validate: %w", err) + } + if errs := ParseReport(stderr.String()); len(errs) > 0 { + return errs, nil + } + return []LoadError{{Scope: "(file)", Message: strings.TrimSpace(stderr.String())}}, nil +} + +// ParseReport reads the aggregated error report aisix prints on stderr: +// +// resources file : 2 error(s): +// - models[0] ("chat"): unknown field `x` +// - (file): missing mandatory _format_version +func ParseReport(report string) []LoadError { + var out []LoadError + for _, line := range strings.Split(report, "\n") { + rest, ok := strings.CutPrefix(line, " - ") + if !ok { + // A multi-line message continues the previous entry. + if len(out) > 0 && strings.HasPrefix(line, " ") { + out[len(out)-1].Message += "\n" + strings.TrimSpace(line) + } + continue + } + scope, message := splitScope(rest) + out = append(out, LoadError{Scope: scope, Message: message}) + } + return out +} + +// splitScope splits `: `. A scope's identity is a quoted +// Rust debug string that may itself contain ": ". +func splitScope(s string) (string, string) { + end := -1 + if strings.HasPrefix(s, "(file)") { + end = len("(file)") + } else if i := strings.Index(s, "]"); i > 0 { + end = i + 1 + if strings.HasPrefix(s[end:], ` ("`) { + if j := closingQuote(s, end+3); j > 0 && j+1 < len(s) && s[j+1] == ')' { + end = j + 2 + } + } + } + if end > 0 && strings.HasPrefix(s[end:], ": ") { + return s[:end], s[end+2:] + } + if scope, msg, ok := strings.Cut(s, ": "); ok { + return scope, msg + } + return "(file)", s +} + +func closingQuote(s string, from int) int { + for i := from; i < len(s); i++ { + switch s[i] { + case '\\': + i++ + case '"': + return i + } + } + return -1 +} diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go new file mode 100644 index 0000000..2b73fa4 --- /dev/null +++ b/internal/validate/validate_test.go @@ -0,0 +1,92 @@ +package validate + +import ( + "context" + "os" + "strings" + "testing" +) + +// Captured from aisix v1.9.0 `validate`. +const report = `resources file /w/bad.yaml: 3 error(s): + - provider_keys[0]: field ` + "`api_key`" + `: environment variable ` + "`NOPE`" + ` is unset or empty + - models[0] ("we\"ird: name"): ` + "`provider_key`" + ` references unknown provider key "mock" + - (file): missing mandatory _format_version (expected "1") + +` + +func TestParseReport(t *testing.T) { + got := ParseReport(report) + want := []LoadError{ + {"provider_keys[0]", "field `api_key`: environment variable `NOPE` is unset or empty"}, + {`models[0] ("we\"ird: name")`, "`provider_key` references unknown provider key \"mock\""}, + {"(file)", `missing mandatory _format_version (expected "1")`}, + } + if len(got) != len(want) { + t.Fatalf("got %d errors: %+v", len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("[%d] got %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestParseReportJoinsContinuationLines(t *testing.T) { + got := ParseReport("x:\n - models[1] (\"a\"): first\n second\n") + if len(got) != 1 || got[0].Message != "first\nsecond" { + t.Errorf("got %+v", got) + } +} + +func binary(t *testing.T) *Binary { + p := os.Getenv("AISIX_BIN") + if p == "" { + t.Skip("set AISIX_BIN to an aisix binary") + } + b := &Binary{Path: p} + if err := b.Check(); err != nil { + t.Fatal(err) + } + return b +} + +func TestBinaryAcceptsAValidDocument(t *testing.T) { + errs, err := binary(t).Validate(context.Background(), []byte(`{"_format_version": "1"}`)) + if err != nil || len(errs) != 0 { + t.Fatalf("errs=%v err=%v", errs, err) + } +} + +func TestBinaryReportsScopedErrors(t *testing.T) { + doc := `{"_format_version": "1", "models": [{"display_name": "m", "provider": "openai", "model_name": "a", "provider_key": "ghost"}]}` + errs, err := binary(t).Validate(context.Background(), []byte(doc)) + if err != nil { + t.Fatal(err) + } + if len(errs) != 1 || errs[0].Scope != `models[0] ("m")` || !strings.Contains(errs[0].Message, "ghost") { + t.Errorf("errs = %+v", errs) + } +} + +func TestBinaryNeverReadsTheControllerEnvironment(t *testing.T) { + t.Setenv("AISIX_TEST_SECRET", "leaked") + doc := `{"_format_version": "1", "provider_keys": [{"display_name": "p", "provider": "openai", "api_key": "${AISIX_TEST_SECRET}"}]}` + errs, err := binary(t).Validate(context.Background(), []byte(doc)) + if err != nil { + t.Fatal(err) + } + if len(errs) != 1 || !strings.Contains(errs[0].Message, "AISIX_TEST_SECRET") { + t.Errorf("errs = %+v", errs) + } +} + +func TestMissingBinaryIsAnError(t *testing.T) { + b := &Binary{Path: "/nonexistent/aisix"} + if b.Check() == nil { + t.Error("Check passed") + } + if _, err := b.Validate(context.Background(), []byte("{}")); err == nil { + t.Error("Validate passed") + } +} From 2a4da3d6c5c9f3a3cf051f222ba264848a9367aa Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:15:01 +0545 Subject: [PATCH 06/14] feat: assemble one validated resources file, keeping served versions of rejected resources --- internal/render/assemble.go | 222 ++++++++++++++++++++++++++ internal/render/assemble_test.go | 260 +++++++++++++++++++++++++++++++ 2 files changed, 482 insertions(+) create mode 100644 internal/render/assemble.go create mode 100644 internal/render/assemble_test.go diff --git a/internal/render/assemble.go b/internal/render/assemble.go new file mode 100644 index 0000000..adffb41 --- /dev/null +++ b/internal/render/assemble.go @@ -0,0 +1,222 @@ +package render + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/api7/aisix-controller/api/v1alpha1" + "github.com/api7/aisix-controller/internal/validate" +) + +// FormatVersion is the resources-file format the controller writes. +const FormatVersion = "1" + +// Disposition is what happened to one custom resource. +type Disposition struct { + State State + Reason string +} + +type State int + +const ( + // Applied: its current spec is in the document. + Applied State = iota + // Retained: rejected; its previously served entry stays. + Retained + // Rejected: rejected, and not served at all. + Rejected +) + +// Outcome of assembling one document. +type Outcome struct { + // Document to serve; nil when no valid one could be made. + Document []byte + Dispositions map[Key]Disposition + // Error is set when the document as a whole failed. + Error string +} + +// Document renders entries as a resources file. JSON is valid YAML, and +// encoding/json sorts keys, so equal inputs give equal bytes. +func Document(entries map[v1alpha1.Kind][]Entry) []byte { + root := map[string]any{"_format_version": FormatVersion} + for _, k := range v1alpha1.Kinds { + if items := entries[k]; len(items) > 0 { + list := make([]any, len(items)) + for i, e := range items { + list[i] = e + } + root[k.Collection()] = list + } + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(root); err != nil { + panic(fmt.Sprintf("resources document does not encode: %v", err)) + } + return buf.Bytes() +} + +// Hash is what the gateway reports as applied.config_hash in file mode: +// SHA-256 of the raw file bytes, lowercase hex. +func Hash(document []byte) string { + sum := sha256.Sum256(document) + return hex.EncodeToString(sum[:]) +} + +type servedKey struct { + kind v1alpha1.Kind + identity string +} + +// Served indexes the entries of the currently served document. +type Served map[servedKey]Entry + +// ServedEntries parses a served document. Anything unreadable counts as +// nothing served. +func ServedEntries(document []byte) Served { + out := Served{} + if len(document) == 0 { + return out + } + dec := json.NewDecoder(bytes.NewReader(document)) + dec.UseNumber() + var root map[string]any + if err := dec.Decode(&root); err != nil { + return out + } + for _, k := range v1alpha1.Kinds { + items, _ := root[k.Collection()].([]any) + for _, item := range items { + e, ok := item.(map[string]any) + if !ok { + continue + } + if id, ok := e[v1alpha1.IdentityField].(string); ok { + out[servedKey{k, strings.ReplaceAll(id, "$$", "$")}] = e + } + } + } + return out +} + +func (s Served) get(src Source) (Entry, bool) { + e, ok := s[servedKey{src.Kind, src.Identity()}] + return e, ok +} + +// ParseScope maps `models[2] ("gpt")` to (Model, 2). +func ParseScope(scope string) (v1alpha1.Kind, int, bool) { + collection, rest, ok := strings.Cut(scope, "[") + if !ok { + return "", 0, false + } + index, _, ok := strings.Cut(rest, "]") + if !ok { + return "", 0, false + } + k, ok := v1alpha1.KindForCollection(collection) + if !ok { + return "", 0, false + } + var i int + if _, err := fmt.Sscanf(index, "%d", &i); err != nil || fmt.Sprint(i) != index { + return "", 0, false + } + return k, i, true +} + +type item struct { + source Source + state State + entry Entry + reason string +} + +// Assemble builds the document to serve. An entry the gateway rejects is +// replaced by the version currently served, so one bad resource never takes +// the others down. With no served version it is left out. +func Assemble(ctx context.Context, rendered []Rendered, served Served, v validate.Validator) (Outcome, error) { + items := make([]*item, len(rendered)) + fallback := func(it *item, reason string) { + if e, ok := served.get(it.source); ok { + it.state, it.entry, it.reason = Retained, e, reason + } else { + it.state, it.entry, it.reason = Rejected, nil, reason + } + } + for i, r := range rendered { + it := &item{source: r.Source, state: Applied, entry: r.Entry} + if r.Err != "" { + fallback(it, r.Err) + } + items[i] = it + } + + // Each failing round drops or replaces at least one entry. + for round := 0; round < 2*len(items)+1; round++ { + entries := map[v1alpha1.Kind][]Entry{} + positions := map[servedKey]*item{} + for _, it := range items { + if it.state == Rejected { + continue + } + k := it.source.Kind + positions[servedKey{k, fmt.Sprint(len(entries[k]))}] = it + entries[k] = append(entries[k], it.entry) + } + doc := Document(entries) + errs, err := v.Validate(ctx, doc) + if err != nil { + return Outcome{}, err + } + if len(errs) == 0 { + return finish(items, doc, ""), nil + } + + faults := map[*item][]string{} + var order []*item + for _, e := range errs { + k, i, ok := ParseScope(e.Scope) + it := positions[servedKey{k, fmt.Sprint(i)}] + if !ok || it == nil { + all := make([]string, len(errs)) + for j, x := range errs { + all[j] = x.String() + } + return finish(items, nil, strings.Join(all, "; ")), nil + } + if _, seen := faults[it]; !seen { + order = append(order, it) + } + faults[it] = append(faults[it], e.Message) + } + for _, it := range order { + reason := strings.Join(faults[it], "; ") + switch it.state { + case Applied: + fallback(it, reason) + case Retained: + it.state, it.entry = Rejected, nil + it.reason = it.reason + "; the previously served version is no longer valid either: " + reason + } + } + } + return finish(items, nil, "the resources file did not converge to a valid state"), nil +} + +func finish(items []*item, doc []byte, errMsg string) Outcome { + out := Outcome{Document: doc, Dispositions: map[Key]Disposition{}, Error: errMsg} + for _, it := range items { + out.Dispositions[it.source.Key()] = Disposition{State: it.state, Reason: it.reason} + } + return out +} diff --git a/internal/render/assemble_test.go b/internal/render/assemble_test.go new file mode 100644 index 0000000..c5c61f7 --- /dev/null +++ b/internal/render/assemble_test.go @@ -0,0 +1,260 @@ +package render + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "github.com/api7/aisix-controller/api/v1alpha1" + "github.com/api7/aisix-controller/internal/validate" +) + +// fakeValidator stands in for the aisix binary: it rejects an entry when +// reject returns a message, and reports dangling provider_key names the way +// the gateway does. +type fakeValidator struct { + reject func(v1alpha1.Kind, Entry) string + calls int +} + +func (f *fakeValidator) Validate(_ context.Context, doc []byte) ([]validate.LoadError, error) { + f.calls++ + dec := json.NewDecoder(bytes.NewReader(doc)) + dec.UseNumber() + var root map[string]any + if err := dec.Decode(&root); err != nil { + return []validate.LoadError{{Scope: "(file)", Message: err.Error()}}, nil + } + if root["_format_version"] != FormatVersion { + return []validate.LoadError{{Scope: "(file)", Message: "bad _format_version"}}, nil + } + providers := map[string]bool{} + for _, item := range list(root, "provider_keys") { + providers[item[v1alpha1.IdentityField].(string)] = true + } + var errs []validate.LoadError + for _, k := range v1alpha1.Kinds { + for i, e := range list(root, k.Collection()) { + scope := fmt.Sprintf("%s[%d] (%q)", k.Collection(), i, e[v1alpha1.IdentityField]) + if name, ok := e["provider_key"].(string); ok && !providers[name] { + errs = append(errs, validate.LoadError{Scope: scope, Message: fmt.Sprintf("`provider_key` references unknown provider key %q", name)}) + } + if f.reject != nil { + if msg := f.reject(k, e); msg != "" { + errs = append(errs, validate.LoadError{Scope: scope, Message: msg}) + } + } + } + } + return errs, nil +} + +func list(root map[string]any, c string) []map[string]any { + items, _ := root[c].([]any) + out := make([]map[string]any, 0, len(items)) + for _, i := range items { + out = append(out, i.(map[string]any)) + } + return out +} + +// gatewayValidator prefers the real gateway pipeline. +func gatewayValidator(t *testing.T, fallback *fakeValidator) validate.Validator { + if p := os.Getenv("AISIX_BIN"); p != "" { + return &validate.Binary{Path: p} + } + if fallback == nil { + t.Skip("needs the gateway pipeline; set AISIX_BIN to an aisix binary") + } + return fallback +} + +func provider() Source { + return src(v1alpha1.KindProviderKey, "mock", map[string]any{ + "provider": "openai", "adapter": "openai", "api_base": "http://mock:8080/v1", + v1alpha1.APIKeySecretRef: ref("creds", "openai"), + }) +} + +func model(name, providerRef string) Source { + return src(v1alpha1.KindModel, name, map[string]any{ + "provider": "openai", "model_name": "upstream", + v1alpha1.ProviderKeyRef: map[string]any{"name": providerRef}, + }) +} + +func apiKey() Source { + return src(v1alpha1.KindAPIKey, "caller", map[string]any{ + "allowed_models": []any{"*"}, v1alpha1.KeySecretRef: ref("creds", "caller"), + }) +} + +func assemble(t *testing.T, v validate.Validator, sources []Source, served []byte) Outcome { + t.Helper() + out, err := Assemble(context.Background(), All(sources, testSecrets()), ServedEntries(served), v) + if err != nil { + t.Fatal(err) + } + return out +} + +func disposition(o Outcome, kind v1alpha1.Kind, name string) Disposition { + return o.Dispositions[Key{kind, name}] +} + +func TestEmptyDocumentIsValid(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + out := assemble(t, v, nil, nil) + if out.Error != "" || out.Document == nil { + t.Fatalf("outcome = %+v", out) + } + if string(out.Document) != "{\n \"_format_version\": \"1\"\n}\n" { + t.Errorf("document = %q", out.Document) + } +} + +func TestValidResourcesProduceALoadableDocument(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + out := assemble(t, v, []Source{provider(), model("chat", "mock"), apiKey()}, nil) + if out.Error != "" || out.Document == nil { + t.Fatalf("outcome = %+v", out) + } + for k, d := range out.Dispositions { + if d.State != Applied { + t.Errorf("%s: %+v", k, d) + } + } + if !bytes.Contains(out.Document, []byte(`"sk-$$abc"`)) { + t.Errorf("credential not escaped in %s", out.Document) + } +} + +func TestGatewayRejectionIsAttributedToTheResource(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{reject: func(k v1alpha1.Kind, e Entry) string { + if _, ok := e["no_such_field"]; ok { + return "unknown field `no_such_field`" + } + return "" + }}) + bad := model("bad", "mock") + bad.Fields["no_such_field"] = 1 + out := assemble(t, v, []Source{provider(), model("good", "mock"), bad}, nil) + if out.Error != "" || out.Document == nil { + t.Fatalf("outcome = %+v", out) + } + if d := disposition(out, v1alpha1.KindModel, "bad"); d.State != Rejected || !strings.Contains(d.Reason, "no_such_field") { + t.Errorf("bad: %+v", d) + } + if d := disposition(out, v1alpha1.KindModel, "good"); d.State != Applied { + t.Errorf("good: %+v", d) + } + if bytes.Contains(out.Document, []byte("no_such_field")) { + t.Error("rejected entry is in the document") + } +} + +func TestRejectedUpdateKeepsTheServedVersion(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{reject: func(k v1alpha1.Kind, e Entry) string { + if _, ok := e["no_such_field"]; ok { + return "unknown field `no_such_field`" + } + return "" + }}) + first := assemble(t, v, []Source{provider(), model("chat", "mock")}, nil) + edited := model("chat", "mock") + edited.Fields["no_such_field"] = 1 + edited.Fields["model_name"] = "new" + out := assemble(t, v, []Source{provider(), edited}, first.Document) + if d := disposition(out, v1alpha1.KindModel, "chat"); d.State != Retained { + t.Fatalf("chat: %+v", d) + } + if !bytes.Equal(out.Document, first.Document) { + t.Errorf("served document changed:\n%s", out.Document) + } +} + +func TestDanglingReferenceCascadesToDependents(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + pk := provider() + delete(pk.Fields, v1alpha1.APIKeySecretRef) + out := assemble(t, v, []Source{pk, model("chat", "mock")}, nil) + if d := disposition(out, v1alpha1.KindProviderKey, "mock"); d.State != Rejected { + t.Errorf("provider: %+v", d) + } + if d := disposition(out, v1alpha1.KindModel, "chat"); d.State != Rejected || !strings.Contains(d.Reason, "mock") { + t.Errorf("model: %+v", d) + } + if out.Document == nil { + t.Error("no document") + } +} + +func TestCrossFieldRulesDroppedFromTheCRDAreStillEnforced(t *testing.T) { + // A direct model that also routes passes the CRD schema; only the + // gateway's oneOf refuses it. + v := gatewayValidator(t, nil) + m := model("chat", "mock") + m.Fields["routing"] = map[string]any{"targets": []any{map[string]any{"model": "other"}}} + out := assemble(t, v, []Source{provider(), m}, nil) + if d := disposition(out, v1alpha1.KindModel, "chat"); d.State != Rejected { + t.Errorf("chat: %+v", d) + } +} + +func TestDeletedResourcesLeaveTheDocument(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + first := assemble(t, v, []Source{provider(), model("chat", "mock"), apiKey()}, nil) + out := assemble(t, v, []Source{provider(), model("chat", "mock")}, first.Document) + if bytes.Contains(out.Document, []byte("api_keys")) { + t.Errorf("deleted APIKey still served:\n%s", out.Document) + } +} + +func TestWholeDocumentErrorsAreNotAttributed(t *testing.T) { + broken := validate.Func(func(ctx context.Context, doc []byte) ([]validate.LoadError, error) { + return []validate.LoadError{{Scope: "(file)", Message: "boom"}}, nil + }) + out, err := Assemble(context.Background(), All([]Source{provider()}, testSecrets()), Served{}, broken) + if err != nil { + t.Fatal(err) + } + if out.Document != nil || !strings.Contains(out.Error, "boom") { + t.Errorf("outcome = %+v", out) + } +} + +func TestScopesParse(t *testing.T) { + cases := map[string]struct { + kind v1alpha1.Kind + i int + ok bool + }{ + `models[2] ("gpt")`: {v1alpha1.KindModel, 2, true}, + `provider_keys[0]`: {v1alpha1.KindProviderKey, 0, true}, + `api_keys[11] ("a]")`: {v1alpha1.KindAPIKey, 11, true}, + `(file)`: {"", 0, false}, + `guardrails[0]`: {"", 0, false}, + `models[x]`: {"", 0, false}, + } + for scope, want := range cases { + k, i, ok := ParseScope(scope) + if k != want.kind || i != want.i || ok != want.ok { + t.Errorf("%s: got (%s,%d,%v)", scope, k, i, ok) + } + } +} + +func TestServedIdentitiesAreUnescaped(t *testing.T) { + doc := Document(map[v1alpha1.Kind][]Entry{v1alpha1.KindModel: {{v1alpha1.IdentityField: "a$$b"}}}) + served := ServedEntries(doc) + if _, ok := served.get(src(v1alpha1.KindModel, "x", map[string]any{v1alpha1.IdentityField: "a$b"})); !ok { + t.Error("served entry not found by its literal identity") + } + if len(ServedEntries([]byte("not: json"))) != 0 { + t.Error("unreadable document should count as nothing served") + } +} From 2673327af38c881a1c574a5335c4c03b5aea813e Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:19:52 +0545 Subject: [PATCH 07/14] feat: add the reconcile loop and the controller manager --- cmd/aisix-controller/main.go | 107 ++++++++ go.mod | 39 ++- go.sum | 78 +++++- internal/controller/gateway.go | 160 +++++++++++ internal/controller/reconciler.go | 264 ++++++++++++++++++ internal/controller/reconciler_test.go | 363 +++++++++++++++++++++++++ internal/controller/setup.go | 64 +++++ internal/controller/status.go | 52 ++++ internal/controller/status_test.go | 134 +++++++++ 9 files changed, 1256 insertions(+), 5 deletions(-) create mode 100644 cmd/aisix-controller/main.go create mode 100644 internal/controller/gateway.go create mode 100644 internal/controller/reconciler.go create mode 100644 internal/controller/reconciler_test.go create mode 100644 internal/controller/setup.go create mode 100644 internal/controller/status.go create mode 100644 internal/controller/status_test.go diff --git a/cmd/aisix-controller/main.go b/cmd/aisix-controller/main.go new file mode 100644 index 0000000..6de49c2 --- /dev/null +++ b/cmd/aisix-controller/main.go @@ -0,0 +1,107 @@ +// Command aisix-controller reconciles AISIX custom resources into a +// standalone gateway's resources file. +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/api7/aisix-controller/api/v1alpha1" + "github.com/api7/aisix-controller/internal/controller" + "github.com/api7/aisix-controller/internal/validate" +) + +func main() { + var ( + namespace = flag.String("namespace", os.Getenv("POD_NAMESPACE"), "namespace of the gateway and its custom resources (default $POD_NAMESPACE)") + secret = flag.String("secret", "aisix-resources", "Secret the resources file is written to") + secretKey = flag.String("secret-key", "resources.yaml", "key of the resources file in the Secret") + deployment = flag.String("deployment", "aisix", "gateway Deployment") + statusPort = flag.Int("gateway-status-port", 9090, "gateway pods' metrics listener port, which serves /status/config") + aisixBinary = flag.String("aisix-binary", "/usr/local/bin/aisix", "aisix binary used to validate resources files") + tempDir = flag.String("temp-dir", os.TempDir(), "directory for files being validated") + resync = flag.Duration("resync", time.Minute, "full re-check interval") + poll = flag.Duration("poll", 3*time.Second, "gateway status poll interval while a change is pending") + metricsAddr = flag.String("metrics-bind-address", ":8080", "metrics endpoint address; 0 disables it") + probeAddr = flag.String("health-probe-bind-address", ":8081", "health probe address") + leaderElect = flag.Bool("leader-elect", true, "elect a leader so only one replica writes") + ) + opts := zap.Options{} + opts.BindFlags(flag.CommandLine) + flag.Parse() + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + setupLog := ctrl.Log.WithName("setup") + + if err := run(*namespace, *secret, *secretKey, *deployment, *statusPort, *aisixBinary, *tempDir, + *resync, *poll, *metricsAddr, *probeAddr, *leaderElect); err != nil { + setupLog.Error(err, "controller stopped") + os.Exit(1) + } +} + +func run(namespace, secret, secretKey, deployment string, statusPort int, aisixBinary, tempDir string, + resync, poll time.Duration, metricsAddr, probeAddr string, leaderElect bool) error { + if namespace == "" { + return fmt.Errorf("--namespace or $POD_NAMESPACE is required") + } + validator := &validate.Binary{Path: aisixBinary, TempDir: tempDir} + if err := validator.Check(); err != nil { + return err + } + + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + return err + } + if err := v1alpha1.AddToScheme(scheme); err != nil { + return err + } + + ns := map[string]cache.Config{namespace: {}} + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: leaderElect, + LeaderElectionID: "aisix-controller.aisix.api7.ai", + LeaderElectionNamespace: namespace, + // Only the gateway's namespace is watched. + Cache: cache.Options{DefaultNamespaces: ns}, + }) + if err != nil { + return err + } + + r := &controller.Reconciler{ + Client: mgr.GetClient(), + Namespace: namespace, + Secret: secret, + SecretKey: secretKey, + Deployment: deployment, + Validator: validator, + Status: &controller.HTTPStatusReader{Port: statusPort}, + Resync: resync, + Poll: poll, + } + if err := r.SetupWithManager(mgr); err != nil { + return err + } + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + return err + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + return err + } + ctrl.Log.WithName("setup").Info("starting", "namespace", namespace, "secret", secret, "deployment", deployment) + return mgr.Start(ctrl.SetupSignalHandler()) +} diff --git a/go.mod b/go.mod index 0a90fc0..56b8518 100644 --- a/go.mod +++ b/go.mod @@ -3,28 +3,61 @@ module github.com/api7/aisix-controller go 1.26.0 require ( + k8s.io/api v0.36.1 k8s.io/apiextensions-apiserver v0.36.1 k8s.io/apimachinery v0.36.1 + k8s.io/client-go v0.36.1 + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 sigs.k8s.io/controller-runtime v0.24.1 sigs.k8s.io/yaml v1.6.0 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.33.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + golang.org/x/time v0.14.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect diff --git a/go.sum b/go.sum index cbaef40..e2f3531 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,55 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -25,31 +57,58 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -58,19 +117,32 @@ golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= @@ -79,6 +151,8 @@ k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67G k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8= k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= +k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= +k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go new file mode 100644 index 0000000..34215ab --- /dev/null +++ b/internal/controller/gateway.go @@ -0,0 +1,160 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + + "github.com/api7/aisix-controller/api/v1alpha1" +) + +// PodStatus is what one gateway pod reports on /status/config. +type PodStatus struct { + // ConfigHash is applied.config_hash: in file mode, SHA-256 of the raw + // resources file the pod serves. + ConfigHash string + // LastFailure is last_failure.last_error, when a load failed. + LastFailure string +} + +// StatusReader reads a gateway pod's configuration status. +type StatusReader interface { + Read(ctx context.Context, pod *corev1.Pod) (PodStatus, error) +} + +// HTTPStatusReader reads GET /status/config on the pod's metrics listener, +// which serves it without auth (aisix crates/aisix-admin metrics_router). +type HTTPStatusReader struct { + Port int + Client *http.Client +} + +type statusConfig struct { + Applied *struct { + ConfigHash string `json:"config_hash"` + } `json:"applied"` + LastFailure *struct { + LastError string `json:"last_error"` + } `json:"last_failure"` +} + +func (r *HTTPStatusReader) Read(ctx context.Context, pod *corev1.Pod) (PodStatus, error) { + if pod.Status.PodIP == "" { + return PodStatus{}, fmt.Errorf("pod %s has no IP", pod.Name) + } + client := r.Client + if client == nil { + client = &http.Client{Timeout: 3 * time.Second} + } + url := "http://" + net.JoinHostPort(pod.Status.PodIP, strconv.Itoa(r.Port)) + "/status/config" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return PodStatus{}, err + } + resp, err := client.Do(req) + if err != nil { + return PodStatus{}, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return PodStatus{}, err + } + if resp.StatusCode != http.StatusOK { + return PodStatus{}, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode) + } + var s statusConfig + if err := json.Unmarshal(body, &s); err != nil { + return PodStatus{}, fmt.Errorf("%s: %w", url, err) + } + var out PodStatus + if s.Applied != nil { + out.ConfigHash = strings.ToLower(s.Applied.ConfigHash) + } + if s.LastFailure != nil { + out.LastFailure = s.LastFailure.LastError + } + return out, nil +} + +// Gateway is whether the gateway serves the wanted document. +type Gateway struct { + Ready bool + Reason string + Message string +} + +func podReady(p *corev1.Pod) bool { + if p.DeletionTimestamp != nil || p.Status.Phase != corev1.PodRunning { + return false + } + for _, c := range p.Status.Conditions { + if c.Type == corev1.PodReady { + return c.Status == corev1.ConditionTrue + } + } + return false +} + +// readyPods are the pods that take traffic. +func readyPods(pods []corev1.Pod) []*corev1.Pod { + var out []*corev1.Pod + for i := range pods { + if podReady(&pods[i]) { + out = append(out, &pods[i]) + } + } + return out +} + +// evaluate decides whether every pod that takes traffic serves the wanted +// hash, and enough of them do to make up the Deployment. +func evaluate(dep *appsv1.Deployment, ready []*corev1.Pod, statuses map[string]PodStatus, errs map[string]error, want string) Gateway { + if dep == nil { + return Gateway{Reason: "GatewayNotFound", Message: "the gateway Deployment does not exist"} + } + replicas := int32(1) + if dep.Spec.Replicas != nil { + replicas = *dep.Spec.Replicas + } + if replicas == 0 { + return Gateway{Reason: "Pending", Message: "the gateway Deployment is scaled to zero"} + } + serving := 0 + var notes []string + for _, p := range ready { + if err, ok := errs[p.Name]; ok { + notes = append(notes, fmt.Sprintf("pod %s: %v", p.Name, err)) + continue + } + s := statuses[p.Name] + if s.ConfigHash == want { + serving++ + continue + } + if s.LastFailure != "" { + notes = append(notes, fmt.Sprintf("pod %s: %s", p.Name, s.LastFailure)) + } + } + if serving == len(ready) && int32(serving) >= replicas { + return Gateway{Ready: true, Reason: "Programmed", Message: fmt.Sprintf("served by %d gateway pod(s)", serving)} + } + msg := fmt.Sprintf("%d/%d gateway pods serve this configuration", serving, replicas) + if len(notes) > 0 { + msg += "; " + strings.Join(notes, "; ") + } + return Gateway{Reason: "Pending", Message: msg} +} + +func stamped(dep *appsv1.Deployment, hash string) bool { + return dep.Spec.Template.Annotations[v1alpha1.HashAnnotation] == hash +} diff --git a/internal/controller/reconciler.go b/internal/controller/reconciler.go new file mode 100644 index 0000000..66284ab --- /dev/null +++ b/internal/controller/reconciler.go @@ -0,0 +1,264 @@ +// Package controller reconciles AISIX custom resources into the gateway's +// standalone resources file. +// +// The file is one document, so the whole namespace is reconciled at once: +// any change to a custom resource, a Secret, the gateway Deployment or its +// pods re-renders it. A changed document is written to the gateway's +// resources Secret and rolled out by stamping its hash on the Deployment's +// pod template, because the gateway reloads the file only on SIGHUP or at +// boot. +package controller + +import ( + "bytes" + "context" + "fmt" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/api7/aisix-controller/api/v1alpha1" + "github.com/api7/aisix-controller/internal/render" + "github.com/api7/aisix-controller/internal/validate" +) + +// FieldManager names the controller in managed fields and labels. +const FieldManager = "aisix-controller" + +// Reconciler writes the resources file of one gateway. +type Reconciler struct { + Client client.Client + Namespace string + Secret string + SecretKey string + Deployment string + Validator validate.Validator + Status StatusReader + // Resync re-checks everything this often. + Resync time.Duration + // Poll re-reads the gateway pods this often while a change is pending. + Poll time.Duration +} + +func (r *Reconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + ns := client.InNamespace(r.Namespace) + + var pks v1alpha1.ProviderKeyList + var models v1alpha1.ModelList + var keys v1alpha1.APIKeyList + var secrets corev1.SecretList + for _, l := range []client.ObjectList{&pks, &models, &keys, &secrets} { + if err := r.Client.List(ctx, l, ns); err != nil { + return ctrl.Result{}, err + } + } + + var objects []v1alpha1.Object + kinds := map[v1alpha1.Object]v1alpha1.Kind{} + for i := range pks.Items { + objects = append(objects, &pks.Items[i]) + kinds[&pks.Items[i]] = v1alpha1.KindProviderKey + } + for i := range models.Items { + objects = append(objects, &models.Items[i]) + kinds[&models.Items[i]] = v1alpha1.KindModel + } + for i := range keys.Items { + objects = append(objects, &keys.Items[i]) + kinds[&keys.Items[i]] = v1alpha1.KindAPIKey + } + + sources := make([]render.Source, 0, len(objects)) + decodeErrs := map[render.Key]string{} + for _, o := range objects { + fields, err := v1alpha1.SpecFields(o.SpecRaw()) + s := render.Source{ + Kind: kinds[o], Name: o.GetName(), Generation: o.GetGeneration(), + Created: o.GetCreationTimestamp().Time, Fields: fields, + } + if err != nil { + s.Fields = map[string]any{} + decodeErrs[s.Key()] = fmt.Sprintf("spec: %v", err) + } + sources = append(sources, s) + } + + secretData := render.Secrets{} + var current []byte + var target *corev1.Secret + for i := range secrets.Items { + s := &secrets.Items[i] + secretData[s.Name] = s.Data + if s.Name == r.Secret { + target = s + current = s.Data[r.SecretKey] + } + } + + rendered := render.All(sources, secretData) + for i := range rendered { + if msg, ok := decodeErrs[rendered[i].Source.Key()]; ok { + rendered[i].Entry, rendered[i].Err = nil, msg + } + } + outcome, err := render.Assemble(ctx, rendered, render.ServedEntries(current), r.Validator) + if err != nil { + return ctrl.Result{}, err + } + if outcome.Error != "" { + logger.Info("resources file rejected; keeping the served version", "error", outcome.Error) + } + + // A gateway needs a file to start, so seed an empty one. + desired := outcome.Document + if desired == nil && target == nil { + desired = render.Document(nil) + } + served := current + if desired != nil && (target == nil || !bytes.Equal(current, desired)) { + if err := r.writeSecret(ctx, target, desired); err != nil { + return ctrl.Result{}, err + } + logger.Info("resources file written", "hash", render.Hash(desired)[:12]) + served = desired + } + hash := render.Hash(served) + + gw, err := r.gateway(ctx, hash) + if err != nil { + return ctrl.Result{}, err + } + + written := outcome.Document != nil && bytes.Equal(outcome.Document, served) + pending := false + for _, o := range objects { + key := render.Key{Kind: kinds[o], Name: o.GetName()} + d, known := outcome.Dispositions[key] + st := desiredStatus(*o.ResourceStatus(), o.GetGeneration(), d, known, outcome.Error, written, gw) + if d.State == render.Applied && !gw.Ready { + pending = true + } + if err := r.patchStatus(ctx, o, st); err != nil { + return ctrl.Result{}, err + } + } + + if pending { + return ctrl.Result{RequeueAfter: r.Poll}, nil + } + return ctrl.Result{RequeueAfter: r.Resync}, nil +} + +func (r *Reconciler) writeSecret(ctx context.Context, existing *corev1.Secret, doc []byte) error { + labels := map[string]string{"app.kubernetes.io/managed-by": FieldManager} + annotations := map[string]string{v1alpha1.HashAnnotation: render.Hash(doc)} + if existing == nil { + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: r.Secret, Namespace: r.Namespace, Labels: labels, Annotations: annotations, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{r.SecretKey: doc}, + } + if err := r.Client.Create(ctx, s, client.FieldOwner(FieldManager)); err != nil { + return fmt.Errorf("creating Secret %s: %w", r.Secret, err) + } + return nil + } + s := existing.DeepCopy() + if s.Labels == nil { + s.Labels = map[string]string{} + } + if s.Annotations == nil { + s.Annotations = map[string]string{} + } + for k, v := range labels { + s.Labels[k] = v + } + for k, v := range annotations { + s.Annotations[k] = v + } + if s.Data == nil { + s.Data = map[string][]byte{} + } + s.Data[r.SecretKey] = doc + if err := r.Client.Update(ctx, s, client.FieldOwner(FieldManager)); err != nil { + return fmt.Errorf("updating Secret %s: %w", r.Secret, err) + } + return nil +} + +// gateway stamps the served hash on the Deployment and reports whether its +// pods serve it. +func (r *Reconciler) gateway(ctx context.Context, hash string) (Gateway, error) { + var dep appsv1.Deployment + err := r.Client.Get(ctx, types.NamespacedName{Namespace: r.Namespace, Name: r.Deployment}, &dep) + if apierrors.IsNotFound(err) { + return evaluate(nil, nil, nil, nil, hash), nil + } + if err != nil { + return Gateway{}, err + } + + if !stamped(&dep, hash) { + patch := client.MergeFrom(dep.DeepCopy()) + if dep.Spec.Template.Annotations == nil { + dep.Spec.Template.Annotations = map[string]string{} + } + dep.Spec.Template.Annotations[v1alpha1.HashAnnotation] = hash + if err := r.Client.Patch(ctx, &dep, patch, client.FieldOwner(FieldManager)); err != nil { + return Gateway{}, fmt.Errorf("stamping Deployment %s: %w", r.Deployment, err) + } + log.FromContext(ctx).Info("gateway rollout started", "hash", hash[:12]) + return Gateway{Reason: "Pending", Message: "rollout started"}, nil + } + + selector, err := metav1.LabelSelectorAsSelector(dep.Spec.Selector) + if err != nil { + return Gateway{}, err + } + var pods corev1.PodList + if err := r.Client.List(ctx, &pods, client.InNamespace(r.Namespace), client.MatchingLabelsSelector{Selector: selector}); err != nil { + return Gateway{}, err + } + ready := readyPods(pods.Items) + statuses := map[string]PodStatus{} + errs := map[string]error{} + for _, p := range ready { + s, err := r.Status.Read(ctx, p) + if err != nil { + errs[p.Name] = err + continue + } + statuses[p.Name] = s + } + return evaluate(&dep, ready, statuses, errs, hash), nil +} + +func (r *Reconciler) patchStatus(ctx context.Context, o v1alpha1.Object, st v1alpha1.ResourceStatus) error { + if equality.Semantic.DeepEqual(*o.ResourceStatus(), st) { + return nil + } + obj, ok := o.(client.Object) + if !ok { + return fmt.Errorf("%T is not a client.Object", o) + } + patch := client.MergeFrom(obj.DeepCopyObject().(client.Object)) + *o.ResourceStatus() = st + if err := r.Client.Status().Patch(ctx, obj, patch, client.FieldOwner(FieldManager)); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("status of %s %s: %w", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName(), err) + } + return nil +} diff --git a/internal/controller/reconciler_test.go b/internal/controller/reconciler_test.go new file mode 100644 index 0000000..e86e259 --- /dev/null +++ b/internal/controller/reconciler_test.go @@ -0,0 +1,363 @@ +package controller + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + + "github.com/api7/aisix-controller/api/v1alpha1" + "github.com/api7/aisix-controller/internal/render" + "github.com/api7/aisix-controller/internal/validate" +) + +var ( + testEnv *envtest.Environment + testClient client.Client + testScheme = runtime.NewScheme() +) + +func TestMain(m *testing.M) { + if os.Getenv("KUBEBUILDER_ASSETS") == "" { + fmt.Println("KUBEBUILDER_ASSETS unset; skipping envtest suites") + os.Exit(m.Run()) + } + _ = clientgoscheme.AddToScheme(testScheme) + _ = v1alpha1.AddToScheme(testScheme) + testEnv = &envtest.Environment{CRDDirectoryPaths: []string{"../../config/crd"}, ErrorIfCRDPathMissing: true} + cfg, err := testEnv.Start() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + testClient, err = client.New(cfg, client.Options{Scheme: testScheme}) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + code := m.Run() + _ = testEnv.Stop() + os.Exit(code) +} + +func needEnv(t *testing.T) { + if testEnv == nil { + t.Skip("needs envtest (KUBEBUILDER_ASSETS)") + } +} + +// rejectingValidator rejects any entry whose model_name is "reject-me", +// scoped the way the gateway scopes errors. +var rejectingValidator = validate.Func(func(_ context.Context, doc []byte) ([]validate.LoadError, error) { + var root map[string]any + if err := json.Unmarshal(doc, &root); err != nil { + return []validate.LoadError{{Scope: "(file)", Message: err.Error()}}, nil + } + var errs []validate.LoadError + items, _ := root["models"].([]any) + for i, it := range items { + if it.(map[string]any)["model_name"] == "reject-me" { + errs = append(errs, validate.LoadError{Scope: fmt.Sprintf("models[%d]", i), Message: "rejected by the gateway"}) + } + } + return errs, nil +}) + +// fakeStatus serves configured hashes per pod. +type fakeStatus struct { + mu sync.Mutex + hashes map[string]string +} + +func (f *fakeStatus) set(pod, hash string) { + f.mu.Lock() + defer f.mu.Unlock() + f.hashes[pod] = hash +} + +func (f *fakeStatus) Read(_ context.Context, p *corev1.Pod) (PodStatus, error) { + f.mu.Lock() + defer f.mu.Unlock() + return PodStatus{ConfigHash: f.hashes[p.Name]}, nil +} + +func newNamespace(t *testing.T) string { + t.Helper() + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "t-"}} + if err := testClient.Create(context.Background(), ns); err != nil { + t.Fatal(err) + } + return ns.Name +} + +func spec(t *testing.T, v map[string]any) apiextensionsv1.JSON { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return apiextensionsv1.JSON{Raw: b} +} + +func create(t *testing.T, objs ...client.Object) { + t.Helper() + for _, o := range objs { + if err := testClient.Create(context.Background(), o); err != nil { + t.Fatalf("create %T %s: %v", o, o.GetName(), err) + } + } +} + +func gatewayDeployment(ns string) *appsv1.Deployment { + labels := map[string]string{"app": "aisix"} + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "aisix", Namespace: ns}, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To[int32](1), + Selector: &metav1.LabelSelector{MatchLabels: labels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "aisix", Image: "aisix"}}}, + }, + }, + } +} + +func readyPod(t *testing.T, ns, name string) { + t.Helper() + p := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Labels: map[string]string{"app": "aisix"}}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "aisix", Image: "aisix"}}}, + } + create(t, p) + p.Status = corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.7", + Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}, + } + if err := testClient.Status().Update(context.Background(), p); err != nil { + t.Fatal(err) + } +} + +func newReconciler(ns string, status StatusReader) *Reconciler { + return &Reconciler{ + Client: testClient, Namespace: ns, Secret: "aisix-resources", SecretKey: "resources.yaml", + Deployment: "aisix", Validator: rejectingValidator, Status: status, + Resync: time.Minute, Poll: time.Second, + } +} + +func reconcileOnce(t *testing.T, r *Reconciler) ctrl.Result { + t.Helper() + res, err := r.Reconcile(context.Background(), ctrl.Request{}) + if err != nil { + t.Fatal(err) + } + return res +} + +func served(t *testing.T, ns string) []byte { + t.Helper() + var s corev1.Secret + if err := testClient.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: "aisix-resources"}, &s); err != nil { + t.Fatal(err) + } + return s.Data["resources.yaml"] +} + +func conditionOf(t *testing.T, o client.Object, typ string) *metav1.Condition { + t.Helper() + if err := testClient.Get(context.Background(), client.ObjectKeyFromObject(o), o); err != nil { + t.Fatal(err) + } + c := meta.FindStatusCondition(o.(v1alpha1.Object).ResourceStatus().Conditions, typ) + if c == nil { + t.Fatalf("%s has no %s condition", o.GetName(), typ) + } + return c +} + +func TestReconcileSeedsAnEmptyResourcesFile(t *testing.T) { + needEnv(t) + ns := newNamespace(t) + reconcileOnce(t, newReconciler(ns, &fakeStatus{hashes: map[string]string{}})) + if got := served(t, ns); !bytes.Equal(got, render.Document(nil)) { + t.Errorf("seed = %q", got) + } +} + +func TestReconcileServesResourcesAndReportsStatus(t *testing.T) { + needEnv(t) + ns := newNamespace(t) + status := &fakeStatus{hashes: map[string]string{}} + r := newReconciler(ns, status) + + create(t, + gatewayDeployment(ns), + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: ns}, + StringData: map[string]string{"upstream": "sk-$x", "caller": "caller-key"}, + }, + &v1alpha1.ProviderKey{ + ObjectMeta: metav1.ObjectMeta{Name: "mock", Namespace: ns}, + Spec: spec(t, map[string]any{"provider": "openai", "api_base": "http://mock/v1", + v1alpha1.APIKeySecretRef: map[string]any{"name": "creds", "key": "upstream"}}), + }, + &v1alpha1.Model{ + ObjectMeta: metav1.ObjectMeta{Name: "chat", Namespace: ns}, + Spec: spec(t, map[string]any{"display_name": "e2e/chat", "provider": "openai", "model_name": "a", + v1alpha1.ProviderKeyRef: map[string]any{"name": "mock"}}), + }, + &v1alpha1.APIKey{ + ObjectMeta: metav1.ObjectMeta{Name: "caller", Namespace: ns}, + Spec: spec(t, map[string]any{"allowed_models": []any{"*"}, + v1alpha1.KeySecretRef: map[string]any{"name": "creds", "key": "caller"}}), + }, + ) + + // First pass: written and stamped, not yet served. + res := reconcileOnce(t, r) + doc := served(t, ns) + for _, want := range []string{`"api_key": "sk-$$x"`, `"provider_key": "mock"`, `"key_hash": "` + render.HashKey("caller-key")} { + if !strings.Contains(string(doc), want) { + t.Errorf("document lacks %s:\n%s", want, doc) + } + } + if strings.Contains(string(doc), "caller-key") { + t.Error("caller key plaintext is in the document") + } + var dep appsv1.Deployment + if err := testClient.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: "aisix"}, &dep); err != nil { + t.Fatal(err) + } + if dep.Spec.Template.Annotations[v1alpha1.HashAnnotation] != render.Hash(doc) { + t.Errorf("deployment not stamped: %v", dep.Spec.Template.Annotations) + } + model := &v1alpha1.Model{ObjectMeta: metav1.ObjectMeta{Name: "chat", Namespace: ns}} + if c := conditionOf(t, model, CondAccepted); c.Status != metav1.ConditionTrue { + t.Errorf("Accepted = %+v", c) + } + if c := conditionOf(t, model, CondProgrammed); c.Status != metav1.ConditionFalse || c.Reason != "Pending" { + t.Errorf("Programmed = %+v", c) + } + if res.RequeueAfter != r.Poll { + t.Errorf("pending change should poll, got %v", res.RequeueAfter) + } + + // A ready pod that serves an older file keeps it pending. + readyPod(t, ns, "aisix-1") + status.set("aisix-1", "old") + reconcileOnce(t, r) + if c := conditionOf(t, model, CondProgrammed); c.Status != metav1.ConditionFalse || !strings.Contains(c.Message, "0/1") { + t.Errorf("Programmed = %+v", c) + } + + // Once it serves the written file, everything is programmed. + status.set("aisix-1", render.Hash(doc)) + res = reconcileOnce(t, r) + for _, o := range []client.Object{ + &v1alpha1.ProviderKey{ObjectMeta: metav1.ObjectMeta{Name: "mock", Namespace: ns}}, + model, + &v1alpha1.APIKey{ObjectMeta: metav1.ObjectMeta{Name: "caller", Namespace: ns}}, + } { + if c := conditionOf(t, o, CondProgrammed); c.Status != metav1.ConditionTrue { + t.Errorf("%s Programmed = %+v", o.GetName(), c) + } + } + if res.RequeueAfter != r.Resync { + t.Errorf("settled state should resync, got %v", res.RequeueAfter) + } + + // A change the gateway rejects keeps the served version. + if err := testClient.Get(context.Background(), client.ObjectKeyFromObject(model), model); err != nil { + t.Fatal(err) + } + model.Spec = spec(t, map[string]any{"display_name": "e2e/chat", "provider": "openai", "model_name": "reject-me", + v1alpha1.ProviderKeyRef: map[string]any{"name": "mock"}}) + if err := testClient.Update(context.Background(), model); err != nil { + t.Fatal(err) + } + reconcileOnce(t, r) + if !bytes.Equal(served(t, ns), doc) { + t.Error("a rejected change altered the served document") + } + c := conditionOf(t, model, CondAccepted) + if c.Status != metav1.ConditionFalse || !strings.Contains(c.Message, "rejected by the gateway") || c.ObservedGeneration != model.Generation { + t.Errorf("Accepted = %+v (generation %d)", c, model.Generation) + } + if c := conditionOf(t, model, CondProgrammed); c.Message != "the previously accepted version keeps serving" { + t.Errorf("Programmed = %+v", c) + } + + // Deleting a resource removes it from the file. + if err := testClient.Delete(context.Background(), &v1alpha1.APIKey{ObjectMeta: metav1.ObjectMeta{Name: "caller", Namespace: ns}}); err != nil { + t.Fatal(err) + } + reconcileOnce(t, r) + if strings.Contains(string(served(t, ns)), "api_keys") { + t.Error("deleted APIKey is still served") + } +} + +func TestManagerReconcilesOnStartAndOnChange(t *testing.T) { + needEnv(t) + ns := newNamespace(t) + mgr, err := ctrl.NewManager(testEnv.Config, ctrl.Options{ + Scheme: testScheme, + Metrics: metricsserver.Options{BindAddress: "0"}, + Cache: cache.Options{DefaultNamespaces: map[string]cache.Config{ns: {}}}, + }) + if err != nil { + t.Fatal(err) + } + r := newReconciler(ns, &fakeStatus{hashes: map[string]string{}}) + r.Client = mgr.GetClient() + if err := r.SetupWithManager(mgr); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = mgr.Start(ctx) }() + + waitFor := func(what string, ok func(doc []byte) bool) { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + var s corev1.Secret + if err := testClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: "aisix-resources"}, &s); err == nil && ok(s.Data["resources.yaml"]) { + return + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) + } + waitFor("the seeded file", func(doc []byte) bool { return len(doc) > 0 }) + + create(t, &v1alpha1.Model{ + ObjectMeta: metav1.ObjectMeta{Name: "routed", Namespace: ns}, + Spec: spec(t, map[string]any{"routing": map[string]any{"targets": []any{map[string]any{"model": "x"}}}}), + }) + waitFor("the new Model", func(doc []byte) bool { return strings.Contains(string(doc), `"routed"`) }) +} diff --git a/internal/controller/setup.go b/internal/controller/setup.go new file mode 100644 index 0000000..0fab50c --- /dev/null +++ b/internal/controller/setup.go @@ -0,0 +1,64 @@ +package controller + +import ( + "context" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/api7/aisix-controller/api/v1alpha1" +) + +// Settle is how long events coalesce before one reconcile, so a burst of +// edits rolls the gateway once. +const Settle = time.Second + +// delayed enqueues the single work item after Settle. +type delayed struct{ key reconcile.Request } + +type queue = workqueue.TypedRateLimitingInterface[reconcile.Request] + +func (d delayed) Create(_ context.Context, _ event.CreateEvent, q queue) { q.AddAfter(d.key, Settle) } +func (d delayed) Update(_ context.Context, _ event.UpdateEvent, q queue) { q.AddAfter(d.key, Settle) } +func (d delayed) Delete(_ context.Context, _ event.DeleteEvent, q queue) { q.AddAfter(d.key, Settle) } +func (d delayed) Generic(_ context.Context, _ event.GenericEvent, q queue) { + q.AddAfter(d.key, Settle) +} + +// SetupWithManager watches everything the resources file depends on. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + key := reconcile.Request{NamespacedName: types.NamespacedName{Namespace: r.Namespace, Name: r.Secret}} + h := delayed{key} + generation := builder.WithPredicates(predicate.GenerationChangedPredicate{}) + inNamespace := builder.WithPredicates(predicate.NewPredicateFuncs(func(o client.Object) bool { + return o.GetNamespace() == r.Namespace + })) + + // Reconcile once at start, even in an empty namespace. + kick := make(chan event.GenericEvent, 1) + kick <- event.GenericEvent{Object: &corev1.Secret{}} + + return ctrl.NewControllerManagedBy(mgr). + Named("aisix-resources"). + WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + Watches(&v1alpha1.ProviderKey{}, h, generation, inNamespace). + Watches(&v1alpha1.Model{}, h, generation, inNamespace). + Watches(&v1alpha1.APIKey{}, h, generation, inNamespace). + Watches(&corev1.Secret{}, h, inNamespace). + Watches(&appsv1.Deployment{}, h, inNamespace). + Watches(&corev1.Pod{}, h, inNamespace). + WatchesRawSource(source.Channel(kick, handler.EventHandler(h))). + Complete(r) +} diff --git a/internal/controller/status.go b/internal/controller/status.go new file mode 100644 index 0000000..b6de61d --- /dev/null +++ b/internal/controller/status.go @@ -0,0 +1,52 @@ +package controller + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/api7/aisix-controller/api/v1alpha1" + "github.com/api7/aisix-controller/internal/render" +) + +const ( + CondAccepted = "Accepted" + CondProgrammed = "Programmed" +) + +// desiredStatus derives one resource's conditions. Accepted: its current +// spec is valid. Programmed: the gateway serves its current spec. +func desiredStatus(prev v1alpha1.ResourceStatus, generation int64, d render.Disposition, known bool, docErr string, written bool, gw Gateway) v1alpha1.ResourceStatus { + st := *prev.DeepCopy() + st.ObservedGeneration = generation + set := func(t string, s metav1.ConditionStatus, reason, msg string) { + meta.SetStatusCondition(&st.Conditions, metav1.Condition{ + Type: t, Status: s, Reason: reason, Message: msg, ObservedGeneration: generation, + }) + } + + switch { + case !known: + set(CondAccepted, metav1.ConditionUnknown, "Pending", "not reconciled yet") + set(CondProgrammed, metav1.ConditionUnknown, "Pending", "not reconciled yet") + case d.State == render.Retained && docErr == "": + set(CondAccepted, metav1.ConditionFalse, "Invalid", d.Reason) + set(CondProgrammed, metav1.ConditionFalse, "Invalid", "the previously accepted version keeps serving") + case d.State != render.Applied: + set(CondAccepted, metav1.ConditionFalse, "Invalid", d.Reason) + set(CondProgrammed, metav1.ConditionFalse, "Invalid", "not served") + case docErr != "": + set(CondAccepted, metav1.ConditionUnknown, "Pending", "the resources file as a whole is invalid") + set(CondProgrammed, metav1.ConditionFalse, "ConfigurationRejected", docErr) + default: + set(CondAccepted, metav1.ConditionTrue, "Accepted", "valid") + switch { + case !written: + set(CondProgrammed, metav1.ConditionFalse, "Pending", "waiting for the resources file to be written") + case gw.Ready: + set(CondProgrammed, metav1.ConditionTrue, gw.Reason, gw.Message) + default: + set(CondProgrammed, metav1.ConditionFalse, gw.Reason, gw.Message) + } + } + return st +} diff --git a/internal/controller/status_test.go b/internal/controller/status_test.go new file mode 100644 index 0000000..c516d59 --- /dev/null +++ b/internal/controller/status_test.go @@ -0,0 +1,134 @@ +package controller + +import ( + "errors" + "strings" + "testing" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + "github.com/api7/aisix-controller/api/v1alpha1" + "github.com/api7/aisix-controller/internal/render" +) + +func deployment(replicas int32) *appsv1.Deployment { + return &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To(replicas)}} +} + +func pod(name string, ready bool) corev1.Pod { + status := corev1.ConditionFalse + if ready { + status = corev1.ConditionTrue + } + return corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.1", + Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: status}}, + }, + } +} + +func TestEvaluate(t *testing.T) { + want := "abc" + pods := []corev1.Pod{pod("a", true), pod("b", true), pod("c", false)} + ready := readyPods(pods) + if len(ready) != 2 { + t.Fatalf("ready = %d", len(ready)) + } + + cases := []struct { + name string + dep *appsv1.Deployment + statuses map[string]PodStatus + errs map[string]error + ready bool + reason string + substr string + }{ + {"missing deployment", nil, nil, nil, false, "GatewayNotFound", "does not exist"}, + {"scaled to zero", deployment(0), nil, nil, false, "Pending", "scaled to zero"}, + {"all serve", deployment(2), map[string]PodStatus{"a": {ConfigHash: want}, "b": {ConfigHash: want}}, nil, true, "Programmed", "2 gateway pod"}, + {"one stale", deployment(2), map[string]PodStatus{"a": {ConfigHash: want}, "b": {ConfigHash: "old"}}, nil, false, "Pending", "1/2"}, + {"stale with failure", deployment(2), map[string]PodStatus{"a": {ConfigHash: want}, "b": {ConfigHash: "old", LastFailure: "boom"}}, nil, false, "Pending", "pod b: boom"}, + {"unreachable", deployment(2), map[string]PodStatus{"a": {ConfigHash: want}}, map[string]error{"b": errors.New("refused")}, false, "Pending", "pod b: refused"}, + {"too few ready", deployment(3), map[string]PodStatus{"a": {ConfigHash: want}, "b": {ConfigHash: want}}, nil, false, "Pending", "2/3"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := ready + if c.dep == nil { + r = nil + } + gw := evaluate(c.dep, r, c.statuses, c.errs, want) + if gw.Ready != c.ready || gw.Reason != c.reason || !strings.Contains(gw.Message, c.substr) { + t.Errorf("got %+v", gw) + } + }) + } +} + +func cond(st v1alpha1.ResourceStatus, t string) *metav1.Condition { + return meta.FindStatusCondition(st.Conditions, t) +} + +func TestDesiredStatus(t *testing.T) { + applied := render.Disposition{State: render.Applied} + ready := Gateway{Ready: true, Reason: "Programmed", Message: "served"} + pending := Gateway{Reason: "Pending", Message: "1/2"} + + cases := []struct { + name string + d render.Disposition + known bool + docErr string + written bool + gw Gateway + accepted, program metav1.ConditionStatus + acceptedR, programR string + }{ + {"applied and served", applied, true, "", true, ready, "True", "True", "Accepted", "Programmed"}, + {"applied while rolling", applied, true, "", true, pending, "True", "False", "Accepted", "Pending"}, + {"applied, not written", applied, true, "", false, ready, "True", "False", "Accepted", "Pending"}, + {"retained", render.Disposition{State: render.Retained, Reason: "bad"}, true, "", true, ready, "False", "False", "Invalid", "Invalid"}, + {"rejected", render.Disposition{State: render.Rejected, Reason: "bad"}, true, "", true, ready, "False", "False", "Invalid", "Invalid"}, + {"document invalid", applied, true, "boom", false, ready, "Unknown", "False", "Pending", "ConfigurationRejected"}, + {"unknown", render.Disposition{}, false, "", true, ready, "Unknown", "Unknown", "Pending", "Pending"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + st := desiredStatus(v1alpha1.ResourceStatus{}, 3, c.d, c.known, c.docErr, c.written, c.gw) + a, p := cond(st, CondAccepted), cond(st, CondProgrammed) + if a.Status != c.accepted || a.Reason != c.acceptedR || p.Status != c.program || p.Reason != c.programR { + t.Errorf("accepted=%s/%s programmed=%s/%s", a.Status, a.Reason, p.Status, p.Reason) + } + if st.ObservedGeneration != 3 || a.ObservedGeneration != 3 || p.ObservedGeneration != 3 { + t.Error("observedGeneration not set") + } + }) + } +} + +func TestTransitionTimeIsKeptWhenStatusIsUnchanged(t *testing.T) { + applied := render.Disposition{State: render.Applied} + gw := Gateway{Ready: true, Reason: "Programmed", Message: "served"} + first := desiredStatus(v1alpha1.ResourceStatus{}, 1, applied, true, "", true, gw) + old := metav1.NewTime(time.Now().Add(-time.Hour).Truncate(time.Second)) + for i := range first.Conditions { + first.Conditions[i].LastTransitionTime = old + } + second := desiredStatus(first, 2, applied, true, "", true, gw) + if !cond(second, CondProgrammed).LastTransitionTime.Equal(&old) { + t.Error("transition time moved without a status change") + } + third := desiredStatus(second, 2, render.Disposition{State: render.Rejected, Reason: "x"}, true, "", true, gw) + if cond(third, CondAccepted).LastTransitionTime.Equal(&old) { + t.Error("transition time kept across a status change") + } +} From 6fadcac210e7bcb9479d2ec1070d3b10b63aad7d Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:22:58 +0545 Subject: [PATCH 08/14] build: add deploy manifests, Dockerfile and Makefile --- .dockerignore | 7 +++ Dockerfile | 33 ++++++++++++++ Makefile | 57 ++++++++++++++++++++++++ config/controller/deployment.yaml | 65 ++++++++++++++++++++++++++++ config/controller/kustomization.yaml | 5 +++ config/controller/rbac.yaml | 47 ++++++++++++++++++++ config/default/kustomization.yaml | 5 +++ 7 files changed, 219 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 config/controller/deployment.yaml create mode 100644 config/controller/kustomization.yaml create mode 100644 config/controller/rbac.yaml create mode 100644 config/default/kustomization.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cbcb56c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +bin +.git +test +config +third_party +hack +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..08a423f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +# The aisix release whose `validate` the controller runs. Keep it equal to +# third_party/aisix/VERSION. +ARG AISIX_VERSION=1.9.0 + +FROM --platform=$BUILDPLATFORM golang:1.26 AS build +ARG TARGETOS TARGETARCH +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY api ./api +COPY cmd ./cmd +COPY internal ./internal +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ + go build -trimpath -ldflags="-s -w" -o /out/aisix-controller ./cmd/aisix-controller + +FROM docker.io/api7/aisix:${AISIX_VERSION} AS aisix + +# The gateway image grants the binary cap_net_bind_service. A container +# that drops all capabilities with no_new_privs cannot exec such a file, +# and validation binds nothing, so a plain copy drops it. +FROM debian:trixie-slim AS aisix-bin +COPY --from=aisix /usr/local/bin/aisix /aisix-with-caps +RUN cp /aisix-with-caps /aisix + +# Same Debian release as the aisix image, for the binary's glibc. +FROM debian:trixie-slim +COPY --from=aisix-bin /aisix /usr/local/bin/aisix +COPY --from=build /out/aisix-controller /usr/local/bin/aisix-controller +USER 65532:65532 +ENTRYPOINT ["/usr/local/bin/aisix-controller"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b2467cb --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +IMAGE ?= aisix-controller:dev +AISIX_TAG ?= $(shell cat third_party/aisix/VERSION) +ENVTEST_K8S_VERSION ?= 1.36.2 +LOCALBIN ?= $(CURDIR)/bin +CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen +SETUP_ENVTEST ?= $(LOCALBIN)/setup-envtest +GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint + +CONTROLLER_TOOLS_VERSION ?= v0.20.0 +SETUP_ENVTEST_VERSION ?= release-0.24 +GOLANGCI_LINT_VERSION ?= v2.7.1 + +.PHONY: build test test-aisix lint generate crds crds-check sync-schemas image e2e + +build: + go build ./... + +## Unit tests plus the envtest suites. +test: $(SETUP_ENVTEST) + KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./... + +## Unit tests against the real aisix binary, in a Linux container. +test-aisix: + hack/test-with-aisix.sh ./internal/... + +lint: $(GOLANGCI_LINT) + $(GOLANGCI_LINT) run ./... + +## DeepCopy methods for the API types. +generate: $(CONTROLLER_GEN) + $(CONTROLLER_GEN) object:headerFile=hack/boilerplate.go.txt paths=./api/... + +## CRDs from the vendored aisix schemas. +crds: + go run ./cmd/crdgen -schemas third_party/aisix -out config/crd + +crds-check: generate crds + git diff --exit-code api config/crd + +## Vendor another aisix release's schemas: make sync-schemas AISIX_TAG=v1.10.0 +sync-schemas: + hack/sync-schemas.sh $(AISIX_TAG) + +image: + docker build --build-arg AISIX_VERSION=$(patsubst v%,%,$(AISIX_TAG)) -t $(IMAGE) . + +e2e: + IMAGE=$(IMAGE) test/e2e/run.sh + +$(CONTROLLER_GEN): + GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION) + +$(SETUP_ENVTEST): + GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(SETUP_ENVTEST_VERSION) + +$(GOLANGCI_LINT): + GOBIN=$(LOCALBIN) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) diff --git a/config/controller/deployment.yaml b/config/controller/deployment.yaml new file mode 100644 index 0000000..0bb0982 --- /dev/null +++ b/config/controller/deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: aisix-controller + labels: + app.kubernetes.io/name: aisix-controller +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: aisix-controller + template: + metadata: + labels: + app.kubernetes.io/name: aisix-controller + spec: + serviceAccountName: aisix-controller + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: controller + image: aisix-controller:dev + imagePullPolicy: IfNotPresent + args: + - --secret=aisix-resources + - --deployment=aisix + - --gateway-status-port=9090 + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + ports: + - name: metrics + containerPort: 8080 + - name: probes + containerPort: 8081 + livenessProbe: + httpGet: + path: /healthz + port: probes + readinessProbe: + httpGet: + path: /readyz + port: probes + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + # Resources files being validated. + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/config/controller/kustomization.yaml b/config/controller/kustomization.yaml new file mode 100644 index 0000000..4b14a26 --- /dev/null +++ b/config/controller/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - rbac.yaml + - deployment.yaml diff --git a/config/controller/rbac.yaml b/config/controller/rbac.yaml new file mode 100644 index 0000000..1106570 --- /dev/null +++ b/config/controller/rbac.yaml @@ -0,0 +1,47 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: aisix-controller +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: aisix-controller +rules: + - apiGroups: ["aisix.api7.ai"] + resources: ["providerkeys", "models", "apikeys"] + verbs: ["get", "list", "watch"] + - apiGroups: ["aisix.api7.ai"] + resources: ["providerkeys/status", "models/status", "apikeys/status"] + verbs: ["get", "patch", "update"] + # Reads the Secrets that resources reference; writes the resources Secret. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "patch", "update"] + # Reads gateway pods' /status/config. + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + # Stamps the resources hash on the gateway pod template to roll it out. + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "patch"] + # Leader election. + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: aisix-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: aisix-controller +subjects: + - kind: ServiceAccount + name: aisix-controller diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml new file mode 100644 index 0000000..e5f272c --- /dev/null +++ b/config/default/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../crd + - ../controller From a949dd8f39b6edefbd5bfd33abb9d8c795c07be1 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:32:41 +0545 Subject: [PATCH 09/14] test: add the kind-based e2e suite --- test/e2e/manifests/mock-openai.yaml | 76 ++++++++++ test/e2e/manifests/resources.yaml | 43 ++++++ test/e2e/run.sh | 223 ++++++++++++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 test/e2e/manifests/mock-openai.yaml create mode 100644 test/e2e/manifests/resources.yaml create mode 100755 test/e2e/run.sh diff --git a/test/e2e/manifests/mock-openai.yaml b/test/e2e/manifests/mock-openai.yaml new file mode 100644 index 0000000..3b772ff --- /dev/null +++ b/test/e2e/manifests/mock-openai.yaml @@ -0,0 +1,76 @@ +# OpenAI-compatible upstream that echoes what the gateway sent it. +apiVersion: v1 +kind: ConfigMap +metadata: + name: mock-openai +data: + server.py: | + import json + from http.server import BaseHTTPRequestHandler, HTTPServer + + class Handler(BaseHTTPRequestHandler): + def _send(self, code, body): + data = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + self._send(200, {"ok": True}) + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + req = json.loads(self.rfile.read(length) or b"{}") + model = req.get("model", "") + auth = self.headers.get("Authorization", "") + self._send(200, { + "id": "chatcmpl-e2e", + "object": "chat.completion", + "created": 0, + "model": model, + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": f"upstream-model={model} auth={auth}"}, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }) + + def log_message(self, *args): + pass + + HTTPServer(("0.0.0.0", 8080), Handler).serve_forever() +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mock-openai +spec: + replicas: 1 + selector: + matchLabels: {app: mock-openai} + template: + metadata: + labels: {app: mock-openai} + spec: + containers: + - name: mock + image: python:3.12-alpine + command: ["python", "-u", "/app/server.py"] + ports: [{containerPort: 8080}] + readinessProbe: + httpGet: {path: /healthz, port: 8080} + volumeMounts: [{name: app, mountPath: /app}] + volumes: + - name: app + configMap: {name: mock-openai} +--- +apiVersion: v1 +kind: Service +metadata: + name: mock-openai +spec: + selector: {app: mock-openai} + ports: [{port: 8080, targetPort: 8080}] diff --git a/test/e2e/manifests/resources.yaml b/test/e2e/manifests/resources.yaml new file mode 100644 index 0000000..ff71e2c --- /dev/null +++ b/test/e2e/manifests/resources.yaml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: Secret +metadata: + name: e2e-creds +type: Opaque +stringData: + # A literal `$` must reach the upstream unchanged. + upstream: "sk-e2e-$dollar" + caller: "caller-e2e-key" +--- +apiVersion: aisix.api7.ai/v1alpha1 +kind: ProviderKey +metadata: + name: mock +spec: + provider: openai + adapter: openai + api_base: http://mock-openai:8080/v1 + api_key_secret_ref: + name: e2e-creds + key: upstream +--- +apiVersion: aisix.api7.ai/v1alpha1 +kind: Model +metadata: + name: chat +spec: + # Not a valid Kubernetes name, so it has to come from display_name. + display_name: e2e/chat + provider: openai + model_name: upstream-a + provider_key_ref: + name: mock +--- +apiVersion: aisix.api7.ai/v1alpha1 +kind: APIKey +metadata: + name: caller +spec: + allowed_models: ["*"] + key_secret_ref: + name: e2e-creds + key: caller diff --git a/test/e2e/run.sh b/test/e2e/run.sh new file mode 100755 index 0000000..f92c221 --- /dev/null +++ b/test/e2e/run.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# End-to-end test: kind cluster, the AISIX gateway from its Helm chart, the +# controller, and a mock OpenAI upstream. +# +# test/e2e/run.sh # creates and deletes its own kind cluster +# KEEP=1 test/e2e/run.sh # leaves the cluster up for inspection +set -euo pipefail +cd "$(dirname "$0")/../.." + +CLUSTER=${CLUSTER:-aisix-controller-e2e} +NS=${NS:-aisix-e2e} +IMAGE=${IMAGE:-aisix-controller:dev} +AISIX_TAG=$(cat third_party/aisix/VERSION) +AISIX_CHART_VERSION=${AISIX_CHART_VERSION:-${AISIX_TAG#v}} +KEEP=${KEEP:-0} +MANIFESTS=test/e2e/manifests +CREATED_CLUSTER=0 +ERR=$(mktemp) + +log() { printf '\n==> %s\n' "$*"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +dump() { + echo "---- diagnostics ----" + kubectl -n "$NS" get pods,deploy,secrets -o wide || true + kubectl -n "$NS" get providerkeys,models,apikeys -o yaml || true + kubectl -n "$NS" logs deploy/aisix-controller --tail=200 || true + kubectl -n "$NS" logs deploy/aisix --tail=100 || true +} + +cleanup() { + local rc=$? + if [ "$rc" -ne 0 ]; then dump; fi + if [ "$KEEP" != 1 ] && [ "$CREATED_CLUSTER" = 1 ]; then + kind delete cluster --name "$CLUSTER" >/dev/null 2>&1 || true + fi + exit "$rc" +} +trap cleanup EXIT + +k() { kubectl -n "$NS" "$@"; } + +# wait_condition : the condition holds for +# the resource's current generation. +wait_condition() { + local res=$1 name=$2 type=$3 want=$4 gen got + for _ in $(seq 1 120); do + gen=$(k get "$res" "$name" -o jsonpath='{.metadata.generation}') + got=$(k get "$res" "$name" -o jsonpath="{.status.conditions[?(@.type==\"$type\")].status}/{.status.conditions[?(@.type==\"$type\")].observedGeneration}") + if [ "$got" = "$want/$gen" ]; then return 0; fi + sleep 2 + done + k get "$res" "$name" -o yaml + fail "$res/$name: $type never became $want for generation $gen (last: $got)" +} + +# chat : prints " " for a request through the gateway. +chat() { + local out + out=$(k exec curl -- curl -s -w '\n%{http_code}' \ + -H "Authorization: Bearer $1" -H 'Content-Type: application/json' \ + -d '{"model":"e2e/chat","messages":[{"role":"user","content":"hi"}]}' \ + http://aisix/v1/chat/completions 2>/dev/null) || true + printf '%s %s\n' "${out##*$'\n'}" "${out%$'\n'*}" +} + +# expect_chat [substring]: retries while the gateway rolls. +expect_chat() { + local key=$1 code=$2 want=${3:-} out + for _ in $(seq 1 60); do + out=$(chat "$key") + if [ "${out%% *}" = "$code" ] && { [ -z "$want" ] || [[ "$out" == *"$want"* ]]; }; then + echo "ok: $out" + return 0 + fi + sleep 2 + done + fail "expected $code ${want:+containing '$want'}, got: $out" +} + +# served_hash: sha256 of the resources file the controller wrote. +served_hash() { + k get secret aisix-resources -o jsonpath='{.data.resources\.yaml}' \ + | python3 -c 'import base64,hashlib,sys; print(hashlib.sha256(base64.b64decode(sys.stdin.read())).hexdigest())' +} + +# pod_hashes: applied.config_hash of every gateway pod that is not +# terminating, from /status/config. +pod_hashes() { + local ip + for ip in $(k get pods -l app.kubernetes.io/name=aisix -o json \ + | python3 -c 'import json,sys; print(" ".join(p["status"].get("podIP","") for p in json.load(sys.stdin)["items"] if "deletionTimestamp" not in p["metadata"]))'); do + k exec curl -- curl -s "http://$ip:9090/status/config" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["applied"]["config_hash"])' + done +} + +log "kind cluster $CLUSTER" +if ! kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then + kind create cluster --name "$CLUSTER" --wait 120s + CREATED_CLUSTER=1 +fi +kubectl config use-context "kind-$CLUSTER" >/dev/null + +log "build and load $IMAGE" +docker build --build-arg AISIX_VERSION="${AISIX_TAG#v}" -t "$IMAGE" . +kind load docker-image "$IMAGE" --name "$CLUSTER" + +log "install CRDs and controller" +kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - +kubectl apply --server-side -k config/crd +kubectl wait --for=condition=Established crd/providerkeys.aisix.api7.ai crd/models.aisix.api7.ai crd/apikeys.aisix.api7.ai --timeout=60s +k apply -k config/controller +k set image deploy/aisix-controller controller="$IMAGE" +# The tag may be unchanged on a kept cluster; pick up the freshly loaded image. +k rollout restart deploy/aisix-controller +k rollout status deploy/aisix-controller --timeout=120s + +log "controller seeds the resources Secret" +for _ in $(seq 1 60); do k get secret aisix-resources >/dev/null 2>&1 && break; sleep 1; done +k get secret aisix-resources >/dev/null || fail "aisix-resources Secret was not created" + +log "mock upstream and curl client" +k apply -f "$MANIFESTS/mock-openai.yaml" +k run curl --image=curlimages/curl:8.11.1 --restart=Never --dry-run=client -o yaml --command -- sleep infinity | k apply -f - +k rollout status deploy/mock-openai --timeout=120s +k wait --for=condition=Ready pod/curl --timeout=120s + +log "AISIX $AISIX_CHART_VERSION from its Helm chart, standalone mode" +helm repo add api7 https://charts.api7.ai >/dev/null 2>&1 || true +helm repo update api7 >/dev/null +# AISIX_CHART may point at a local chart .tgz; otherwise it is downloaded. +CHART=${AISIX_CHART:-} +if [ -z "$CHART" ]; then + CHART_DIR=$(mktemp -d) + CHART="$CHART_DIR/aisix-$AISIX_CHART_VERSION.tgz" + for i in 1 2 3; do + helm pull api7/aisix --version "$AISIX_CHART_VERSION" -d "$CHART_DIR" && break + # The chart index points at GitHub release assets; fetch through the API instead. + if command -v gh >/dev/null && gh release download "aisix-$AISIX_CHART_VERSION" \ + --repo api7/api7-helm-chart -p "aisix-$AISIX_CHART_VERSION.tgz" -D "$CHART_DIR" --clobber; then + break + fi + [ "$i" = 3 ] && fail "could not download the aisix chart" + sleep $((i * 5)) + done +fi +helm upgrade --install aisix "$CHART" -n "$NS" \ + --set controlPlane.enabled=false \ + --set standalone.existingSecret=aisix-resources \ + --set fullnameOverride=aisix \ + --set replicaCount=2 \ + --wait --timeout 5m + +log "1. resources are applied and served" +k apply -f "$MANIFESTS/resources.yaml" +for r in providerkey/mock model/chat apikey/caller; do + wait_condition "${r%%/*}" "${r##*/}" Accepted True + wait_condition "${r%%/*}" "${r##*/}" Programmed True +done +expect_chat caller-e2e-key 200 'upstream-model=upstream-a auth=Bearer sk-e2e-$dollar' + +log "2. Programmed means every gateway pod reports the written file's hash" +want=$(served_hash) +got=$(pod_hashes | sort -u) +[ "$got" = "$want" ] || fail "pods serve [$got], the controller wrote $want" +k get model chat -o jsonpath='{.status.conditions[?(@.type=="Programmed")].message}' | grep -q 'served by 2 gateway pod' \ + || fail "Programmed message does not count the pods" +echo "ok: 2 pods serve $want" + +log "3. an update reaches the gateway" +k patch model chat --type=merge -p '{"spec":{"model_name":"upstream-b"}}' +wait_condition model chat Programmed True +expect_chat caller-e2e-key 200 'upstream-model=upstream-b' +[ "$(pod_hashes | sort -u)" = "$(served_hash)" ] || fail "pods do not serve the updated file" + +log "4. the CRD schema rejects unknown fields" +if k apply -f - 2>"$ERR" <<'YAML' +apiVersion: aisix.api7.ai/v1alpha1 +kind: Model +metadata: + name: bogus +spec: + no_such_field: 1 +YAML +then + fail "unknown field was accepted" +fi +grep -q 'no_such_field' "$ERR" || fail "unexpected error: $(cat "$ERR")" +echo "ok: $(cat "$ERR")" + +log "5. a change the gateway rejects keeps the served version" +# Passes the CRD schema; the gateway refuses a direct model that also routes. +before=$(served_hash) +k patch model chat --type=merge -p '{"spec":{"routing":{"targets":[{"model":"e2e/chat"}]}}}' +wait_condition model chat Accepted False +k get model chat -o jsonpath='{.status.conditions[?(@.type=="Accepted")].message}'; echo +[ "$(served_hash)" = "$before" ] || fail "a rejected change altered the served file" +expect_chat caller-e2e-key 200 'upstream-model=upstream-b' +k patch model chat --type=json -p '[{"op":"remove","path":"/spec/routing"}]' +wait_condition model chat Accepted True +wait_condition model chat Programmed True + +log "6. a dangling reference is reported" +k patch model chat --type=merge -p '{"spec":{"provider_key_ref":{"name":"ghost"}}}' +wait_condition model chat Accepted False +k get model chat -o jsonpath='{.status.conditions[?(@.type=="Accepted")].message}' | grep -q 'ghost' \ + || fail "Accepted message does not name the missing ProviderKey" +expect_chat caller-e2e-key 200 'upstream-model=upstream-b' +k patch model chat --type=merge -p '{"spec":{"provider_key_ref":{"name":"mock"}}}' +wait_condition model chat Programmed True + +log "7. deleting the APIKey revokes it" +k delete apikey caller +expect_chat caller-e2e-key 401 + +log "8. printer columns" +# The APIKey deletion rolled the gateway again. +wait_condition model chat Programmed True +k get models +k get models chat --no-headers | grep -qE 'True +True' || fail "Accepted/Programmed columns missing" + +log "PASS" From 0ecd252effc7bf70b9e694a6a7bf9a640603e7ca Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:32:41 +0545 Subject: [PATCH 10/14] ci: run lint, generated-code drift, unit, envtest and e2e --- .github/workflows/ci.yaml | 45 +++++++++++++++++++++++++++++++++++++++ .golangci.yml | 6 ++++++ 2 files changed, 51 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 .golangci.yml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..cda77e6 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: gofmt + run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; } + - name: lint + run: make lint + - name: generated code and CRDs are current + run: make crds-check + - name: unit and envtest + run: make test + - name: unit tests against the aisix binary + run: make test-aisix + + e2e: + needs: check + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: helm/kind-action@v1 + with: + install_only: true + - uses: azure/setup-helm@v4 + - name: e2e + run: test/e2e/run.sh + env: + # The chart download falls back to `gh release download`. + GH_TOKEN: ${{ github.token }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1e49c6c --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,6 @@ +version: "2" +linters: + default: standard +formatters: + enable: + - gofmt From 9e43c9ddc80782183a79a231502f380f57992396 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 10:32:41 +0545 Subject: [PATCH 11/14] docs: add README --- README.md | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..c90b1c7 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# aisix-controller + +A Kubernetes controller for the [AISIX](https://github.com/api7/aisix) AI gateway. It reconciles AISIX custom resources into a standalone gateway's resources file, so models, provider credentials and caller keys are managed with `kubectl apply`. + +## How it works + +``` +ProviderKey / Model / APIKey + Secrets + │ + ▼ +aisix-controller ── render ── aisix validate (bundled binary) ──► Secret aisix-resources + │ │ mounted by the chart + │ stamps aisix.api7.ai/resources-hash on the pod template ▼ + └──────────────────────────────────────────────────► gateway Deployment rolls + ▲ │ + └──── GET :9090/status/config (applied.config_hash) ◄──┘ +``` + +1. Every change to a custom resource, a referenced Secret, the gateway Deployment or its pods re-renders the whole resources file (`_format_version: "1"`). +2. The document is checked with `aisix validate` from the same aisix release, run with an empty environment. A resource the gateway would reject keeps its previously served entry, or is left out if it was never served. The Secret never holds a file the gateway cannot load, which matters because a gateway pod with an invalid file fails to start. +3. A changed document is written to the `aisix-resources` Secret, and its SHA-256 is stamped on the gateway Deployment's pod template. The gateway reads its resources file at boot and on SIGHUP only, so the stamp rolls the pods. +4. `Programmed` turns true once every ready gateway pod reports the written file's hash as `applied.config_hash` on its status listener. + +## Resources + +All kinds are namespaced, in `aisix.api7.ai/v1alpha1`. A `spec` is the AISIX resource document with AISIX's own snake_case field names, minus credentials and id-form references, plus the references below. `display_name` defaults to `metadata.name`; set it when the name is not a valid Kubernetes name (for example `openai/gpt-4o`). + +| Kind | Short name | Reference fields | +|---|---|---| +| `ProviderKey` | `aipk` | `api_key_secret_ref: {name, key}`: the upstream credential | +| `Model` | `aimodel` | `provider_key_ref: {name}`: a ProviderKey's `metadata.name` (direct models only) | +| `APIKey` | `aikey` | `key_secret_ref: {name, key}`: the caller's plaintext key; only its SHA-256 is written | + +```yaml +apiVersion: aisix.api7.ai/v1alpha1 +kind: ProviderKey +metadata: + name: openai +spec: + provider: openai + api_key_secret_ref: {name: llm-creds, key: openai} +--- +apiVersion: aisix.api7.ai/v1alpha1 +kind: Model +metadata: + name: gpt-4o +spec: + display_name: openai/gpt-4o + provider: openai + model_name: gpt-4o + provider_key_ref: {name: openai} +--- +apiVersion: aisix.api7.ai/v1alpha1 +kind: APIKey +metadata: + name: team-a +spec: + allowed_models: ["openai/gpt-4o"] + key_secret_ref: {name: caller-keys, key: team-a} +``` + +Conditions: + +| Condition | True when | +|---|---| +| `Accepted` | The current spec is valid for the gateway | +| `Programmed` | Every ready gateway pod serves a resources file containing the current spec | + +The CRD schemas are generated from the strict JSON Schemas of the aisix release in `third_party/aisix/VERSION`. Rules a structural schema cannot hold (`oneOf` branches, `if`/`then`) are still enforced by `aisix validate`, and show up as `Accepted=False`. + +## Install + +The controller and the gateway run in the same namespace. Install the controller first, so the resources Secret exists: + +```bash +kubectl apply --server-side -k config/crd +kubectl -n aisix apply -k config/controller + +helm install aisix aisix --repo https://charts.api7.ai -n aisix \ + --set controlPlane.enabled=false \ + --set standalone.existingSecret=aisix-resources \ + --set fullnameOverride=aisix +``` + +Flags (see `aisix-controller --help`): `--secret`, `--secret-key`, `--deployment`, `--gateway-status-port` (the chart's metrics port, default 9090), `--aisix-binary`, `--resync`, `--poll`, `--leader-elect`. + +The controller reads each gateway pod's `/status/config` on the metrics port, which the gateway serves without authentication. Keep the chart's `metrics.enabled` on, and allow controller-to-gateway traffic on that port if a NetworkPolicy applies. + +## Limitations in v0.1 + +- Every configuration change restarts the gateway pods, at least `preStopSleepSeconds` (30 s by default) per batch, and resets in-memory rate-limit counters. +- One gateway Deployment per controller, and one namespace. +- No admission webhook: invalid specs are reported through `Accepted=False`. +- The resources file must fit in one Secret (1 MiB). +- Kinds beyond ProviderKey, Model and APIKey are not supported yet. + +## Development + +```bash +make build +make test # unit + envtest suites +make test-aisix # unit tests against the real aisix binary (Docker) +make lint +make crds # regenerate config/crd from third_party/aisix +make crds-check # fail if generated code or CRDs are stale +make e2e # kind cluster + AISIX chart + controller +``` + +To move to another aisix release: `make sync-schemas AISIX_TAG=v1.x.y && make crds`, then bump `AISIX_VERSION` in the Dockerfile. From f5013f43dc6fb606dc5cba070e974a1885236cb3 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 14:30:38 +0545 Subject: [PATCH 12/14] fix: refuse references to the generated resources Secret An APIKey or ProviderKey that pointed at the controller's own Secret read the generated file back as a credential, so every write changed the next one and the gateway rolled forever. The Secret is now kept out of credential sources and such references are rejected. --- internal/controller/reconciler.go | 6 +++-- internal/controller/reconciler_test.go | 34 ++++++++++++++++++++++++++ internal/render/assemble_test.go | 4 +-- internal/render/render.go | 20 +++++++++------ internal/render/render_test.go | 32 ++++++++++++++++++------ 5 files changed, 78 insertions(+), 18 deletions(-) diff --git a/internal/controller/reconciler.go b/internal/controller/reconciler.go index 66284ab..1a87b66 100644 --- a/internal/controller/reconciler.go +++ b/internal/controller/reconciler.go @@ -97,14 +97,16 @@ func (r *Reconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result var target *corev1.Secret for i := range secrets.Items { s := &secrets.Items[i] - secretData[s.Name] = s.Data if s.Name == r.Secret { + // The generated file is never a credential source. target = s current = s.Data[r.SecretKey] + continue } + secretData[s.Name] = s.Data } - rendered := render.All(sources, secretData) + rendered := render.All(sources, secretData, r.Secret) for i := range rendered { if msg, ok := decodeErrs[rendered[i].Source.Key()]; ok { rendered[i].Entry, rendered[i].Err = nil, msg diff --git a/internal/controller/reconciler_test.go b/internal/controller/reconciler_test.go index e86e259..b872bc4 100644 --- a/internal/controller/reconciler_test.go +++ b/internal/controller/reconciler_test.go @@ -321,6 +321,40 @@ func TestReconcileServesResourcesAndReportsStatus(t *testing.T) { } } +func TestSelfReferenceDoesNotFeedBack(t *testing.T) { + needEnv(t) + ns := newNamespace(t) + r := newReconciler(ns, &fakeStatus{hashes: map[string]string{}}) + reconcileOnce(t, r) + + loop := &v1alpha1.APIKey{ + ObjectMeta: metav1.ObjectMeta{Name: "loop", Namespace: ns}, + Spec: spec(t, map[string]any{"allowed_models": []any{"*"}, + v1alpha1.KeySecretRef: map[string]any{"name": "aisix-resources", "key": "resources.yaml"}}), + } + create(t, loop) + reconcileOnce(t, r) + var before corev1.Secret + if err := testClient.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: "aisix-resources"}, &before); err != nil { + t.Fatal(err) + } + reconcileOnce(t, r) + reconcileOnce(t, r) + var after corev1.Secret + if err := testClient.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: "aisix-resources"}, &after); err != nil { + t.Fatal(err) + } + if after.ResourceVersion != before.ResourceVersion { + t.Error("the resources Secret keeps changing") + } + if !bytes.Equal(after.Data["resources.yaml"], render.Document(nil)) { + t.Errorf("served = %s", after.Data["resources.yaml"]) + } + if c := conditionOf(t, loop, CondAccepted); c.Status != metav1.ConditionFalse || !strings.Contains(c.Message, "holds the generated resources file") { + t.Errorf("Accepted = %+v", c) + } +} + func TestManagerReconcilesOnStartAndOnChange(t *testing.T) { needEnv(t) ns := newNamespace(t) diff --git a/internal/render/assemble_test.go b/internal/render/assemble_test.go index c5c61f7..9dbf092 100644 --- a/internal/render/assemble_test.go +++ b/internal/render/assemble_test.go @@ -95,7 +95,7 @@ func apiKey() Source { func assemble(t *testing.T, v validate.Validator, sources []Source, served []byte) Outcome { t.Helper() - out, err := Assemble(context.Background(), All(sources, testSecrets()), ServedEntries(served), v) + out, err := Assemble(context.Background(), All(sources, testSecrets(), ""), ServedEntries(served), v) if err != nil { t.Fatal(err) } @@ -218,7 +218,7 @@ func TestWholeDocumentErrorsAreNotAttributed(t *testing.T) { broken := validate.Func(func(ctx context.Context, doc []byte) ([]validate.LoadError, error) { return []validate.LoadError{{Scope: "(file)", Message: "boom"}}, nil }) - out, err := Assemble(context.Background(), All([]Source{provider()}, testSecrets()), Served{}, broken) + out, err := Assemble(context.Background(), All([]Source{provider()}, testSecrets(), ""), Served{}, broken) if err != nil { t.Fatal(err) } diff --git a/internal/render/render.go b/internal/render/render.go index 6c2759e..2c8a0e8 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -74,8 +74,11 @@ func sortSources(sources []Source) []Source { return out } -// All renders every source, in file order. -func All(sources []Source, secrets Secrets) []Rendered { +// All renders every source, in file order. reserved names the Secret the +// resources file is written to; references to it are refused, since +// reading the generated file back as a credential would feed every write +// into the next one. +func All(sources []Source, secrets Secrets, reserved string) []Rendered { ordered := sortSources(sources) providerIdentities := map[string]string{} @@ -98,7 +101,7 @@ func All(sources []Source, secrets Secrets) []Rendered { r.Err = fmt.Sprintf("%s %q is already used by %s/%s", v1alpha1.IdentityField, s.Identity(), s.Kind, owner) } else { seen[id] = s.Name - entry, err := one(s, secrets, providerIdentities) + entry, err := one(s, secrets, reserved, providerIdentities) if err != nil { r.Err = err.Error() } else { @@ -110,7 +113,7 @@ func All(sources []Source, secrets Secrets) []Rendered { return out } -func one(s Source, secrets Secrets, providerIdentities map[string]string) (Entry, error) { +func one(s Source, secrets Secrets, reserved string, providerIdentities map[string]string) (Entry, error) { fields := make(Entry, len(s.Fields)+1) for k, v := range s.Fields { fields[k] = v @@ -129,7 +132,7 @@ func one(s Source, secrets Secrets, providerIdentities map[string]string) (Entry return nil, fmt.Errorf("%s is required", v1alpha1.APIKeySecretRef) } delete(fields, v1alpha1.APIKeySecretRef) - value, err := secretValue(ref, secrets, v1alpha1.APIKeySecretRef) + value, err := secretValue(ref, secrets, reserved, v1alpha1.APIKeySecretRef) if err != nil { return nil, err } @@ -160,7 +163,7 @@ func one(s Source, secrets Secrets, providerIdentities map[string]string) (Entry return nil, fmt.Errorf("%s is required", v1alpha1.KeySecretRef) } delete(fields, v1alpha1.KeySecretRef) - plaintext, err := secretValue(ref, secrets, v1alpha1.KeySecretRef) + plaintext, err := secretValue(ref, secrets, reserved, v1alpha1.KeySecretRef) if err != nil { return nil, err } @@ -177,7 +180,7 @@ func HashKey(plaintext string) string { return hex.EncodeToString(sum[:]) } -func secretValue(ref any, secrets Secrets, field string) (string, error) { +func secretValue(ref any, secrets Secrets, reserved, field string) (string, error) { m, _ := ref.(map[string]any) name, _ := m["name"].(string) key, _ := m["key"].(string) @@ -187,6 +190,9 @@ func secretValue(ref any, secrets Secrets, field string) (string, error) { if key == "" { return "", fmt.Errorf("%s.key is required", field) } + if reserved != "" && name == reserved { + return "", fmt.Errorf("%s: Secret %q holds the generated resources file and cannot be referenced", field, name) + } data, ok := secrets[name] if !ok { return "", fmt.Errorf("%s: Secret %q not found", field, name) diff --git a/internal/render/render_test.go b/internal/render/render_test.go index e04fc12..a968bbb 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -37,7 +37,7 @@ func TestProviderKeyTakesCredentialFromSecretAndEscapesIt(t *testing.T) { "provider": "openai", "api_base": "http://x/${not_env}", v1alpha1.APIKeySecretRef: ref("creds", "openai"), - })}, testSecrets())) + })}, testSecrets(), "")) r := out["ProviderKey/openai"] if r.Err != "" { t.Fatal(r.Err) @@ -66,7 +66,7 @@ func TestModelReferenceResolvesToProviderIdentity(t *testing.T) { v1alpha1.IdentityField: "openai/gpt", v1alpha1.ProviderKeyRef: map[string]any{"name": "pk"}, }), - }, testSecrets())) + }, testSecrets(), "")) m := out["Model/m"] if m.Err != "" { t.Fatal(m.Err) @@ -79,7 +79,7 @@ func TestModelReferenceResolvesToProviderIdentity(t *testing.T) { func TestAPIKeyIsHashed(t *testing.T) { out := byKey(All([]Source{src(v1alpha1.KindAPIKey, "k", map[string]any{ v1alpha1.KeySecretRef: ref("creds", "caller"), - })}, testSecrets())) + })}, testSecrets(), "")) k := out["APIKey/k"] if k.Err != "" { t.Fatal(k.Err) @@ -104,7 +104,7 @@ func TestMissingReferencesAreErrors(t *testing.T) { src(v1alpha1.KindProviderKey, "unset", map[string]any{}), src(v1alpha1.KindModel, "dangling", map[string]any{v1alpha1.ProviderKeyRef: map[string]any{"name": "ghost"}}), src(v1alpha1.KindAPIKey, "keyless", map[string]any{}), - }, testSecrets())) + }, testSecrets(), "")) want := map[string]string{ "ProviderKey/no-secret": `Secret "nope" not found`, "ProviderKey/no-key": `has no key "nope"`, @@ -126,7 +126,7 @@ func TestInlineCredentialsAreRefused(t *testing.T) { src(v1alpha1.KindProviderKey, "b", map[string]any{"secret": "x", v1alpha1.APIKeySecretRef: ref("creds", "openai")}), src(v1alpha1.KindModel, "c", map[string]any{"provider_key_id": "x"}), src(v1alpha1.KindAPIKey, "d", map[string]any{"key_hash": "x", v1alpha1.KeySecretRef: ref("creds", "caller")}), - }, testSecrets())) + }, testSecrets(), "")) for _, k := range []string{"ProviderKey/a", "ProviderKey/b", "Model/c", "APIKey/d"} { if out[k].Err == "" { t.Errorf("%s was accepted", k) @@ -138,7 +138,7 @@ func TestDuplicateIdentityLosesToOlderResource(t *testing.T) { older := src(v1alpha1.KindModel, "zzz", map[string]any{v1alpha1.IdentityField: "same"}) newer := src(v1alpha1.KindModel, "aaa", map[string]any{v1alpha1.IdentityField: "same"}) newer.Created = t0.Add(time.Minute) - out := byKey(All([]Source{newer, older}, testSecrets())) + out := byKey(All([]Source{newer, older}, testSecrets(), "")) if out["Model/zzz"].Err != "" { t.Errorf("older lost: %s", out["Model/zzz"].Err) } @@ -155,7 +155,7 @@ func TestOrderIsKindThenAgeThenName(t *testing.T) { key := src(v1alpha1.KindAPIKey, "k", map[string]any{}) pk := src(v1alpha1.KindProviderKey, "p", map[string]any{}) var got []string - for _, r := range All([]Source{key, late, b, a, pk}, testSecrets()) { + for _, r := range All([]Source{key, late, b, a, pk}, testSecrets(), "") { got = append(got, r.Source.Key().String()) } want := "ProviderKey/p Model/a Model/b Model/0 APIKey/k" @@ -174,3 +174,21 @@ func TestEscapeDollarsReachesEveryString(t *testing.T) { t.Error("input was mutated") } } + +func TestReferencesToTheResourcesSecretAreRefused(t *testing.T) { + secrets := testSecrets() + secrets["aisix-resources"] = map[string][]byte{"resources.yaml": []byte(`{"_format_version": "1"}`)} + out := byKey(All([]Source{ + src(v1alpha1.KindAPIKey, "loop", map[string]any{v1alpha1.KeySecretRef: ref("aisix-resources", "resources.yaml")}), + src(v1alpha1.KindProviderKey, "grow", map[string]any{v1alpha1.APIKeySecretRef: ref("aisix-resources", "other")}), + src(v1alpha1.KindAPIKey, "fine", map[string]any{v1alpha1.KeySecretRef: ref("creds", "caller")}), + }, secrets, "aisix-resources")) + for _, k := range []string{"APIKey/loop", "ProviderKey/grow"} { + if !strings.Contains(out[k].Err, "holds the generated resources file") { + t.Errorf("%s: err = %q", k, out[k].Err) + } + } + if out["APIKey/fine"].Err != "" { + t.Errorf("unrelated reference refused: %s", out["APIKey/fine"].Err) + } +} From 78b7d98df237bea63a6a875a1e3e45700633af29 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 14:30:51 +0545 Subject: [PATCH 13/14] fix: resolve provider_key_ref only to served ProviderKeys References were resolved through every ProviderKey's display_name before duplicates were rejected, so a Model pointing at the losing ProviderKey of a shared display_name was accepted and silently used the winner's credential. Such references are now rejected and name both ProviderKeys. --- internal/render/render.go | 49 +++++++++++++++++++++++----------- internal/render/render_test.go | 24 +++++++++++++++++ 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/internal/render/render.go b/internal/render/render.go index 2c8a0e8..a6db668 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -81,27 +81,35 @@ func sortSources(sources []Source) []Source { func All(sources []Source, secrets Secrets, reserved string) []Rendered { ordered := sortSources(sources) - providerIdentities := map[string]string{} + type identity struct { + kind v1alpha1.Kind + id string + } + // The oldest resource wins a shared identity; the rest are not served. + owners := map[identity]string{} for _, s := range ordered { - if s.Kind == v1alpha1.KindProviderKey { - providerIdentities[s.Name] = s.Identity() + id := identity{s.Kind, s.Identity()} + if _, taken := owners[id]; !taken { + owners[id] = s.Name } } + owner := func(s Source) string { return owners[identity{s.Kind, s.Identity()}] } - type identity struct { - kind v1alpha1.Kind - id string + // Model references resolve only to ProviderKeys that are served. + providers := map[string]providerRef{} + for _, s := range ordered { + if s.Kind == v1alpha1.KindProviderKey { + providers[s.Name] = providerRef{identity: s.Identity(), owner: owner(s)} + } } - seen := map[identity]string{} + out := make([]Rendered, 0, len(ordered)) for _, s := range ordered { r := Rendered{Source: s} - id := identity{s.Kind, s.Identity()} - if owner, dup := seen[id]; dup { - r.Err = fmt.Sprintf("%s %q is already used by %s/%s", v1alpha1.IdentityField, s.Identity(), s.Kind, owner) + if o := owner(s); o != s.Name { + r.Err = fmt.Sprintf("%s %q is already used by %s/%s", v1alpha1.IdentityField, s.Identity(), s.Kind, o) } else { - seen[id] = s.Name - entry, err := one(s, secrets, reserved, providerIdentities) + entry, err := one(s, secrets, reserved, providers) if err != nil { r.Err = err.Error() } else { @@ -113,7 +121,14 @@ func All(sources []Source, secrets Secrets, reserved string) []Rendered { return out } -func one(s Source, secrets Secrets, reserved string, providerIdentities map[string]string) (Entry, error) { +// providerRef is what a Model's provider_key_ref resolves through. +type providerRef struct { + identity string + // owner is the ProviderKey that holds the identity in the file. + owner string +} + +func one(s Source, secrets Secrets, reserved string, providers map[string]providerRef) (Entry, error) { fields := make(Entry, len(s.Fields)+1) for k, v := range s.Fields { fields[k] = v @@ -148,11 +163,15 @@ func one(s Source, secrets Secrets, reserved string, providerIdentities map[stri if name == "" { return nil, fmt.Errorf("%s.name is required", v1alpha1.ProviderKeyRef) } - identity, ok := providerIdentities[name] + p, ok := providers[name] if !ok { return nil, fmt.Errorf("%s names ProviderKey %q, which does not exist", v1alpha1.ProviderKeyRef, name) } - fields["provider_key"] = identity + if p.owner != name { + return nil, fmt.Errorf("%s names ProviderKey %q, which is not served: %s %q is already used by ProviderKey/%s", + v1alpha1.ProviderKeyRef, name, v1alpha1.IdentityField, p.identity, p.owner) + } + fields["provider_key"] = p.identity } case v1alpha1.KindAPIKey: if _, ok := fields["key_hash"]; ok { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index a968bbb..029ed26 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -192,3 +192,27 @@ func TestReferencesToTheResourcesSecretAreRefused(t *testing.T) { t.Errorf("unrelated reference refused: %s", out["APIKey/fine"].Err) } } + +func TestModelCannotReferenceALosingProviderKey(t *testing.T) { + prod := src(v1alpha1.KindProviderKey, "openai-prod", map[string]any{ + v1alpha1.IdentityField: "openai", v1alpha1.APIKeySecretRef: ref("creds", "openai"), + }) + test := src(v1alpha1.KindProviderKey, "openai-test", map[string]any{ + v1alpha1.IdentityField: "openai", v1alpha1.APIKeySecretRef: ref("creds", "caller"), + }) + test.Created = t0.Add(time.Minute) + out := byKey(All([]Source{ + test, prod, + src(v1alpha1.KindModel, "cheap", map[string]any{v1alpha1.ProviderKeyRef: map[string]any{"name": "openai-test"}}), + src(v1alpha1.KindModel, "main", map[string]any{v1alpha1.ProviderKeyRef: map[string]any{"name": "openai-prod"}}), + }, testSecrets(), "")) + + cheap := out["Model/cheap"] + if cheap.Entry != nil || !strings.Contains(cheap.Err, `ProviderKey "openai-test", which is not served`) || + !strings.Contains(cheap.Err, "ProviderKey/openai-prod") { + t.Errorf("cheap: entry=%v err=%q", cheap.Entry, cheap.Err) + } + if m := out["Model/main"]; m.Err != "" || m.Entry["provider_key"] != "openai" { + t.Errorf("main: entry=%v err=%q", m.Entry, m.Err) + } +} From ca59be55213cfb859eb3c32152abfaf025dec7a1 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 10 Nov 2026 14:33:16 +0545 Subject: [PATCH 14/14] fix: keep a resource's served entry across display_name changes A rejected update fell back to the served entry found under its new display_name, so an edit that both renamed a resource and carried a rejected value dropped the working entry. The Secret now stores an index of which resource owns each entry, and fallbacks look up the resource. A new resource can no longer inherit a deleted resource's entry by sharing its display_name. --- README.md | 4 +- internal/controller/reconciler.go | 22 ++++--- internal/controller/reconciler_test.go | 14 ++++- internal/render/assemble.go | 82 ++++++++++++++++++++------ internal/render/assemble_test.go | 74 +++++++++++++++++++---- 5 files changed, 157 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index c90b1c7..2e88cde 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ aisix-controller ── render ── aisix validate (bundled binary) ──► 1. Every change to a custom resource, a referenced Secret, the gateway Deployment or its pods re-renders the whole resources file (`_format_version: "1"`). 2. The document is checked with `aisix validate` from the same aisix release, run with an empty environment. A resource the gateway would reject keeps its previously served entry, or is left out if it was never served. The Secret never holds a file the gateway cannot load, which matters because a gateway pod with an invalid file fails to start. -3. A changed document is written to the `aisix-resources` Secret, and its SHA-256 is stamped on the gateway Deployment's pod template. The gateway reads its resources file at boot and on SIGHUP only, so the stamp rolls the pods. +3. A changed document is written to the `aisix-resources` Secret, next to `aisix-controller.index.json`, which records the resource each entry belongs to so a resource's served entry is found even after its `display_name` changes. The document's SHA-256 is stamped on the gateway Deployment's pod template. The gateway reads its resources file at boot and on SIGHUP only, so the stamp rolls the pods. 4. `Programmed` turns true once every ready gateway pod reports the written file's hash as `applied.config_hash` on its status listener. ## Resources @@ -31,6 +31,8 @@ All kinds are namespaced, in `aisix.api7.ai/v1alpha1`. A `spec` is the AISIX res | `Model` | `aimodel` | `provider_key_ref: {name}`: a ProviderKey's `metadata.name` (direct models only) | | `APIKey` | `aikey` | `key_secret_ref: {name, key}`: the caller's plaintext key; only its SHA-256 is written | +Secret refs may name any Secret in the namespace except the controller's own `aisix-resources`. A `provider_key_ref` must name a ProviderKey that is served; one that lost a shared `display_name` to an older ProviderKey is rejected. + ```yaml apiVersion: aisix.api7.ai/v1alpha1 kind: ProviderKey diff --git a/internal/controller/reconciler.go b/internal/controller/reconciler.go index 1a87b66..dcd8a88 100644 --- a/internal/controller/reconciler.go +++ b/internal/controller/reconciler.go @@ -33,6 +33,10 @@ import ( // FieldManager names the controller in managed fields and labels. const FieldManager = "aisix-controller" +// IndexKey holds, next to the resources file, which resource owns each of +// its entries. The gateway reads only the resources file. +const IndexKey = "aisix-controller.index.json" + // Reconciler writes the resources file of one gateway. type Reconciler struct { Client client.Client @@ -93,7 +97,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result } secretData := render.Secrets{} - var current []byte + var current, currentIndex []byte var target *corev1.Secret for i := range secrets.Items { s := &secrets.Items[i] @@ -101,6 +105,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result // The generated file is never a credential source. target = s current = s.Data[r.SecretKey] + currentIndex = s.Data[IndexKey] continue } secretData[s.Name] = s.Data @@ -112,7 +117,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result rendered[i].Entry, rendered[i].Err = nil, msg } } - outcome, err := render.Assemble(ctx, rendered, render.ServedEntries(current), r.Validator) + outcome, err := render.Assemble(ctx, rendered, render.ServedEntries(current, currentIndex), r.Validator) if err != nil { return ctrl.Result{}, err } @@ -121,13 +126,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result } // A gateway needs a file to start, so seed an empty one. - desired := outcome.Document + desired, desiredIndex := outcome.Document, outcome.Index if desired == nil && target == nil { - desired = render.Document(nil) + desired, desiredIndex = render.Document(nil), render.Index(nil) } served := current - if desired != nil && (target == nil || !bytes.Equal(current, desired)) { - if err := r.writeSecret(ctx, target, desired); err != nil { + if desired != nil && (target == nil || !bytes.Equal(current, desired) || !bytes.Equal(currentIndex, desiredIndex)) { + if err := r.writeSecret(ctx, target, desired, desiredIndex); err != nil { return ctrl.Result{}, err } logger.Info("resources file written", "hash", render.Hash(desired)[:12]) @@ -160,7 +165,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result return ctrl.Result{RequeueAfter: r.Resync}, nil } -func (r *Reconciler) writeSecret(ctx context.Context, existing *corev1.Secret, doc []byte) error { +func (r *Reconciler) writeSecret(ctx context.Context, existing *corev1.Secret, doc, index []byte) error { labels := map[string]string{"app.kubernetes.io/managed-by": FieldManager} annotations := map[string]string{v1alpha1.HashAnnotation: render.Hash(doc)} if existing == nil { @@ -169,7 +174,7 @@ func (r *Reconciler) writeSecret(ctx context.Context, existing *corev1.Secret, d Name: r.Secret, Namespace: r.Namespace, Labels: labels, Annotations: annotations, }, Type: corev1.SecretTypeOpaque, - Data: map[string][]byte{r.SecretKey: doc}, + Data: map[string][]byte{r.SecretKey: doc, IndexKey: index}, } if err := r.Client.Create(ctx, s, client.FieldOwner(FieldManager)); err != nil { return fmt.Errorf("creating Secret %s: %w", r.Secret, err) @@ -193,6 +198,7 @@ func (r *Reconciler) writeSecret(ctx context.Context, existing *corev1.Secret, d s.Data = map[string][]byte{} } s.Data[r.SecretKey] = doc + s.Data[IndexKey] = index if err := r.Client.Update(ctx, s, client.FieldOwner(FieldManager)); err != nil { return fmt.Errorf("updating Secret %s: %w", r.Secret, err) } diff --git a/internal/controller/reconciler_test.go b/internal/controller/reconciler_test.go index b872bc4..5f9eef9 100644 --- a/internal/controller/reconciler_test.go +++ b/internal/controller/reconciler_test.go @@ -290,11 +290,21 @@ func TestReconcileServesResourcesAndReportsStatus(t *testing.T) { t.Errorf("settled state should resync, got %v", res.RequeueAfter) } - // A change the gateway rejects keeps the served version. + // The index records which resource owns each served entry. + var sec corev1.Secret + if err := testClient.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: "aisix-resources"}, &sec); err != nil { + t.Fatal(err) + } + if got := string(sec.Data[IndexKey]); !strings.Contains(got, `"Model/chat":"e2e/chat"`) { + t.Errorf("index = %s", got) + } + + // A change the gateway rejects keeps the served version, even when the + // same edit renames the resource. if err := testClient.Get(context.Background(), client.ObjectKeyFromObject(model), model); err != nil { t.Fatal(err) } - model.Spec = spec(t, map[string]any{"display_name": "e2e/chat", "provider": "openai", "model_name": "reject-me", + model.Spec = spec(t, map[string]any{"display_name": "renamed", "provider": "openai", "model_name": "reject-me", v1alpha1.ProviderKeyRef: map[string]any{"name": "mock"}}) if err := testClient.Update(context.Background(), model); err != nil { t.Fatal(err) diff --git a/internal/render/assemble.go b/internal/render/assemble.go index adffb41..8634e78 100644 --- a/internal/render/assemble.go +++ b/internal/render/assemble.go @@ -36,7 +36,12 @@ const ( // Outcome of assembling one document. type Outcome struct { // Document to serve; nil when no valid one could be made. - Document []byte + Document []byte + // Index records which resource owns each entry of Document, keyed + // "Kind/name", valued with the entry's identity. It is stored next to + // the document so a resource's served entry is found even after its + // display_name changes. + Index []byte Dispositions map[Key]Disposition // Error is set when the document as a whole failed. Error string @@ -65,6 +70,19 @@ func Document(entries map[v1alpha1.Kind][]Entry) []byte { return buf.Bytes() } +// Index renders a resource-to-identity index. encoding/json sorts keys, so +// equal inputs give equal bytes. +func Index(owners map[string]string) []byte { + if owners == nil { + owners = map[string]string{} + } + b, err := json.Marshal(owners) + if err != nil { + panic(fmt.Sprintf("resources index does not encode: %v", err)) + } + return append(b, '\n') +} + // Hash is what the gateway reports as applied.config_hash in file mode: // SHA-256 of the raw file bytes, lowercase hex. func Hash(document []byte) string { @@ -77,14 +95,17 @@ type servedKey struct { identity string } -// Served indexes the entries of the currently served document. -type Served map[servedKey]Entry +// Served is the currently served document, indexed by owning resource. +type Served struct { + entries map[servedKey]Entry + owners map[Key]string +} -// ServedEntries parses a served document. Anything unreadable counts as -// nothing served. -func ServedEntries(document []byte) Served { - out := Served{} - if len(document) == 0 { +// ServedEntries parses a served document and its index. Anything unreadable +// counts as nothing served. +func ServedEntries(document, index []byte) Served { + out := Served{entries: map[servedKey]Entry{}, owners: map[Key]string{}} + if len(document) == 0 || len(index) == 0 { return out } dec := json.NewDecoder(bytes.NewReader(document)) @@ -93,6 +114,10 @@ func ServedEntries(document []byte) Served { if err := dec.Decode(&root); err != nil { return out } + var owners map[string]string + if err := json.Unmarshal(index, &owners); err != nil { + return out + } for _, k := range v1alpha1.Kinds { items, _ := root[k.Collection()].([]any) for _, item := range items { @@ -101,16 +126,28 @@ func ServedEntries(document []byte) Served { continue } if id, ok := e[v1alpha1.IdentityField].(string); ok { - out[servedKey{k, strings.ReplaceAll(id, "$$", "$")}] = e + out.entries[servedKey{k, strings.ReplaceAll(id, "$$", "$")}] = e } } } + for key, id := range owners { + kind, name, ok := strings.Cut(key, "/") + if !ok { + continue + } + out.owners[Key{v1alpha1.Kind(kind), name}] = id + } return out } -func (s Served) get(src Source) (Entry, bool) { - e, ok := s[servedKey{src.Kind, src.Identity()}] - return e, ok +// get returns the entry a resource is served with, and its identity. +func (s Served) get(src Source) (Entry, string, bool) { + id, ok := s.owners[src.Key()] + if !ok { + return nil, "", false + } + e, ok := s.entries[servedKey{src.Kind, id}] + return e, id, ok } // ParseScope maps `models[2] ("gpt")` to (Model, 2). @@ -138,7 +175,9 @@ type item struct { source Source state State entry Entry - reason string + // identity the entry is served under. + identity string + reason string } // Assemble builds the document to serve. An entry the gateway rejects is @@ -147,14 +186,14 @@ type item struct { func Assemble(ctx context.Context, rendered []Rendered, served Served, v validate.Validator) (Outcome, error) { items := make([]*item, len(rendered)) fallback := func(it *item, reason string) { - if e, ok := served.get(it.source); ok { - it.state, it.entry, it.reason = Retained, e, reason + if e, id, ok := served.get(it.source); ok { + it.state, it.entry, it.identity, it.reason = Retained, e, id, reason } else { - it.state, it.entry, it.reason = Rejected, nil, reason + it.state, it.entry, it.identity, it.reason = Rejected, nil, "", reason } } for i, r := range rendered { - it := &item{source: r.Source, state: Applied, entry: r.Entry} + it := &item{source: r.Source, state: Applied, entry: r.Entry, identity: r.Source.Identity()} if r.Err != "" { fallback(it, r.Err) } @@ -205,7 +244,7 @@ func Assemble(ctx context.Context, rendered []Rendered, served Served, v validat case Applied: fallback(it, reason) case Retained: - it.state, it.entry = Rejected, nil + it.state, it.entry, it.identity = Rejected, nil, "" it.reason = it.reason + "; the previously served version is no longer valid either: " + reason } } @@ -215,8 +254,15 @@ func Assemble(ctx context.Context, rendered []Rendered, served Served, v validat func finish(items []*item, doc []byte, errMsg string) Outcome { out := Outcome{Document: doc, Dispositions: map[Key]Disposition{}, Error: errMsg} + owners := map[string]string{} for _, it := range items { out.Dispositions[it.source.Key()] = Disposition{State: it.state, Reason: it.reason} + if it.state != Rejected { + owners[it.source.Key().String()] = it.identity + } + } + if doc != nil { + out.Index = Index(owners) } return out } diff --git a/internal/render/assemble_test.go b/internal/render/assemble_test.go index 9dbf092..7a50ccd 100644 --- a/internal/render/assemble_test.go +++ b/internal/render/assemble_test.go @@ -93,9 +93,14 @@ func apiKey() Source { }) } -func assemble(t *testing.T, v validate.Validator, sources []Source, served []byte) Outcome { +// assemble builds a document, given what the previous outcome serves. +func assemble(t *testing.T, v validate.Validator, sources []Source, prev *Outcome) Outcome { t.Helper() - out, err := Assemble(context.Background(), All(sources, testSecrets(), ""), ServedEntries(served), v) + served := ServedEntries(nil, nil) + if prev != nil { + served = ServedEntries(prev.Document, prev.Index) + } + out, err := Assemble(context.Background(), All(sources, testSecrets(), ""), served, v) if err != nil { t.Fatal(err) } @@ -168,7 +173,7 @@ func TestRejectedUpdateKeepsTheServedVersion(t *testing.T) { edited := model("chat", "mock") edited.Fields["no_such_field"] = 1 edited.Fields["model_name"] = "new" - out := assemble(t, v, []Source{provider(), edited}, first.Document) + out := assemble(t, v, []Source{provider(), edited}, &first) if d := disposition(out, v1alpha1.KindModel, "chat"); d.State != Retained { t.Fatalf("chat: %+v", d) } @@ -208,7 +213,7 @@ func TestCrossFieldRulesDroppedFromTheCRDAreStillEnforced(t *testing.T) { func TestDeletedResourcesLeaveTheDocument(t *testing.T) { v := gatewayValidator(t, &fakeValidator{}) first := assemble(t, v, []Source{provider(), model("chat", "mock"), apiKey()}, nil) - out := assemble(t, v, []Source{provider(), model("chat", "mock")}, first.Document) + out := assemble(t, v, []Source{provider(), model("chat", "mock")}, &first) if bytes.Contains(out.Document, []byte("api_keys")) { t.Errorf("deleted APIKey still served:\n%s", out.Document) } @@ -248,13 +253,62 @@ func TestScopesParse(t *testing.T) { } } -func TestServedIdentitiesAreUnescaped(t *testing.T) { +func rejectUnknownField(_ v1alpha1.Kind, e Entry) string { + if _, ok := e["no_such_field"]; ok { + return "unknown field `no_such_field`" + } + return "" +} + +func TestRenamedAndRejectedKeepsTheServedEntry(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{reject: rejectUnknownField}) + first := assemble(t, v, []Source{provider(), model("chat", "mock")}, nil) + if string(first.Index) != `{"Model/chat":"chat","ProviderKey/mock":"mock"}`+"\n" { + t.Fatalf("index = %s", first.Index) + } + edited := model("chat", "mock") + edited.Fields[v1alpha1.IdentityField] = "renamed" + edited.Fields["no_such_field"] = 1 + out := assemble(t, v, []Source{provider(), edited}, &first) + if d := disposition(out, v1alpha1.KindModel, "chat"); d.State != Retained { + t.Fatalf("chat: %+v", d) + } + if !bytes.Equal(out.Document, first.Document) || !bytes.Equal(out.Index, first.Index) { + t.Errorf("served state changed:\n%s\n%s", out.Document, out.Index) + } +} + +func TestServedEntriesBelongToTheirResource(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{reject: rejectUnknownField}) + old := model("old", "mock") + old.Fields[v1alpha1.IdentityField] = "shared" + first := assemble(t, v, []Source{provider(), old}, nil) + + // A new resource under the same identity must not inherit old's entry. + newer := model("new", "mock") + newer.Fields[v1alpha1.IdentityField] = "shared" + newer.Fields["no_such_field"] = 1 + out := assemble(t, v, []Source{provider(), newer}, &first) + if d := disposition(out, v1alpha1.KindModel, "new"); d.State != Rejected { + t.Errorf("new: %+v", d) + } + if bytes.Contains(out.Document, []byte(`"shared"`)) { + t.Errorf("the deleted resource's entry is still served:\n%s", out.Document) + } +} + +func TestServedEntriesParse(t *testing.T) { doc := Document(map[v1alpha1.Kind][]Entry{v1alpha1.KindModel: {{v1alpha1.IdentityField: "a$$b"}}}) - served := ServedEntries(doc) - if _, ok := served.get(src(v1alpha1.KindModel, "x", map[string]any{v1alpha1.IdentityField: "a$b"})); !ok { - t.Error("served entry not found by its literal identity") + served := ServedEntries(doc, Index(map[string]string{"Model/x": "a$b"})) + if _, id, ok := served.get(src(v1alpha1.KindModel, "x", map[string]any{v1alpha1.IdentityField: "renamed"})); !ok || id != "a$b" { + t.Errorf("served entry not found through the index: %q %v", id, ok) } - if len(ServedEntries([]byte("not: json"))) != 0 { - t.Error("unreadable document should count as nothing served") + if _, _, ok := served.get(src(v1alpha1.KindModel, "y", map[string]any{v1alpha1.IdentityField: "a$b"})); ok { + t.Error("an unindexed resource found an entry by identity") + } + for _, bad := range [][2][]byte{{[]byte("not: json"), Index(nil)}, {doc, []byte("nope")}, {doc, nil}} { + if _, _, ok := ServedEntries(bad[0], bad[1]).get(src(v1alpha1.KindModel, "x", nil)); ok { + t.Errorf("unreadable input counted as served: %q %q", bad[0], bad[1]) + } } }