feat: unify installation model catalog
This commit is contained in:
@@ -28,46 +28,21 @@ const maxEnvironmentFileBytes = 1 << 20
|
||||
|
||||
const maxMetadataSecretBundleBytes = 64 << 10
|
||||
|
||||
const maxMetadataGenerationModels = 64
|
||||
|
||||
const maxSecretSources = 32
|
||||
|
||||
var dotenvParseMu sync.Mutex
|
||||
|
||||
type descriptor struct {
|
||||
SchemaVersion int `yaml:"schemaVersion"`
|
||||
Profile string `yaml:"profile"`
|
||||
ProjectDirectory string `yaml:"projectDirectory"`
|
||||
EnvFile string `yaml:"envFile"`
|
||||
WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"`
|
||||
Authentication authenticationDescriptor `yaml:"authentication"`
|
||||
MetadataGeneration metadataGenerationDescriptor `yaml:"metadataGeneration"`
|
||||
ModelCatalog ModelCatalog `yaml:"modelCatalog"`
|
||||
Overrides []string `yaml:"overrides"`
|
||||
}
|
||||
|
||||
type metadataGenerationDescriptor struct {
|
||||
Default string `yaml:"default"`
|
||||
Models []metadataGenerationModelDescriptor `yaml:"models"`
|
||||
}
|
||||
|
||||
type metadataGenerationModelDescriptor struct {
|
||||
ID string `yaml:"id"`
|
||||
Label string `yaml:"label"`
|
||||
LiteLLM liteLLMDescriptor `yaml:"litellm"`
|
||||
APIKeyEnv string `yaml:"apiKeyEnv"`
|
||||
}
|
||||
|
||||
type liteLLMDescriptor struct {
|
||||
Provider string `yaml:"provider"`
|
||||
Model string `yaml:"model"`
|
||||
DisableThinking bool `yaml:"disableThinking"`
|
||||
Endpoint *metadataEndpointDescriptor `yaml:"endpoint"`
|
||||
}
|
||||
|
||||
type metadataEndpointDescriptor struct {
|
||||
BaseURL string `yaml:"baseUrl"`
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
}
|
||||
|
||||
type authenticationDescriptor struct {
|
||||
ConfigDirectory string `yaml:"configDirectory"`
|
||||
RuntimeProjection *runtimeProjectionDescriptor `yaml:"runtimeProjection"`
|
||||
@@ -105,46 +80,17 @@ type Authentication struct {
|
||||
RuntimeProjection *RuntimeProjection
|
||||
}
|
||||
|
||||
// MetadataGenerationEndpoint contains optional provider endpoint settings for one LiteLLM model.
|
||||
type MetadataGenerationEndpoint struct {
|
||||
BaseURL string
|
||||
APIVersion string
|
||||
}
|
||||
|
||||
// MetadataGenerationLiteLLM identifies the provider/model pair used by the internal completion helper.
|
||||
type MetadataGenerationLiteLLM struct {
|
||||
Provider string
|
||||
Model string
|
||||
DisableThinking bool
|
||||
Endpoint *MetadataGenerationEndpoint
|
||||
}
|
||||
|
||||
// MetadataGenerationModel is one selectable installation-owned metadata-generation model.
|
||||
// APIKeyEnv is an optional reference only; credential values never enter Installation.
|
||||
// An empty value is allowed only for a model with an explicit keyless endpoint.
|
||||
type MetadataGenerationModel struct {
|
||||
ID string
|
||||
Label string
|
||||
LiteLLM MetadataGenerationLiteLLM
|
||||
APIKeyEnv string
|
||||
}
|
||||
|
||||
// MetadataGeneration is the installation-owned model list and its default selection.
|
||||
type MetadataGeneration struct {
|
||||
Default string
|
||||
Models []MetadataGenerationModel
|
||||
}
|
||||
|
||||
// Installation is a validated local Compose installation. It intentionally contains paths, not
|
||||
// environment values or secret content.
|
||||
type Installation struct {
|
||||
Path string
|
||||
SchemaVersion int
|
||||
Profile string
|
||||
ProjectDirectory string
|
||||
EnvFile string
|
||||
WorkspaceRepository WorkspaceRepository
|
||||
Authentication Authentication
|
||||
MetadataGeneration MetadataGeneration
|
||||
ModelCatalog ModelCatalog
|
||||
Overrides []string
|
||||
}
|
||||
|
||||
@@ -171,18 +117,34 @@ func Load(path string) (Installation, error) {
|
||||
decoder := yaml.NewDecoder(file)
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
if strings.Contains(err.Error(), "field metadataGeneration not found") {
|
||||
return Installation{}, errors.New("migration_required: metadataGeneration was replaced by modelCatalog in installation schema version 2")
|
||||
}
|
||||
return Installation{}, fmt.Errorf("read installation file: %w", err)
|
||||
}
|
||||
if err := ensureOnlyOneDocument(decoder); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
|
||||
if raw.SchemaVersion != 2 {
|
||||
return Installation{}, errors.New("migration_required: installation schemaVersion must be 2")
|
||||
}
|
||||
if raw.Profile != "local" && raw.Profile != "server" {
|
||||
return Installation{}, fmt.Errorf("profile must be local or server")
|
||||
}
|
||||
if err := requireDirectory(raw.ProjectDirectory, "projectDirectory"); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
for _, legacyProjection := range []string{
|
||||
filepath.Join(raw.ProjectDirectory, "deploy", "pi", "models.json"),
|
||||
filepath.Join(raw.ProjectDirectory, "deploy", "pi", "settings.json"),
|
||||
} {
|
||||
if _, err := os.Lstat(legacyProjection); err == nil {
|
||||
return Installation{}, errors.New("migration_required: remove legacy deploy/pi model sources after reviewing the schema version 2 candidate")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return Installation{}, errors.New("legacy deploy/pi model sources could not be inspected")
|
||||
}
|
||||
}
|
||||
if err := requireRegularFile(raw.EnvFile, "envFile"); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
@@ -198,31 +160,9 @@ func Load(path string) (Installation, error) {
|
||||
GID: raw.Authentication.RuntimeProjection.GID,
|
||||
}
|
||||
}
|
||||
metadataGeneration := MetadataGeneration{
|
||||
Default: raw.MetadataGeneration.Default,
|
||||
Models: make([]MetadataGenerationModel, 0, len(raw.MetadataGeneration.Models)),
|
||||
}
|
||||
for _, rawModel := range raw.MetadataGeneration.Models {
|
||||
model := MetadataGenerationModel{
|
||||
ID: rawModel.ID,
|
||||
Label: rawModel.Label,
|
||||
LiteLLM: MetadataGenerationLiteLLM{
|
||||
Provider: rawModel.LiteLLM.Provider,
|
||||
Model: rawModel.LiteLLM.Model,
|
||||
DisableThinking: rawModel.LiteLLM.DisableThinking,
|
||||
},
|
||||
APIKeyEnv: rawModel.APIKeyEnv,
|
||||
}
|
||||
if rawModel.LiteLLM.Endpoint != nil {
|
||||
model.LiteLLM.Endpoint = &MetadataGenerationEndpoint{
|
||||
BaseURL: rawModel.LiteLLM.Endpoint.BaseURL,
|
||||
APIVersion: rawModel.LiteLLM.Endpoint.APIVersion,
|
||||
}
|
||||
}
|
||||
metadataGeneration.Models = append(metadataGeneration.Models, model)
|
||||
}
|
||||
installation := Installation{
|
||||
Path: path,
|
||||
SchemaVersion: raw.SchemaVersion,
|
||||
Profile: raw.Profile,
|
||||
ProjectDirectory: filepath.Clean(raw.ProjectDirectory),
|
||||
EnvFile: filepath.Clean(raw.EnvFile),
|
||||
@@ -231,15 +171,15 @@ func Load(path string) (Installation, error) {
|
||||
Branch: raw.WorkspaceRepository.Branch,
|
||||
Access: raw.WorkspaceRepository.Access,
|
||||
},
|
||||
Authentication: authentication,
|
||||
MetadataGeneration: metadataGeneration,
|
||||
Overrides: make([]string, 0, len(raw.Overrides)),
|
||||
Authentication: authentication,
|
||||
ModelCatalog: raw.ModelCatalog,
|
||||
Overrides: make([]string, 0, len(raw.Overrides)),
|
||||
}
|
||||
values, err := installation.environmentValues()
|
||||
if err != nil {
|
||||
return Installation{}, errors.New("installation secret declarations could not be read")
|
||||
}
|
||||
if err := installation.validateMetadataGeneration(values); err != nil {
|
||||
if err := installation.ModelCatalog.Validate(values); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
if values["THT_AUTH_CONFIG_ROOT"] != installation.AuthenticationDirectory() {
|
||||
@@ -277,88 +217,9 @@ func Load(path string) (Installation, error) {
|
||||
return installation, nil
|
||||
}
|
||||
|
||||
func (i Installation) validateMetadataGeneration(values map[string]string) error {
|
||||
if len(i.MetadataGeneration.Models) > maxMetadataGenerationModels {
|
||||
return fmt.Errorf(
|
||||
"metadataGeneration.models must contain at most %d entries",
|
||||
maxMetadataGenerationModels,
|
||||
)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(i.MetadataGeneration.Models))
|
||||
for index, model := range i.MetadataGeneration.Models {
|
||||
if err := validateMetadataGenerationModel(index, model); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := seen[model.ID]; exists {
|
||||
return fmt.Errorf("duplicate metadataGeneration model id %q", model.ID)
|
||||
}
|
||||
seen[model.ID] = struct{}{}
|
||||
}
|
||||
if len(i.MetadataGeneration.Models) > 0 && i.MetadataGeneration.Default == "" {
|
||||
return errors.New("metadataGeneration.default is required when models are configured")
|
||||
}
|
||||
if i.MetadataGeneration.Default != "" {
|
||||
if _, exists := seen[i.MetadataGeneration.Default]; !exists {
|
||||
return fmt.Errorf(
|
||||
"metadataGeneration.default %q does not identify a configured model",
|
||||
i.MetadataGeneration.Default,
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(i.MetadataGeneration.Models) == 0 {
|
||||
return nil
|
||||
}
|
||||
if values["THT_INSTALLATION_CONFIG_SOURCE"] != i.Path {
|
||||
return errors.New("metadataGeneration requires THT_INSTALLATION_CONFIG_SOURCE to match the installation file")
|
||||
}
|
||||
requiresSecrets := false
|
||||
for _, model := range i.MetadataGeneration.Models {
|
||||
if model.APIKeyEnv != "" {
|
||||
requiresSecrets = true
|
||||
break
|
||||
}
|
||||
}
|
||||
secrets := map[string]string{}
|
||||
if requiresSecrets {
|
||||
bundlePath := values["THT_SECRETS_FILE"]
|
||||
if bundlePath == "" {
|
||||
return errors.New("metadataGeneration keyed models require THT_SECRETS_FILE")
|
||||
}
|
||||
var err error
|
||||
secrets, err = readMetadataGenerationSecrets(bundlePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, model := range i.MetadataGeneration.Models {
|
||||
if model.APIKeyEnv == "" {
|
||||
continue
|
||||
}
|
||||
value, exists := secrets[model.APIKeyEnv]
|
||||
if !exists {
|
||||
return fmt.Errorf(
|
||||
"metadataGeneration model %q secret %q is missing from THT_SECRETS_FILE",
|
||||
model.ID,
|
||||
model.APIKeyEnv,
|
||||
)
|
||||
}
|
||||
if !usableMetadataGenerationSecret(value) {
|
||||
return fmt.Errorf(
|
||||
"metadataGeneration model %q secret %q is unusable",
|
||||
model.ID,
|
||||
model.APIKeyEnv,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataModelIDPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`)
|
||||
var metadataProviderPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
var metadataProviderModelPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`)
|
||||
var metadataAPIVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
var metadataSecretBundleKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
|
||||
var metadataAPIKeyEnvironments = map[string]struct{}{
|
||||
"THT_MODEL_API_KEY": {},
|
||||
"THT_METADATA_API_KEY": {},
|
||||
"ANTHROPIC_API_KEY": {},
|
||||
"AZURE_API_KEY": {},
|
||||
@@ -392,50 +253,6 @@ var metadataSecretBundleKeys = map[string]struct{}{
|
||||
"ZAI_API_KEY": {},
|
||||
}
|
||||
|
||||
func validateMetadataGenerationModel(index int, model MetadataGenerationModel) error {
|
||||
prefix := fmt.Sprintf("metadataGeneration.models[%d]", index)
|
||||
if !metadataModelIDPattern.MatchString(model.ID) {
|
||||
return fmt.Errorf("%s.id is invalid", prefix)
|
||||
}
|
||||
if len(model.Label) == 0 || len(model.Label) > 128 || strings.TrimSpace(model.Label) != model.Label ||
|
||||
strings.IndexFunc(model.Label, unicode.IsControl) >= 0 {
|
||||
return fmt.Errorf("%s.label is invalid", prefix)
|
||||
}
|
||||
if !metadataProviderPattern.MatchString(model.LiteLLM.Provider) {
|
||||
return fmt.Errorf("%s.litellm.provider is invalid", prefix)
|
||||
}
|
||||
if !metadataProviderModelPattern.MatchString(model.LiteLLM.Model) {
|
||||
return fmt.Errorf("%s.litellm.model is invalid", prefix)
|
||||
}
|
||||
if model.APIKeyEnv == "" {
|
||||
if model.LiteLLM.Endpoint == nil {
|
||||
return fmt.Errorf("%s.apiKeyEnv is required unless an explicit keyless endpoint is configured", prefix)
|
||||
}
|
||||
} else {
|
||||
if !metadataSecretBundleKeyPattern.MatchString(model.APIKeyEnv) {
|
||||
return fmt.Errorf("%s.apiKeyEnv is invalid", prefix)
|
||||
}
|
||||
if _, allowed := metadataAPIKeyEnvironments[model.APIKeyEnv]; !allowed {
|
||||
return fmt.Errorf("%s.apiKeyEnv is invalid", prefix)
|
||||
}
|
||||
}
|
||||
if endpoint := model.LiteLLM.Endpoint; endpoint != nil {
|
||||
parsed, err := url.Parse(endpoint.BaseURL)
|
||||
if err != nil || strings.TrimSpace(endpoint.BaseURL) != endpoint.BaseURL ||
|
||||
(parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" ||
|
||||
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return fmt.Errorf("%s.litellm.endpoint.baseUrl is invalid", prefix)
|
||||
}
|
||||
if endpoint.APIVersion != "" && !metadataAPIVersionPattern.MatchString(endpoint.APIVersion) {
|
||||
return fmt.Errorf("%s.litellm.endpoint.apiVersion is invalid", prefix)
|
||||
}
|
||||
}
|
||||
if model.LiteLLM.DisableThinking && model.LiteLLM.Endpoint == nil {
|
||||
return fmt.Errorf("%s.litellm.disableThinking requires an explicit endpoint", prefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readMetadataGenerationSecrets(path string) (map[string]string, error) {
|
||||
contents, err := safeio.ReadCanonicalPrivateRegular(path, maxMetadataSecretBundleBytes)
|
||||
if err != nil {
|
||||
@@ -604,6 +421,7 @@ func (i Installation) ComposeFiles() []string {
|
||||
filepath.Join(i.ProjectDirectory, "deploy", "compose."+i.Profile+".yaml"),
|
||||
}
|
||||
files = append(files, i.Overrides...)
|
||||
files = append(files, i.ModelProjectionComposePath())
|
||||
if i.HasRuntimeAuthProjection() {
|
||||
files = append(files, i.runtimeAuthProjectionComposePath())
|
||||
}
|
||||
@@ -614,6 +432,27 @@ func (i Installation) ComposeFiles() []string {
|
||||
return files
|
||||
}
|
||||
|
||||
// GeneratedDirectory contains disposable runtime adapters derived from modelCatalog.
|
||||
func (i Installation) GeneratedDirectory() string {
|
||||
return filepath.Join(filepath.Dir(i.Path), "generated")
|
||||
}
|
||||
|
||||
func (i Installation) GeneratedModelCatalogPath() string {
|
||||
return filepath.Join(i.GeneratedDirectory(), "catalog.json")
|
||||
}
|
||||
|
||||
func (i Installation) GeneratedPiModelsPath() string {
|
||||
return filepath.Join(i.GeneratedDirectory(), "pi", "models.json")
|
||||
}
|
||||
|
||||
func (i Installation) GeneratedPiSettingsPath() string {
|
||||
return filepath.Join(i.GeneratedDirectory(), "pi", "settings.json")
|
||||
}
|
||||
|
||||
func (i Installation) ModelProjectionComposePath() string {
|
||||
return filepath.Join(i.GeneratedDirectory(), "compose.models.yaml")
|
||||
}
|
||||
|
||||
func (i Installation) runtimeAuthProjectionComposePath() string {
|
||||
return filepath.Join(i.ProjectDirectory, "deploy", "compose.auth-runtime-projection.yaml")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user