Files
ThothII/tools/tht/internal/config/model_catalog.go
T
Codex 84084bba37
Publish documentation / publish (push) Successful in 30s
Fix Qwen session tool calls and expose thinking compatibility
2026-09-21 16:23:51 +02:00

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
}