ソースを参照

feat(model): add ExternalModel api

Thomas Zhang 3 ヶ月 前
コミット
4bdfd4bc73

+ 8 - 0
PROJECT

@@ -25,4 +25,12 @@ resources:
   kind: Stack
   path: github.com/LocoStack/loco-operator/api/v1alpha1
   version: v1alpha1
+- api:
+    crdVersion: v1
+    namespaced: true
+  controller: true
+  domain: locostack.com
+  kind: ExternalModel
+  path: github.com/LocoStack/loco-operator/api/v1alpha1
+  version: v1alpha1
 version: "3"

+ 149 - 0
api/v1alpha1/externalmodel_types.go

@@ -0,0 +1,149 @@
+/*
+Copyright 2026 LocoStack.
+
+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.
+*/
+
+package v1alpha1
+
+import (
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// InferenceParameters holds well-known inference parameters for models.
+type InferenceParameters struct {
+	// temperature is the sampling temperature for language models.
+	// +optional
+	Temperature string `json:"temperature,omitempty"`
+
+	// topP is the nucleus sampling parameter for language models.
+	// +optional
+	TopP string `json:"topP,omitempty"`
+
+	// maxTokens is the maximum number of tokens to generate for language models.
+	// +optional
+	MaxTokens *int32 `json:"maxTokens,omitempty"`
+
+	// extra are pass-through key-value pairs forwarded as provider-specific parameters
+	// to the gateway when the model is gateway-bound.
+	// +optional
+	Extra map[string]string `json:"extra,omitempty"`
+}
+
+// ExternalModelSpec defines the desired state of ExternalModel
+type ExternalModelSpec struct {
+	// stackRef associates this model with a stack.
+	// +optional
+	StackRef *corev1.LocalObjectReference `json:"stackRef,omitempty"`
+
+	// category is the model category: language, embedding, or reranker.
+	// Determines which gateway endpoint clients use and the routing mode in the proxy config.
+	// +kubebuilder:validation:Required
+	// +kubebuilder:validation:Enum=language;embedding;reranker
+	Category string `json:"category"`
+
+	// modelName is the identifier exposed to clients and used as the model identifier
+	// in the gateway's proxy configuration.
+	// +kubebuilder:validation:Required
+	ModelName string `json:"modelName"`
+
+	// provider identifies the upstream provider (e.g. openai, anthropic).
+	// +kubebuilder:validation:Required
+	Provider string `json:"provider"`
+
+	// providerModel is the provider-side model id (e.g. gpt-4o-2024-11-20).
+	// +kubebuilder:validation:Required
+	ProviderModel string `json:"providerModel"`
+
+	// apiBase overrides the upstream base URL (Azure, on-prem proxies, OpenAI-compatible providers).
+	// +optional
+	APIBase string `json:"apiBase,omitempty"`
+
+	// apiVersion sets the API version when the provider requires it (e.g. Azure).
+	// +optional
+	APIVersion string `json:"apiVersion,omitempty"`
+
+	// auth describes how to authenticate to the provider.
+	// +optional
+	Auth *AuthSpec `json:"auth,omitempty"`
+
+	// extraParams are pass-through provider-specific parameters forwarded to the gateway
+	// proxy config (e.g. aws_region_name, vertex_project, vertex_location).
+	// +optional
+	ExtraParams map[string]string `json:"extraParams,omitempty"`
+
+	// inference holds per-model inference defaults baked into the gateway's proxy config
+	// entry for this model. Applied to every request unless the client overrides them.
+	// +optional
+	DefaultInferenceParams *InferenceParameters `json:"defaultInferenceParams,omitempty"`
+}
+
+// ExternalModelStatus defines the observed state of ExternalModel.
+type ExternalModelStatus struct {
+	// observedGeneration is the .metadata.generation last reconciled.
+	// +optional
+	ObservedGeneration int64 `json:"observedGeneration,omitempty"`
+
+	// conditions represent the current state of the ExternalModel resource.
+	// Each condition has a unique type and reflects the status of a specific aspect of the resource.
+	//
+	// Standard condition types include:
+	// - "Available": the resource is fully functional
+	// - "Progressing": the resource is being created or updated
+	// - "Degraded": the resource failed to reach or maintain its desired state
+	//
+	// The status of each condition is one of True, False, or Unknown.
+	// +listType=map
+	// +listMapKey=type
+	// +optional
+	Conditions []metav1.Condition `json:"conditions,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:resource:shortName=em
+// +kubebuilder:printcolumn:name="Category",type=string,JSONPath=".spec.category"
+// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=".spec.provider"
+// +kubebuilder:printcolumn:name="Model",type=string,JSONPath=".spec.modelName"
+// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=".status.conditions[?(@.type=='Ready')].status"
+
+// ExternalModel is the Schema for the externalmodels API
+type ExternalModel struct {
+	metav1.TypeMeta `json:",inline"`
+
+	// metadata is a standard object metadata
+	// +optional
+	metav1.ObjectMeta `json:"metadata,omitzero"`
+
+	// spec defines the desired state of ExternalModel
+	// +required
+	Spec ExternalModelSpec `json:"spec"`
+
+	// status defines the observed state of ExternalModel
+	// +optional
+	Status ExternalModelStatus `json:"status,omitzero"`
+}
+
+// +kubebuilder:object:root=true
+
+// ExternalModelList contains a list of ExternalModel
+type ExternalModelList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitzero"`
+	Items           []ExternalModel `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&ExternalModel{}, &ExternalModelList{})
+}

+ 209 - 0
api/v1alpha1/zz_generated.deepcopy.go

@@ -26,6 +26,54 @@ import (
 	runtime "k8s.io/apimachinery/pkg/runtime"
 )
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AuthAPIKey) DeepCopyInto(out *AuthAPIKey) {
+	*out = *in
+	in.SecretRef.DeepCopyInto(&out.SecretRef)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthAPIKey.
+func (in *AuthAPIKey) DeepCopy() *AuthAPIKey {
+	if in == nil {
+		return nil
+	}
+	out := new(AuthAPIKey)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AuthSpec) DeepCopyInto(out *AuthSpec) {
+	*out = *in
+	if in.BearerToken != nil {
+		in, out := &in.BearerToken, &out.BearerToken
+		*out = new(v1.SecretKeySelector)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.APIKey != nil {
+		in, out := &in.APIKey, &out.APIKey
+		*out = new(AuthAPIKey)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Headers != nil {
+		in, out := &in.Headers, &out.Headers
+		*out = make([]HTTPHeaderSpec, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthSpec.
+func (in *AuthSpec) DeepCopy() *AuthSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(AuthSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Component) DeepCopyInto(out *Component) {
 	*out = *in
@@ -182,6 +230,167 @@ func (in *ConditionalArgs) DeepCopy() *ConditionalArgs {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ExternalModel) DeepCopyInto(out *ExternalModel) {
+	*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 ExternalModel.
+func (in *ExternalModel) DeepCopy() *ExternalModel {
+	if in == nil {
+		return nil
+	}
+	out := new(ExternalModel)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ExternalModel) 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 *ExternalModelList) DeepCopyInto(out *ExternalModelList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]ExternalModel, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalModelList.
+func (in *ExternalModelList) DeepCopy() *ExternalModelList {
+	if in == nil {
+		return nil
+	}
+	out := new(ExternalModelList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ExternalModelList) 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 *ExternalModelSpec) DeepCopyInto(out *ExternalModelSpec) {
+	*out = *in
+	if in.StackRef != nil {
+		in, out := &in.StackRef, &out.StackRef
+		*out = new(v1.LocalObjectReference)
+		**out = **in
+	}
+	if in.Auth != nil {
+		in, out := &in.Auth, &out.Auth
+		*out = new(AuthSpec)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.ExtraParams != nil {
+		in, out := &in.ExtraParams, &out.ExtraParams
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+	if in.DefaultInferenceParams != nil {
+		in, out := &in.DefaultInferenceParams, &out.DefaultInferenceParams
+		*out = new(InferenceParameters)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalModelSpec.
+func (in *ExternalModelSpec) DeepCopy() *ExternalModelSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ExternalModelSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ExternalModelStatus) DeepCopyInto(out *ExternalModelStatus) {
+	*out = *in
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make([]metav1.Condition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalModelStatus.
+func (in *ExternalModelStatus) DeepCopy() *ExternalModelStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(ExternalModelStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HTTPHeaderSpec) DeepCopyInto(out *HTTPHeaderSpec) {
+	*out = *in
+	in.ValueFrom.DeepCopyInto(&out.ValueFrom)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPHeaderSpec.
+func (in *HTTPHeaderSpec) DeepCopy() *HTTPHeaderSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(HTTPHeaderSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InferenceParameters) DeepCopyInto(out *InferenceParameters) {
+	*out = *in
+	if in.MaxTokens != nil {
+		in, out := &in.MaxTokens, &out.MaxTokens
+		*out = new(int32)
+		**out = **in
+	}
+	if in.Extra != nil {
+		in, out := &in.Extra, &out.Extra
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceParameters.
+func (in *InferenceParameters) DeepCopy() *InferenceParameters {
+	if in == nil {
+		return nil
+	}
+	out := new(InferenceParameters)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *OptionalComponent) DeepCopyInto(out *OptionalComponent) {
 	*out = *in

+ 7 - 0
cmd/main.go

@@ -192,6 +192,13 @@ func main() {
 		setupLog.Error(err, "Failed to create controller", "controller", "Stack")
 		os.Exit(1)
 	}
+	if err := (&controller.ExternalModelReconciler{
+		Client: mgr.GetClient(),
+		Scheme: mgr.GetScheme(),
+	}).SetupWithManager(mgr); err != nil {
+		setupLog.Error(err, "Failed to create controller", "controller", "ExternalModel")
+		os.Exit(1)
+	}
 	// +kubebuilder:scaffold:builder
 
 	if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {

+ 438 - 0
config/crd/bases/locostack.com_externalmodels.yaml

@@ -0,0 +1,438 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.20.1
+  name: externalmodels.locostack.com
+spec:
+  group: locostack.com
+  names:
+    kind: ExternalModel
+    listKind: ExternalModelList
+    plural: externalmodels
+    shortNames:
+    - em
+    singular: externalmodel
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.category
+      name: Category
+      type: string
+    - jsonPath: .spec.provider
+      name: Provider
+      type: string
+    - jsonPath: .spec.modelName
+      name: Model
+      type: string
+    - jsonPath: .status.conditions[?(@.type=='Ready')].status
+      name: Ready
+      type: string
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: ExternalModel is the Schema for the externalmodels API
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: spec defines the desired state of ExternalModel
+            properties:
+              apiBase:
+                description: apiBase overrides the upstream base URL (Azure, on-prem
+                  proxies, OpenAI-compatible providers).
+                type: string
+              apiVersion:
+                description: apiVersion sets the API version when the provider requires
+                  it (e.g. Azure).
+                type: string
+              auth:
+                description: auth describes how to authenticate to the provider.
+                properties:
+                  apiKey:
+                    description: apiKeySecretRef references a Secret key holding an
+                      API key and the header name to carry it in.
+                    properties:
+                      headerName:
+                        description: headerName is the HTTP header used to carry the
+                          key.
+                        type: string
+                      secretRef:
+                        description: secretRef references the Secret key holding the
+                          API key value.
+                        properties:
+                          key:
+                            description: The key of the secret to select from.  Must
+                              be a valid secret key.
+                            type: string
+                          name:
+                            default: ""
+                            description: |-
+                              Name of the referent.
+                              This field is effectively required, but due to backwards compatibility is
+                              allowed to be empty. Instances of this type with an empty value here are
+                              almost certainly wrong.
+                              More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                            type: string
+                          optional:
+                            description: Specify whether the Secret or its key must
+                              be defined
+                            type: boolean
+                        required:
+                        - key
+                        type: object
+                        x-kubernetes-map-type: atomic
+                    required:
+                    - secretRef
+                    type: object
+                  bearerToken:
+                    description: |-
+                      bearerTokenSecretRef references a Secret key holding a bearer token.
+                      Injected as Authorization: Bearer <token> on every request.
+                    properties:
+                      key:
+                        description: The key of the secret to select from.  Must be
+                          a valid secret key.
+                        type: string
+                      name:
+                        default: ""
+                        description: |-
+                          Name of the referent.
+                          This field is effectively required, but due to backwards compatibility is
+                          allowed to be empty. Instances of this type with an empty value here are
+                          almost certainly wrong.
+                          More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                        type: string
+                      optional:
+                        description: Specify whether the Secret or its key must be
+                          defined
+                        type: boolean
+                    required:
+                    - key
+                    type: object
+                    x-kubernetes-map-type: atomic
+                  headers:
+                    description: |-
+                      headers are arbitrary HTTP headers sourced from Secrets.
+                      Use for providers requiring multiple credentials or non-standard auth schemes.
+                    items:
+                      description: |-
+                        HTTPHeaderSpec defines an HTTP header whose value is sourced from a Secret.
+                        Used wherever arbitrary headers must be sent with outbound requests without
+                        inlining credential values into the CR.
+                      properties:
+                        name:
+                          description: name is the HTTP header name (e.g. X-Api-Key,
+                            Authorization).
+                          type: string
+                        valueFrom:
+                          description: valueFrom is the source for the header value
+                            — typically a secretKeyRef.
+                          properties:
+                            configMapKeyRef:
+                              description: Selects a key of a ConfigMap.
+                              properties:
+                                key:
+                                  description: The key to select.
+                                  type: string
+                                name:
+                                  default: ""
+                                  description: |-
+                                    Name of the referent.
+                                    This field is effectively required, but due to backwards compatibility is
+                                    allowed to be empty. Instances of this type with an empty value here are
+                                    almost certainly wrong.
+                                    More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                                  type: string
+                                optional:
+                                  description: Specify whether the ConfigMap or its
+                                    key must be defined
+                                  type: boolean
+                              required:
+                              - key
+                              type: object
+                              x-kubernetes-map-type: atomic
+                            fieldRef:
+                              description: |-
+                                Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['<KEY>']`, `metadata.annotations['<KEY>']`,
+                                spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs.
+                              properties:
+                                apiVersion:
+                                  description: Version of the schema the FieldPath
+                                    is written in terms of, defaults to "v1".
+                                  type: string
+                                fieldPath:
+                                  description: Path of the field to select in the
+                                    specified API version.
+                                  type: string
+                              required:
+                              - fieldPath
+                              type: object
+                              x-kubernetes-map-type: atomic
+                            fileKeyRef:
+                              description: |-
+                                FileKeyRef selects a key of the env file.
+                                Requires the EnvFiles feature gate to be enabled.
+                              properties:
+                                key:
+                                  description: |-
+                                    The key within the env file. An invalid key will prevent the pod from starting.
+                                    The keys defined within a source may consist of any printable ASCII characters except '='.
+                                    During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters.
+                                  type: string
+                                optional:
+                                  default: false
+                                  description: |-
+                                    Specify whether the file or its key must be defined. If the file or key
+                                    does not exist, then the env var is not published.
+                                    If optional is set to true and the specified key does not exist,
+                                    the environment variable will not be set in the Pod's containers.
+
+                                    If optional is set to false and the specified key does not exist,
+                                    an error will be returned during Pod creation.
+                                  type: boolean
+                                path:
+                                  description: |-
+                                    The path within the volume from which to select the file.
+                                    Must be relative and may not contain the '..' path or start with '..'.
+                                  type: string
+                                volumeName:
+                                  description: The name of the volume mount containing
+                                    the env file.
+                                  type: string
+                              required:
+                              - key
+                              - path
+                              - volumeName
+                              type: object
+                              x-kubernetes-map-type: atomic
+                            resourceFieldRef:
+                              description: |-
+                                Selects a resource of the container: only resources limits and requests
+                                (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported.
+                              properties:
+                                containerName:
+                                  description: 'Container name: required for volumes,
+                                    optional for env vars'
+                                  type: string
+                                divisor:
+                                  anyOf:
+                                  - type: integer
+                                  - type: string
+                                  description: Specifies the output format of the
+                                    exposed resources, defaults to "1"
+                                  pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+                                  x-kubernetes-int-or-string: true
+                                resource:
+                                  description: 'Required: resource to select'
+                                  type: string
+                              required:
+                              - resource
+                              type: object
+                              x-kubernetes-map-type: atomic
+                            secretKeyRef:
+                              description: Selects a key of a secret in the pod's
+                                namespace
+                              properties:
+                                key:
+                                  description: The key of the secret to select from.  Must
+                                    be a valid secret key.
+                                  type: string
+                                name:
+                                  default: ""
+                                  description: |-
+                                    Name of the referent.
+                                    This field is effectively required, but due to backwards compatibility is
+                                    allowed to be empty. Instances of this type with an empty value here are
+                                    almost certainly wrong.
+                                    More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                                  type: string
+                                optional:
+                                  description: Specify whether the Secret or its key
+                                    must be defined
+                                  type: boolean
+                              required:
+                              - key
+                              type: object
+                              x-kubernetes-map-type: atomic
+                          type: object
+                      required:
+                      - name
+                      - valueFrom
+                      type: object
+                    type: array
+                type: object
+              category:
+                description: |-
+                  category is the model category: language, embedding, or reranker.
+                  Determines which gateway endpoint clients use and the routing mode in the proxy config.
+                enum:
+                - language
+                - embedding
+                - reranker
+                type: string
+              defaultInferenceParams:
+                description: |-
+                  inference holds per-model inference defaults baked into the gateway's proxy config
+                  entry for this model. Applied to every request unless the client overrides them.
+                properties:
+                  extra:
+                    additionalProperties:
+                      type: string
+                    description: |-
+                      extra are pass-through key-value pairs forwarded as provider-specific parameters
+                      to the gateway when the model is gateway-bound.
+                    type: object
+                  maxTokens:
+                    description: maxTokens is the maximum number of tokens to generate
+                      for language models.
+                    format: int32
+                    type: integer
+                  temperature:
+                    description: temperature is the sampling temperature for language
+                      models.
+                    type: string
+                  topP:
+                    description: topP is the nucleus sampling parameter for language
+                      models.
+                    type: string
+                type: object
+              extraParams:
+                additionalProperties:
+                  type: string
+                description: |-
+                  extraParams are pass-through provider-specific parameters forwarded to the gateway
+                  proxy config (e.g. aws_region_name, vertex_project, vertex_location).
+                type: object
+              modelName:
+                description: |-
+                  modelName is the identifier exposed to clients and used as the model identifier
+                  in the gateway's proxy configuration.
+                type: string
+              provider:
+                description: provider identifies the upstream provider (e.g. openai,
+                  anthropic).
+                type: string
+              providerModel:
+                description: providerModel is the provider-side model id (e.g. gpt-4o-2024-11-20).
+                type: string
+              stackRef:
+                description: stackRef associates this model with a stack.
+                properties:
+                  name:
+                    default: ""
+                    description: |-
+                      Name of the referent.
+                      This field is effectively required, but due to backwards compatibility is
+                      allowed to be empty. Instances of this type with an empty value here are
+                      almost certainly wrong.
+                      More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                    type: string
+                type: object
+                x-kubernetes-map-type: atomic
+            required:
+            - category
+            - modelName
+            - provider
+            - providerModel
+            type: object
+          status:
+            description: status defines the observed state of ExternalModel
+            properties:
+              conditions:
+                description: |-
+                  conditions represent the current state of the ExternalModel resource.
+                  Each condition has a unique type and reflects the status of a specific aspect of the resource.
+
+                  Standard condition types include:
+                  - "Available": the resource is fully functional
+                  - "Progressing": the resource is being created or updated
+                  - "Degraded": the resource failed to reach or maintain its desired state
+
+                  The status of each condition is one of True, False, or Unknown.
+                items:
+                  description: Condition contains details for one aspect of the current
+                    state of this API Resource.
+                  properties:
+                    lastTransitionTime:
+                      description: |-
+                        lastTransitionTime is the last time the condition transitioned from one status to another.
+                        This should be when the underlying condition changed.  If that is not known, then using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: |-
+                        message is a human readable message indicating details about the transition.
+                        This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: |-
+                        observedGeneration represents the .metadata.generation that the condition was set based upon.
+                        For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+                        with respect to the current state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: |-
+                        reason contains a programmatic identifier indicating the reason for the condition's last transition.
+                        Producers of specific condition types may define expected values and meanings for this field,
+                        and whether the values are considered a guaranteed API.
+                        The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+                x-kubernetes-list-map-keys:
+                - type
+                x-kubernetes-list-type: map
+              observedGeneration:
+                description: observedGeneration is the .metadata.generation last reconciled.
+                format: int64
+                type: integer
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}

+ 1 - 0
config/crd/kustomization.yaml

@@ -4,6 +4,7 @@
 resources:
 - bases/locostack.com_components.yaml
 - bases/locostack.com_stacks.yaml
+- bases/locostack.com_externalmodels.yaml
 # +kubebuilder:scaffold:crdkustomizeresource
 
 patches:

+ 27 - 0
config/rbac/externalmodel_admin_role.yaml

@@ -0,0 +1,27 @@
+# This rule is not used by the project loco-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants full permissions ('*') over locostack.com.
+# This role is intended for users authorized to modify roles and bindings within the cluster,
+# enabling them to delegate specific permissions to other users or groups as needed.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/name: loco-operator
+    app.kubernetes.io/managed-by: kustomize
+  name: externalmodel-admin-role
+rules:
+- apiGroups:
+  - locostack.com
+  resources:
+  - externalmodels
+  verbs:
+  - '*'
+- apiGroups:
+  - locostack.com
+  resources:
+  - externalmodels/status
+  verbs:
+  - get

+ 33 - 0
config/rbac/externalmodel_editor_role.yaml

@@ -0,0 +1,33 @@
+# This rule is not used by the project loco-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants permissions to create, update, and delete resources within the locostack.com.
+# This role is intended for users who need to manage these resources
+# but should not control RBAC or manage permissions for others.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/name: loco-operator
+    app.kubernetes.io/managed-by: kustomize
+  name: externalmodel-editor-role
+rules:
+- apiGroups:
+  - locostack.com
+  resources:
+  - externalmodels
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - locostack.com
+  resources:
+  - externalmodels/status
+  verbs:
+  - get

+ 29 - 0
config/rbac/externalmodel_viewer_role.yaml

@@ -0,0 +1,29 @@
+# This rule is not used by the project loco-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants read-only access to locostack.com resources.
+# This role is intended for users who need visibility into these resources
+# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/name: loco-operator
+    app.kubernetes.io/managed-by: kustomize
+  name: externalmodel-viewer-role
+rules:
+- apiGroups:
+  - locostack.com
+  resources:
+  - externalmodels
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - locostack.com
+  resources:
+  - externalmodels/status
+  verbs:
+  - get

+ 3 - 0
config/rbac/kustomization.yaml

@@ -22,6 +22,9 @@ resources:
 # default, aiding admins in cluster management. Those roles are
 # not used by the loco-operator itself. You can comment the following lines
 # if you do not want those helpers be installed with your Project.
+- externalmodel_admin_role.yaml
+- externalmodel_editor_role.yaml
+- externalmodel_viewer_role.yaml
 - stack_admin_role.yaml
 - stack_editor_role.yaml
 - stack_viewer_role.yaml

+ 3 - 0
config/rbac/role.yaml

@@ -34,6 +34,7 @@ rules:
   - locostack.com
   resources:
   - components
+  - externalmodels
   - stacks
   verbs:
   - create
@@ -47,6 +48,7 @@ rules:
   - locostack.com
   resources:
   - components/finalizers
+  - externalmodels/finalizers
   - stacks/finalizers
   verbs:
   - update
@@ -54,6 +56,7 @@ rules:
   - locostack.com
   resources:
   - components/status
+  - externalmodels/status
   - stacks/status
   verbs:
   - get

+ 1 - 0
config/samples/kustomization.yaml

@@ -2,4 +2,5 @@
 resources:
 - v1alpha1_component.yaml
 - v1alpha1_stack.yaml
+- v1alpha1_externalmodel.yaml
 # +kubebuilder:scaffold:manifestskustomizesamples

+ 9 - 0
config/samples/v1alpha1_externalmodel.yaml

@@ -0,0 +1,9 @@
+apiVersion: locostack.com/v1alpha1
+kind: ExternalModel
+metadata:
+  labels:
+    app.kubernetes.io/name: loco-operator
+    app.kubernetes.io/managed-by: kustomize
+  name: externalmodel-sample
+spec:
+  # TODO(user): Add fields here

+ 1 - 1
go.mod

@@ -5,6 +5,7 @@ go 1.25.3
 require (
 	github.com/onsi/ginkgo/v2 v2.27.2
 	github.com/onsi/gomega v1.38.2
+	go.yaml.in/yaml/v2 v2.4.3
 	k8s.io/api v0.35.0
 	k8s.io/apimachinery v0.35.0
 	k8s.io/client-go v0.35.0
@@ -66,7 +67,6 @@ require (
 	go.opentelemetry.io/proto/otlp v1.5.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/zap v1.27.0 // indirect
-	go.yaml.in/yaml/v2 v2.4.3 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
 	golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
 	golang.org/x/mod v0.29.0 // indirect

+ 63 - 0
internal/controller/externalmodel_controller.go

@@ -0,0 +1,63 @@
+/*
+Copyright 2026 LocoStack.
+
+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.
+*/
+
+package controller
+
+import (
+	"context"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	logf "sigs.k8s.io/controller-runtime/pkg/log"
+
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+)
+
+// ExternalModelReconciler reconciles a ExternalModel object
+type ExternalModelReconciler struct {
+	client.Client
+	Scheme *runtime.Scheme
+}
+
+// +kubebuilder:rbac:groups=locostack.com,resources=externalmodels,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=locostack.com,resources=externalmodels/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=locostack.com,resources=externalmodels/finalizers,verbs=update
+
+// Reconcile is part of the main kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+// TODO(user): Modify the Reconcile function to compare the state specified by
+// the ExternalModel object against the actual cluster state, and then
+// perform operations to make the cluster state reflect the state specified by
+// the user.
+//
+// For more details, check Reconcile and its Result here:
+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.23.3/pkg/reconcile
+func (r *ExternalModelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	_ = logf.FromContext(ctx)
+
+	// TODO(user): your logic here
+
+	return ctrl.Result{}, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *ExternalModelReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1alpha1.ExternalModel{}).
+		Named("externalmodel").
+		Complete(r)
+}

+ 84 - 0
internal/controller/externalmodel_controller_test.go

@@ -0,0 +1,84 @@
+/*
+Copyright 2026 LocoStack.
+
+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.
+*/
+
+package controller
+
+import (
+	"context"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+)
+
+var _ = Describe("ExternalModel Controller", func() {
+	Context("When reconciling a resource", func() {
+		const resourceName = "test-resource"
+
+		ctx := context.Background()
+
+		typeNamespacedName := types.NamespacedName{
+			Name:      resourceName,
+			Namespace: "default", // TODO(user):Modify as needed
+		}
+		externalmodel := &v1alpha1.ExternalModel{}
+
+		BeforeEach(func() {
+			By("creating the custom resource for the Kind ExternalModel")
+			err := k8sClient.Get(ctx, typeNamespacedName, externalmodel)
+			if err != nil && errors.IsNotFound(err) {
+				resource := &v1alpha1.ExternalModel{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      resourceName,
+						Namespace: "default",
+					},
+					// TODO(user): Specify other spec details if needed.
+				}
+				Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+			}
+		})
+
+		AfterEach(func() {
+			// TODO(user): Cleanup logic after each test, like removing the resource instance.
+			resource := &v1alpha1.ExternalModel{}
+			err := k8sClient.Get(ctx, typeNamespacedName, resource)
+			Expect(err).NotTo(HaveOccurred())
+
+			By("Cleanup the specific resource instance ExternalModel")
+			Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
+		})
+		It("should successfully reconcile the resource", func() {
+			By("Reconciling the created resource")
+			controllerReconciler := &ExternalModelReconciler{
+				Client: k8sClient,
+				Scheme: k8sClient.Scheme(),
+			}
+
+			_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+				NamespacedName: typeNamespacedName,
+			})
+			Expect(err).NotTo(HaveOccurred())
+			// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
+			// Example: If you expect a certain status condition after reconciliation, verify it here.
+		})
+	})
+})