feat: unify installation model catalog
This commit is contained in:
@@ -199,7 +199,7 @@ func writeDiscoverableInstallation(t *testing.T, projectRoot, directory string)
|
||||
t.Fatal(err)
|
||||
}
|
||||
installationPath := filepath.Join(directory, "thothii-installation.yaml")
|
||||
contents := "profile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n"
|
||||
contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n" + minimalModelCatalogYAML()
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAcceptsMetadataGenerationModels(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, strings.Join([]string{
|
||||
"THT_SECRETS_FILE=" + strconv.Quote(secretBundle),
|
||||
"THT_INSTALLATION_CONFIG_SOURCE=" + strconv.Quote(installationPath),
|
||||
}, "\n")+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm:
|
||||
provider: openai
|
||||
model: gpt-4.1-mini
|
||||
endpoint:
|
||||
baseUrl: https://api.openai.example/v1
|
||||
apiVersion: "2026-08-01"
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
installation, err := Load(installationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if installation.MetadataGeneration.Default != "openai-mini" {
|
||||
t.Fatalf("metadata default = %q", installation.MetadataGeneration.Default)
|
||||
}
|
||||
if len(installation.MetadataGeneration.Models) != 1 {
|
||||
t.Fatalf("metadata models = %#v", installation.MetadataGeneration.Models)
|
||||
}
|
||||
model := installation.MetadataGeneration.Models[0]
|
||||
if model.ID != "openai-mini" || model.Label != "OpenAI Mini" ||
|
||||
model.LiteLLM.Provider != "openai" || model.LiteLLM.Model != "gpt-4.1-mini" ||
|
||||
model.LiteLLM.Endpoint == nil || model.LiteLLM.Endpoint.BaseURL != "https://api.openai.example/v1" ||
|
||||
model.LiteLLM.Endpoint.APIVersion != "2026-08-01" || model.APIKeyEnv != "OPENAI_API_KEY" {
|
||||
t.Fatalf("metadata model = %#v", model)
|
||||
}
|
||||
if strings.Contains(strings.TrimSpace(model.APIKeyEnv), "provider-secret") {
|
||||
t.Fatal("installation model exposed the credential value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsMixedKeyedAndKeylessMetadataGenerationModels(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(
|
||||
secretBundle,
|
||||
[]byte("DEEPSEEK_API_KEY=deepseek-secret\nZAI_API_KEY=zai-secret\n"),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: glm-53
|
||||
models:
|
||||
- id: deepseek-v4-pro
|
||||
label: DeepSeek V4 Pro
|
||||
litellm: {provider: deepseek, model: deepseek-v4-pro}
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
- id: deepseek-v4-flash
|
||||
label: DeepSeek V4 Flash
|
||||
litellm: {provider: deepseek, model: deepseek-v4-flash}
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
- id: glm-53
|
||||
label: GLM 5.3
|
||||
litellm:
|
||||
provider: openai
|
||||
model: glm-5.3
|
||||
endpoint: {baseUrl: https://api.z.ai/api/coding/paas/v4}
|
||||
apiKeyEnv: ZAI_API_KEY
|
||||
- id: qwen-36
|
||||
label: Qwen 3.6
|
||||
litellm:
|
||||
provider: openai
|
||||
model: qwen3.6-35b-a3b
|
||||
disableThinking: true
|
||||
endpoint: {baseUrl: https://models.internal.example/v1}
|
||||
`)
|
||||
|
||||
installation, err := Load(installationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if len(installation.MetadataGeneration.Models) != 4 {
|
||||
t.Fatalf("metadata models = %#v", installation.MetadataGeneration.Models)
|
||||
}
|
||||
qwen := installation.MetadataGeneration.Models[3]
|
||||
if qwen.APIKeyEnv != "" || !qwen.LiteLLM.DisableThinking || qwen.LiteLLM.Endpoint == nil {
|
||||
t.Fatalf("keyless qwen model = %#v", qwen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsOnlyExplicitKeylessMetadataGenerationModelWithoutBundle(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
appendFile(t, envFile, "THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: qwen-36
|
||||
models:
|
||||
- id: qwen-36
|
||||
label: Qwen 3.6
|
||||
litellm:
|
||||
provider: openai
|
||||
model: qwen3.6-35b-a3b
|
||||
disableThinking: true
|
||||
endpoint: {baseUrl: https://models.internal.example/v1}
|
||||
`)
|
||||
|
||||
if _, err := Load(installationPath); err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsTooManyMetadataGenerationModels(t *testing.T) {
|
||||
installationPath, _, _, _ := writeInstallation(t, "local")
|
||||
var configuration strings.Builder
|
||||
configuration.WriteString("metadataGeneration:\n default: model-0\n models:\n")
|
||||
for index := 0; index <= maxMetadataGenerationModels; index++ {
|
||||
fmt.Fprintf(&configuration, ` - id: model-%d
|
||||
label: Model %d
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`, index, index)
|
||||
}
|
||||
appendFile(t, installationPath, configuration.String())
|
||||
|
||||
_, err := Load(installationPath)
|
||||
want := fmt.Sprintf(
|
||||
"metadataGeneration.models must contain at most %d entries",
|
||||
maxMetadataGenerationModels,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Load() error = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsDuplicateMetadataGenerationModelIDs(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
- id: openai-mini
|
||||
label: Duplicate
|
||||
litellm: {provider: openai, model: gpt-4.1}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate metadataGeneration model id "openai-mini"`) {
|
||||
t.Fatalf("Load() error = %v, want actionable duplicate-id error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingOrUnknownMetadataGenerationDefault(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
defaultYAML string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing",
|
||||
want: "metadataGeneration.default is required when models are configured",
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
defaultYAML: " default: unavailable\n",
|
||||
want: `metadataGeneration.default "unavailable" does not identify a configured model`,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, "metadataGeneration:\n"+test.defaultYAML+` models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMalformedMetadataGenerationModelSettings(t *testing.T) {
|
||||
validPrefix := ` - id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm:
|
||||
`
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unstable id",
|
||||
model: strings.Replace(validPrefix, "openai-mini", "OpenAI Mini", 1) +
|
||||
" provider: openai\n model: gpt-4.1-mini\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].id is invalid",
|
||||
},
|
||||
{
|
||||
name: "blank label",
|
||||
model: strings.Replace(validPrefix, "OpenAI Mini", `" "`, 1) +
|
||||
" provider: openai\n model: gpt-4.1-mini\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].label is invalid",
|
||||
},
|
||||
{
|
||||
name: "provider",
|
||||
model: validPrefix + " provider: open ai\n model: gpt-4.1-mini\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].litellm.provider is invalid",
|
||||
},
|
||||
{
|
||||
name: "model",
|
||||
model: validPrefix + " provider: openai\n model: \" gpt-4.1-mini\"\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].litellm.model is invalid",
|
||||
},
|
||||
{
|
||||
name: "endpoint",
|
||||
model: validPrefix + " provider: openai\n model: gpt-4.1-mini\n" +
|
||||
" endpoint:\n baseUrl: https://operator@api.example/v1\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].litellm.endpoint.baseUrl is invalid",
|
||||
},
|
||||
{
|
||||
name: "api version",
|
||||
model: validPrefix + " provider: openai\n model: gpt-4.1-mini\n" +
|
||||
" endpoint:\n baseUrl: https://api.example/v1\n apiVersion: \"bad version\"\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].litellm.endpoint.apiVersion is invalid",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, "metadataGeneration:\n default: openai-mini\n models:\n"+test.model)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingOrUnusableMetadataGenerationSecrets(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
apiKeyEnvYAML string
|
||||
bundle string
|
||||
declareBundle bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing reference",
|
||||
apiKeyEnvYAML: "",
|
||||
bundle: "OPENAI_API_KEY=provider-secret\n",
|
||||
declareBundle: true,
|
||||
want: "metadataGeneration.models[0].apiKeyEnv is required unless an explicit keyless endpoint is configured",
|
||||
},
|
||||
{
|
||||
name: "malformed reference",
|
||||
apiKeyEnvYAML: " apiKeyEnv: openai-api-key\n",
|
||||
bundle: "OPENAI_API_KEY=provider-secret\n",
|
||||
declareBundle: true,
|
||||
want: "metadataGeneration.models[0].apiKeyEnv is invalid",
|
||||
},
|
||||
{
|
||||
name: "unallowed reference",
|
||||
apiKeyEnvYAML: " apiKeyEnv: THT_DWH_API_KEY\n",
|
||||
bundle: "THT_DWH_API_KEY=dwh-secret\n",
|
||||
declareBundle: true,
|
||||
want: "metadataGeneration.models[0].apiKeyEnv is invalid",
|
||||
},
|
||||
{
|
||||
name: "bundle not declared",
|
||||
apiKeyEnvYAML: " apiKeyEnv: OPENAI_API_KEY\n",
|
||||
bundle: "OPENAI_API_KEY=provider-secret\n",
|
||||
want: "metadataGeneration keyed models require THT_SECRETS_FILE",
|
||||
},
|
||||
{
|
||||
name: "reference absent from bundle",
|
||||
apiKeyEnvYAML: " apiKeyEnv: OPENAI_API_KEY\n",
|
||||
bundle: "THT_DWH_API_KEY=dwh-secret\n",
|
||||
declareBundle: true,
|
||||
want: "metadataGeneration model \"openai-mini\" secret \"OPENAI_API_KEY\" is missing",
|
||||
},
|
||||
{
|
||||
name: "unusable value",
|
||||
apiKeyEnvYAML: " apiKeyEnv: OPENAI_API_KEY\n",
|
||||
bundle: "OPENAI_API_KEY=secret with whitespace\n",
|
||||
declareBundle: true,
|
||||
want: "metadataGeneration model \"openai-mini\" secret \"OPENAI_API_KEY\" is unusable",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte(test.bundle), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment := "THT_INSTALLATION_CONFIG_SOURCE=" + strconv.Quote(installationPath) + "\n"
|
||||
if test.declareBundle {
|
||||
environment += "THT_SECRETS_FILE=" + strconv.Quote(secretBundle) + "\n"
|
||||
}
|
||||
appendFile(t, envFile, environment)
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
`+test.apiKeyEnvYAML)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "secret with whitespace") ||
|
||||
strings.Contains(strings.ToLower(err.Error()), "provider-secret") {
|
||||
t.Fatalf("Load() exposed secret content: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnprotectedMetadataGenerationSecretBundle(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX mode assertion; Windows ACL coverage lives in safeio")
|
||||
}
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "metadataGeneration secrets in THT_SECRETS_FILE are unavailable") {
|
||||
t.Fatalf("Load() error = %v, want protected-bundle error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownMetadataGenerationSecretBundleKeys(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(
|
||||
secretBundle,
|
||||
[]byte("OPENAI_API_KEY=provider-secret\nUNRECOGNIZED_API_KEY=unknown-secret\n"),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "metadataGeneration secrets in THT_SECRETS_FILE are invalid") {
|
||||
t.Fatalf("Load() error = %v, want invalid-bundle error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "UNRECOGNIZED_API_KEY") || strings.Contains(err.Error(), "unknown-secret") {
|
||||
t.Fatalf("Load() exposed rejected bundle content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresConfiguredMetadataGenerationToUseTheSameMountedDescriptor(t *testing.T) {
|
||||
for _, configuredSource := range []string{"", "/different/thothii-installation.yaml"} {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment := "THT_SECRETS_FILE=" + strconv.Quote(secretBundle) + "\n"
|
||||
if configuredSource != "" {
|
||||
environment += "THT_INSTALLATION_CONFIG_SOURCE=" + strconv.Quote(configuredSource) + "\n"
|
||||
}
|
||||
appendFile(t, envFile, environment)
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "metadataGeneration requires THT_INSTALLATION_CONFIG_SOURCE to match the installation file") {
|
||||
t.Fatalf("Load() source %q error = %v", configuredSource, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendFile(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.WriteString(contents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAcceptsInstallationModelCatalog(t *testing.T) {
|
||||
path, _, _, _ := writeInstallation(t, "local")
|
||||
rewriteInstallationCatalog(t, path, validModelCatalogYAML())
|
||||
|
||||
installation, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if installation.SchemaVersion != 2 {
|
||||
t.Fatalf("SchemaVersion = %d, want 2", installation.SchemaVersion)
|
||||
}
|
||||
if installation.ModelCatalog.Defaults.Session != "zai/glm-5.3" {
|
||||
t.Fatalf("session default = %q", installation.ModelCatalog.Defaults.Session)
|
||||
}
|
||||
models := installation.ModelCatalog.RuntimeModels()
|
||||
if len(models) != 2 || models[0].ID != "local/qwen" || models[1].ID != "zai/glm-5.3" {
|
||||
t.Fatalf("RuntimeModels() = %#v", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsLegacyMetadataGenerationWithMigrationRequired(t *testing.T) {
|
||||
path, _, _, _ := writeInstallation(t, "local")
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents = append(contents, []byte("metadataGeneration:\n default: legacy\n models: []\n")...)
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "migration_required") {
|
||||
t.Fatalf("Load() error = %v, want migration_required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsLegacyPiCatalogSourcesWithMigrationRequired(t *testing.T) {
|
||||
path, projectDirectory, _, _ := writeInstallation(t, "local")
|
||||
legacyDirectory := filepath.Join(projectDirectory, "deploy", "pi")
|
||||
if err := os.MkdirAll(legacyDirectory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(legacyDirectory, "models.json"), []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "migration_required") {
|
||||
t.Fatalf("Load() error = %v, want migration_required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidModelCatalogDefaultsAndAuthentication(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
catalog string
|
||||
wantError string
|
||||
secretLine string
|
||||
}{
|
||||
{
|
||||
name: "unknown session default",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), "session: zai/glm-5.3", "session: zai/missing", 1),
|
||||
wantError: "defaults.session",
|
||||
},
|
||||
{
|
||||
name: "embedding must use the installation Ollama service",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), "id: ollama/qwen3-embedding:0.6b", "id: external/qwen3-embedding:0.6b", 1),
|
||||
wantError: "ollama canonical id",
|
||||
},
|
||||
{
|
||||
name: "metadata default required",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), " metadataGeneration: local/qwen\n", "", 1),
|
||||
wantError: "defaults.metadataGeneration",
|
||||
},
|
||||
{
|
||||
name: "pi auth cannot serve metadata",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), "mode: none", "mode: pi_auth", 1),
|
||||
wantError: "pi_auth",
|
||||
},
|
||||
{
|
||||
name: "none requires endpoint",
|
||||
catalog: strings.Replace(strings.Replace(validModelCatalogYAML(), "mode: secret_env\n apiKeyEnv: ZAI_API_KEY", "mode: none", 1), " endpoint:\n baseUrl: https://api.z.ai/v1\n", "", 1),
|
||||
wantError: "explicit endpoint",
|
||||
},
|
||||
{
|
||||
name: "secret must exist",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), "mode: none", "mode: secret_env\n apiKeyEnv: ZAI_API_KEY", 1),
|
||||
wantError: "ZAI_API_KEY",
|
||||
secretLine: "ZAI_API_KEY=\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path, _, envFile, _ := writeInstallation(t, "local")
|
||||
rewriteInstallationCatalog(t, path, test.catalog)
|
||||
if test.secretLine != "" {
|
||||
secretPath := filepath.Join(filepath.Dir(path), "secrets.env")
|
||||
if err := os.WriteFile(secretPath, []byte(test.secretLine), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(envFile, []byte("THT_AUTH_CONFIG_ROOT="+filepath.Join(filepath.Dir(path), "auth")+"\nTHT_INSTALLATION_CONFIG_SOURCE="+path+"\nTHT_SECRETS_FILE="+secretPath+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, err := Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("Load() error = %v, want substring %q", err, test.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteInstallationCatalog(t *testing.T, path, catalog string) {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := strings.Split(string(contents), "modelCatalog:\n")[0]
|
||||
text += catalog
|
||||
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func validModelCatalogYAML() string {
|
||||
return `modelCatalog:
|
||||
defaults:
|
||||
session: zai/glm-5.3
|
||||
metadataGeneration: local/qwen
|
||||
embedding:
|
||||
id: ollama/qwen3-embedding:0.6b
|
||||
dimensions: 1024
|
||||
providers:
|
||||
zai:
|
||||
endpoint:
|
||||
baseUrl: https://api.z.ai/v1
|
||||
authentication:
|
||||
mode: none
|
||||
session:
|
||||
mode: openai_compatible
|
||||
models:
|
||||
glm-5.3:
|
||||
session:
|
||||
reasoning: true
|
||||
contextWindow: 200000
|
||||
maxTokens: 131072
|
||||
local:
|
||||
endpoint:
|
||||
baseUrl: http://ollama:11434/v1
|
||||
authentication:
|
||||
mode: none
|
||||
session:
|
||||
mode: openai_compatible
|
||||
metadataGeneration:
|
||||
litellmProvider: openai
|
||||
models:
|
||||
qwen:
|
||||
session:
|
||||
contextWindow: 32768
|
||||
maxTokens: 8192
|
||||
metadataGeneration:
|
||||
disableThinking: true
|
||||
`
|
||||
}
|
||||
@@ -31,6 +31,7 @@ func TestLoadSelectsLocalComposeFilesForAnInstallationInPathsWithSpaces(t *testi
|
||||
filepath.Join(projectDirectory, "compose.yaml"),
|
||||
filepath.Join(projectDirectory, "deploy", "compose.local.yaml"),
|
||||
override,
|
||||
installation.ModelProjectionComposePath(),
|
||||
}
|
||||
assertStringsEqual(t, installation.ComposeFiles(), want)
|
||||
}
|
||||
@@ -48,6 +49,7 @@ func TestLoadSelectsServerComposeFiles(t *testing.T) {
|
||||
filepath.Join(projectDirectory, "compose.yaml"),
|
||||
filepath.Join(projectDirectory, "deploy", "compose.server.yaml"),
|
||||
override,
|
||||
installation.ModelProjectionComposePath(),
|
||||
}
|
||||
assertStringsEqual(t, installation.ComposeFiles(), want)
|
||||
}
|
||||
@@ -91,6 +93,7 @@ func TestLoadAcceptsServerRuntimeProjectionAndPlacesAutomaticOverrideBeforeCurre
|
||||
filepath.Join(projectDirectory, "compose.yaml"),
|
||||
filepath.Join(projectDirectory, "deploy", "compose.server.yaml"),
|
||||
override,
|
||||
installation.ModelProjectionComposePath(),
|
||||
automaticOverride,
|
||||
currentImage,
|
||||
}
|
||||
@@ -175,11 +178,11 @@ func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *te
|
||||
if err := os.WriteFile(envFile, []byte(environment), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := "profile: local\nprojectDirectory: " + projectDirectory +
|
||||
contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectDirectory +
|
||||
"\nenvFile: " + envFile +
|
||||
"\nauthentication:\n configDirectory: " + filepath.Join(filepath.Dir(envFile), "auth") +
|
||||
"\nworkspaceRepository:\n remote: " + remote +
|
||||
"\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n"
|
||||
"\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n" + minimalModelCatalogYAML()
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -201,9 +204,10 @@ func TestLoadRejectsGitOverrideWithoutTypedWorkspaceRepository(t *testing.T) {
|
||||
if err := os.WriteFile(gitOverride, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := "profile: local\nprojectDirectory: " + projectDirectory +
|
||||
contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectDirectory +
|
||||
"\nenvFile: " + envFile + "\noverrides:\n - " + gitOverride + "\n"
|
||||
contents = strings.Replace(contents, "\noverrides:", "\nauthentication:\n configDirectory: "+filepath.Join(filepath.Dir(envFile), "auth")+"\noverrides:", 1)
|
||||
contents += minimalModelCatalogYAML()
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -388,10 +392,11 @@ func writeRuntimeProjectionFixture(t *testing.T, installationPath, envFile, prof
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectDirectory := filepath.Join(filepath.Dir(installationPath), "project directory with spaces")
|
||||
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + configDirectory + "\n runtimeProjection:\n directory: " + runtimeDirectory + "\n uid: " + strconv.FormatUint(uint64(uid), 10) + "\n gid: " + strconv.FormatUint(uint64(gid), 10) + "\noverrides:\n"
|
||||
contents := "schemaVersion: 2\nprofile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + configDirectory + "\n runtimeProjection:\n directory: " + runtimeDirectory + "\n uid: " + strconv.FormatUint(uint64(uid), 10) + "\n gid: " + strconv.FormatUint(uint64(gid), 10) + "\noverrides:\n"
|
||||
for _, override := range overrides {
|
||||
contents += " - " + override + "\n"
|
||||
}
|
||||
contents += minimalModelCatalogYAML()
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -429,13 +434,32 @@ func writeInstallation(t *testing.T, profile string) (string, string, string, st
|
||||
t.Fatal(err)
|
||||
}
|
||||
installationPath := filepath.Join(root, "thothii-installation.yaml")
|
||||
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\noverrides:\n - " + override + "\n"
|
||||
contents := "schemaVersion: 2\nprofile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\noverrides:\n - " + override + "\n" + minimalModelCatalogYAML()
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return installationPath, projectDirectory, envFile, override
|
||||
}
|
||||
|
||||
func minimalModelCatalogYAML() string {
|
||||
return `modelCatalog:
|
||||
defaults:
|
||||
session: deepseek/deepseek-v4-pro
|
||||
embedding:
|
||||
id: ollama/qwen3-embedding:0.6b
|
||||
dimensions: 1024
|
||||
providers:
|
||||
deepseek:
|
||||
authentication:
|
||||
mode: pi_auth
|
||||
session:
|
||||
mode: pi_builtin
|
||||
models:
|
||||
deepseek-v4-pro:
|
||||
session: {}
|
||||
`
|
||||
}
|
||||
|
||||
func assertStringsEqual(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxCatalogModels = 64
|
||||
|
||||
var catalogKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`)
|
||||
var catalogModelIDPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$`)
|
||||
var catalogAPIVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
// ModelCatalog is the only operator-authored source for model identity and runtime eligibility.
|
||||
type ModelCatalog struct {
|
||||
Defaults ModelCatalogDefaults `yaml:"defaults" json:"defaults"`
|
||||
Embedding ModelCatalogEmbedding `yaml:"embedding" json:"embedding"`
|
||||
Providers map[string]ModelProvider `yaml:"providers" json:"providers"`
|
||||
}
|
||||
|
||||
type ModelCatalogDefaults struct {
|
||||
Session string `yaml:"session" json:"session"`
|
||||
MetadataGeneration string `yaml:"metadataGeneration,omitempty" json:"metadataGeneration,omitempty"`
|
||||
}
|
||||
|
||||
type ModelCatalogEmbedding struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Dimensions int `yaml:"dimensions" json:"dimensions"`
|
||||
}
|
||||
|
||||
type ModelEndpoint struct {
|
||||
BaseURL string `yaml:"baseUrl" json:"baseUrl"`
|
||||
APIVersion string `yaml:"apiVersion,omitempty" json:"apiVersion,omitempty"`
|
||||
}
|
||||
|
||||
type ModelAuthentication struct {
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
APIKeyEnv string `yaml:"apiKeyEnv,omitempty" json:"apiKeyEnv,omitempty"`
|
||||
}
|
||||
|
||||
type ModelSessionAdapter struct {
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
}
|
||||
|
||||
type ModelMetadataAdapter struct {
|
||||
LiteLLMProvider string `yaml:"litellmProvider" json:"litellmProvider"`
|
||||
}
|
||||
|
||||
type ModelProvider struct {
|
||||
Endpoint *ModelEndpoint `yaml:"endpoint,omitempty" json:"endpoint,omitempty"`
|
||||
Authentication ModelAuthentication `yaml:"authentication" json:"authentication"`
|
||||
Session *ModelSessionAdapter `yaml:"session,omitempty" json:"session,omitempty"`
|
||||
MetadataGeneration *ModelMetadataAdapter `yaml:"metadataGeneration,omitempty" json:"metadataGeneration,omitempty"`
|
||||
Models map[string]CatalogModel `yaml:"models" json:"models"`
|
||||
}
|
||||
|
||||
type ModelCost struct {
|
||||
Input float64 `yaml:"input" json:"input"`
|
||||
Output float64 `yaml:"output" json:"output"`
|
||||
CacheRead float64 `yaml:"cacheRead" json:"cacheRead"`
|
||||
CacheWrite float64 `yaml:"cacheWrite" json:"cacheWrite"`
|
||||
}
|
||||
|
||||
type ModelCompatibility struct {
|
||||
SupportsDeveloperRole bool `yaml:"supportsDeveloperRole" json:"supportsDeveloperRole"`
|
||||
SupportsReasoningEffort bool `yaml:"supportsReasoningEffort" json:"supportsReasoningEffort"`
|
||||
SupportsStore bool `yaml:"supportsStore" json:"supportsStore"`
|
||||
MaxTokensField string `yaml:"maxTokensField" json:"maxTokensField,omitempty"`
|
||||
}
|
||||
|
||||
type SessionModel struct {
|
||||
Reasoning bool `yaml:"reasoning,omitempty" json:"reasoning"`
|
||||
Input []string `yaml:"input,omitempty" json:"input,omitempty"`
|
||||
Cost *ModelCost `yaml:"cost,omitempty" json:"cost,omitempty"`
|
||||
ContextWindow int `yaml:"contextWindow,omitempty" json:"contextWindow,omitempty"`
|
||||
MaxTokens int `yaml:"maxTokens,omitempty" json:"maxTokens,omitempty"`
|
||||
Compatibility *ModelCompatibility `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
|
||||
}
|
||||
|
||||
type MetadataGenerationModel struct {
|
||||
DisableThinking bool `yaml:"disableThinking,omitempty" json:"disableThinking"`
|
||||
}
|
||||
|
||||
type CatalogModel struct {
|
||||
Label string `yaml:"label,omitempty" json:"label,omitempty"`
|
||||
UpstreamModel string `yaml:"upstreamModel,omitempty" json:"upstreamModel,omitempty"`
|
||||
Session *SessionModel `yaml:"session,omitempty" json:"session,omitempty"`
|
||||
MetadataGeneration *MetadataGenerationModel `yaml:"metadataGeneration,omitempty" json:"metadataGeneration,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeModel is the flattened, canonical representation shared by runtime projections.
|
||||
type RuntimeModel struct {
|
||||
ID string
|
||||
Provider string
|
||||
Model string
|
||||
Label string
|
||||
UpstreamModel string
|
||||
Endpoint *ModelEndpoint
|
||||
Authentication ModelAuthentication
|
||||
SessionAdapter *ModelSessionAdapter
|
||||
MetadataAdapter *ModelMetadataAdapter
|
||||
Session *SessionModel
|
||||
MetadataGeneration *MetadataGenerationModel
|
||||
}
|
||||
|
||||
// RuntimeModels returns all catalog models in canonical identity order.
|
||||
func (c ModelCatalog) RuntimeModels() []RuntimeModel {
|
||||
models := make([]RuntimeModel, 0)
|
||||
for providerID, provider := range c.Providers {
|
||||
for modelID, model := range provider.Models {
|
||||
id := providerID + "/" + modelID
|
||||
label := model.Label
|
||||
if label == "" {
|
||||
label = id
|
||||
}
|
||||
upstream := model.UpstreamModel
|
||||
if upstream == "" {
|
||||
upstream = modelID
|
||||
}
|
||||
models = append(models, RuntimeModel{
|
||||
ID: id, Provider: providerID, Model: modelID, Label: label,
|
||||
UpstreamModel: upstream, Endpoint: provider.Endpoint,
|
||||
Authentication: provider.Authentication, SessionAdapter: provider.Session,
|
||||
MetadataAdapter: provider.MetadataGeneration, Session: model.Session,
|
||||
MetadataGeneration: model.MetadataGeneration,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(models, func(left, right int) bool { return models[left].ID < models[right].ID })
|
||||
return models
|
||||
}
|
||||
|
||||
// Validate rejects ambiguous, duplicated, or runtime-incompatible catalog declarations.
|
||||
func (c ModelCatalog) Validate(environment map[string]string) error {
|
||||
if c.Defaults.Session == "" {
|
||||
return errors.New("modelCatalog.defaults.session is required")
|
||||
}
|
||||
if !catalogModelIDPattern.MatchString(c.Embedding.ID) || !strings.HasPrefix(c.Embedding.ID, "ollama/") || c.Embedding.Dimensions <= 0 {
|
||||
return errors.New("modelCatalog.embedding requires an ollama canonical id and positive dimensions")
|
||||
}
|
||||
if len(c.Providers) == 0 {
|
||||
return errors.New("modelCatalog.providers must not be empty")
|
||||
}
|
||||
if countCatalogModels(c) > maxCatalogModels {
|
||||
return fmt.Errorf("modelCatalog must contain at most %d models", maxCatalogModels)
|
||||
}
|
||||
|
||||
hasMetadata := false
|
||||
secretsNeeded := make(map[string][]string)
|
||||
for providerID, provider := range c.Providers {
|
||||
if !catalogKeyPattern.MatchString(providerID) {
|
||||
return fmt.Errorf("modelCatalog provider %q is invalid", providerID)
|
||||
}
|
||||
if len(provider.Models) == 0 {
|
||||
return fmt.Errorf("modelCatalog provider %q has no models", providerID)
|
||||
}
|
||||
if err := validateCatalogEndpoint(providerID, provider.Endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
hasSession, providerHasMetadata := false, false
|
||||
for modelID, model := range provider.Models {
|
||||
if !catalogKeyPattern.MatchString(modelID) {
|
||||
return fmt.Errorf("modelCatalog model %q/%q is invalid", providerID, modelID)
|
||||
}
|
||||
canonical := providerID + "/" + modelID
|
||||
if model.Session == nil && model.MetadataGeneration == nil {
|
||||
return fmt.Errorf("modelCatalog model %q has no runtime use", canonical)
|
||||
}
|
||||
if model.Label != "" && (len(model.Label) > 128 || strings.TrimSpace(model.Label) != model.Label || strings.IndexFunc(model.Label, unicode.IsControl) >= 0) {
|
||||
return fmt.Errorf("modelCatalog model %q label is invalid", canonical)
|
||||
}
|
||||
if model.Session != nil {
|
||||
hasSession = true
|
||||
}
|
||||
if model.MetadataGeneration != nil {
|
||||
hasMetadata, providerHasMetadata = true, true
|
||||
if model.MetadataGeneration.DisableThinking && provider.Endpoint == nil {
|
||||
return fmt.Errorf("modelCatalog model %q disableThinking requires an explicit endpoint", canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := validateCatalogProvider(providerID, provider, hasSession, providerHasMetadata); err != nil {
|
||||
return err
|
||||
}
|
||||
if provider.Authentication.Mode == "secret_env" {
|
||||
secretsNeeded[provider.Authentication.APIKeyEnv] = append(secretsNeeded[provider.Authentication.APIKeyEnv], providerID)
|
||||
}
|
||||
}
|
||||
|
||||
models := c.RuntimeModels()
|
||||
if !runtimeModelEligible(models, c.Defaults.Session, "session") {
|
||||
return fmt.Errorf("modelCatalog.defaults.session %q is not a session model", c.Defaults.Session)
|
||||
}
|
||||
if hasMetadata && c.Defaults.MetadataGeneration == "" {
|
||||
return errors.New("modelCatalog.defaults.metadataGeneration is required when metadata models are configured")
|
||||
}
|
||||
if !hasMetadata && c.Defaults.MetadataGeneration != "" {
|
||||
return errors.New("modelCatalog.defaults.metadataGeneration must be empty when metadata generation is unavailable")
|
||||
}
|
||||
if hasMetadata && !runtimeModelEligible(models, c.Defaults.MetadataGeneration, "metadata") {
|
||||
return fmt.Errorf("modelCatalog.defaults.metadataGeneration %q is not a metadata-generation model", c.Defaults.MetadataGeneration)
|
||||
}
|
||||
return validateCatalogSecrets(environment, secretsNeeded)
|
||||
}
|
||||
|
||||
func validateCatalogProvider(id string, provider ModelProvider, hasSession, hasMetadata bool) error {
|
||||
auth := provider.Authentication
|
||||
switch auth.Mode {
|
||||
case "secret_env":
|
||||
if !metadataSecretBundleKeyPattern.MatchString(auth.APIKeyEnv) {
|
||||
return fmt.Errorf("modelCatalog provider %q authentication.apiKeyEnv is invalid", id)
|
||||
}
|
||||
if _, allowed := metadataAPIKeyEnvironments[auth.APIKeyEnv]; !allowed {
|
||||
return fmt.Errorf("modelCatalog provider %q authentication.apiKeyEnv is invalid", id)
|
||||
}
|
||||
case "pi_auth":
|
||||
if auth.APIKeyEnv != "" || hasMetadata || !hasSession || provider.Session == nil || provider.Session.Mode != "pi_builtin" {
|
||||
return fmt.Errorf("modelCatalog provider %q pi_auth is valid only for session-only pi_builtin providers", id)
|
||||
}
|
||||
case "none":
|
||||
if auth.APIKeyEnv != "" || provider.Endpoint == nil {
|
||||
return fmt.Errorf("modelCatalog provider %q authentication none requires an explicit endpoint", id)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("modelCatalog provider %q authentication.mode is invalid", id)
|
||||
}
|
||||
if hasSession {
|
||||
if provider.Session == nil || (provider.Session.Mode != "pi_builtin" && provider.Session.Mode != "openai_compatible") {
|
||||
return fmt.Errorf("modelCatalog provider %q requires a supported session adapter", id)
|
||||
}
|
||||
if provider.Session.Mode == "openai_compatible" && provider.Endpoint == nil {
|
||||
return fmt.Errorf("modelCatalog provider %q openai_compatible requires an explicit endpoint", id)
|
||||
}
|
||||
if provider.Session.Mode == "pi_builtin" {
|
||||
for modelID, model := range provider.Models {
|
||||
if model.Session != nil && (model.Session.ContextWindow != 0 || model.Session.MaxTokens != 0 || len(model.Session.Input) != 0 || model.Session.Cost != nil || model.Session.Compatibility != nil || model.Session.Reasoning) {
|
||||
return fmt.Errorf("modelCatalog model %q/%q must use an empty session block for pi_builtin", id, modelID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for modelID, model := range provider.Models {
|
||||
if model.Session != nil && (model.Session.ContextWindow <= 0 || model.Session.MaxTokens <= 0) {
|
||||
return fmt.Errorf("modelCatalog model %q/%q requires contextWindow and maxTokens", id, modelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if provider.Session != nil {
|
||||
return fmt.Errorf("modelCatalog provider %q has a session adapter but no session models", id)
|
||||
}
|
||||
if hasMetadata {
|
||||
if provider.MetadataGeneration == nil || provider.MetadataGeneration.LiteLLMProvider == "" {
|
||||
return fmt.Errorf("modelCatalog provider %q requires metadataGeneration.litellmProvider", id)
|
||||
}
|
||||
} else if provider.MetadataGeneration != nil {
|
||||
return fmt.Errorf("modelCatalog provider %q has a metadata adapter but no metadata models", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCatalogEndpoint(provider string, endpoint *ModelEndpoint) error {
|
||||
if endpoint == nil {
|
||||
return 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("modelCatalog provider %q endpoint.baseUrl is invalid", provider)
|
||||
}
|
||||
if endpoint.APIVersion != "" && !catalogAPIVersionPattern.MatchString(endpoint.APIVersion) {
|
||||
return fmt.Errorf("modelCatalog provider %q endpoint.apiVersion is invalid", provider)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCatalogSecrets(environment map[string]string, needed map[string][]string) error {
|
||||
if len(needed) == 0 {
|
||||
return nil
|
||||
}
|
||||
bundle := environment["THT_SECRETS_FILE"]
|
||||
if bundle == "" {
|
||||
return errors.New("modelCatalog secret_env providers require THT_SECRETS_FILE")
|
||||
}
|
||||
secrets, err := readMetadataGenerationSecrets(bundle)
|
||||
if err != nil {
|
||||
return errors.New("modelCatalog secrets in THT_SECRETS_FILE are unavailable or invalid")
|
||||
}
|
||||
for key := range needed {
|
||||
value, exists := secrets[key]
|
||||
if !exists || !usableMetadataGenerationSecret(value) {
|
||||
return fmt.Errorf("modelCatalog secret %q is missing or unusable", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeModelEligible(models []RuntimeModel, id, usage string) bool {
|
||||
for _, model := range models {
|
||||
if model.ID == id && ((usage == "session" && model.Session != nil) || (usage == "metadata" && model.MetadataGeneration != nil)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countCatalogModels(c ModelCatalog) int {
|
||||
count := 0
|
||||
for _, provider := range c.Providers {
|
||||
count += len(provider.Models)
|
||||
}
|
||||
return count
|
||||
}
|
||||
Reference in New Issue
Block a user