349 lines
14 KiB
Go
349 lines
14 KiB
Go
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 catalogUpstreamModelPattern = regexp.MustCompile(`^[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 {
|
|
Interaction string `yaml:"interaction" json:"interaction"`
|
|
// Legacy input only. NormalizeDefaults clears these before projections or serialization.
|
|
Session string `yaml:"session,omitempty" json:"-"`
|
|
MetadataGeneration string `yaml:"metadataGeneration,omitempty" json:"-"`
|
|
}
|
|
|
|
// NormalizeDefaults accepts unambiguous legacy configuration without rewriting the source file.
|
|
func (c *ModelCatalog) NormalizeDefaults() error {
|
|
d := &c.Defaults
|
|
if d.Interaction != "" && (d.Session != "" || d.MetadataGeneration != "") {
|
|
return errors.New("migration_required: use only modelCatalog.defaults.interaction, not legacy defaults")
|
|
}
|
|
if d.Interaction == "" {
|
|
if d.Session != "" && d.MetadataGeneration != "" && d.Session != d.MetadataGeneration {
|
|
return errors.New("migration_required: session and metadataGeneration defaults differ; explicitly choose modelCatalog.defaults.interaction and remove both legacy defaults")
|
|
}
|
|
d.Interaction = d.Session
|
|
}
|
|
d.Session, d.MetadataGeneration = "", ""
|
|
return nil
|
|
}
|
|
|
|
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"`
|
|
ThinkingFormat string `yaml:"thinkingFormat,omitempty" json:"thinkingFormat,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.Interaction == "" {
|
|
return errors.New("modelCatalog.defaults.interaction 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
|
|
upstreamModels := make(map[string]string)
|
|
for modelID, model := range provider.Models {
|
|
if !catalogKeyPattern.MatchString(modelID) {
|
|
return fmt.Errorf("modelCatalog model %q/%q is invalid", providerID, modelID)
|
|
}
|
|
canonical := providerID + "/" + modelID
|
|
upstream := model.UpstreamModel
|
|
if upstream == "" {
|
|
upstream = modelID
|
|
}
|
|
if !catalogUpstreamModelPattern.MatchString(upstream) {
|
|
return fmt.Errorf("modelCatalog model %q upstreamModel is invalid", canonical)
|
|
}
|
|
if existing, duplicate := upstreamModels[upstream]; duplicate {
|
|
return fmt.Errorf("modelCatalog models %q and %q use the same upstreamModel", existing, canonical)
|
|
}
|
|
upstreamModels[upstream] = canonical
|
|
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.Interaction, "session") ||
|
|
(hasMetadata && !runtimeModelEligible(models, c.Defaults.Interaction, "metadata")) {
|
|
return fmt.Errorf("modelCatalog.defaults.interaction %q must support Core and every configured Admin LLM use", c.Defaults.Interaction)
|
|
}
|
|
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)
|
|
}
|
|
if model.Session != nil && model.Session.Compatibility != nil {
|
|
format := model.Session.Compatibility.ThinkingFormat
|
|
if format != "" && format != "qwen" && format != "qwen-chat-template" {
|
|
return fmt.Errorf("modelCatalog model %q/%q compatibility.thinkingFormat is invalid", id, modelID)
|
|
}
|
|
if format != "" && !model.Session.Reasoning {
|
|
return fmt.Errorf("modelCatalog model %q/%q compatibility.thinkingFormat requires reasoning: true", 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
|
|
}
|