feat: unify installation model catalog
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
// Package modelprojection renders disposable runtime adapters from the installation model catalog.
|
||||
package modelprojection
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
CatalogFile = "catalog.json"
|
||||
PiModelsFile = "pi/models.json"
|
||||
PiSettingsFile = "pi/settings.json"
|
||||
ComposeFile = "compose.models.yaml"
|
||||
)
|
||||
|
||||
type runtimeCatalog struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
DefaultSession string `json:"defaultSession"`
|
||||
DefaultMetadataGeneration string `json:"defaultMetadataGeneration,omitempty"`
|
||||
Embedding config.ModelCatalogEmbedding `json:"embedding"`
|
||||
Models []runtimeCatalogModel `json:"models"`
|
||||
}
|
||||
|
||||
type runtimeCatalogModel struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Label string `json:"label"`
|
||||
UpstreamModel string `json:"upstreamModel"`
|
||||
Endpoint *config.ModelEndpoint `json:"endpoint,omitempty"`
|
||||
Authentication config.ModelAuthentication `json:"authentication"`
|
||||
SessionAdapter *config.ModelSessionAdapter `json:"sessionAdapter,omitempty"`
|
||||
MetadataAdapter *config.ModelMetadataAdapter `json:"metadataAdapter,omitempty"`
|
||||
Session *config.SessionModel `json:"session,omitempty"`
|
||||
MetadataGeneration *config.MetadataGenerationModel `json:"metadataGeneration,omitempty"`
|
||||
}
|
||||
|
||||
type piModels struct {
|
||||
Providers map[string]piProvider `json:"providers"`
|
||||
}
|
||||
|
||||
type piProvider struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
API string `json:"api"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Models []piModel `json:"models"`
|
||||
}
|
||||
|
||||
type piModel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Reasoning bool `json:"reasoning"`
|
||||
Input []string `json:"input,omitempty"`
|
||||
Cost *config.ModelCost `json:"cost,omitempty"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
Compat *config.ModelCompatibility `json:"compat,omitempty"`
|
||||
}
|
||||
|
||||
type piSettings struct {
|
||||
DefaultProjectTrust string `json:"defaultProjectTrust"`
|
||||
EnabledModels []string `json:"enabledModels"`
|
||||
}
|
||||
|
||||
// Render builds every projection in memory so no partial candidate can be published.
|
||||
func Render(installation config.Installation) (map[string][]byte, error) {
|
||||
models := installation.ModelCatalog.RuntimeModels()
|
||||
runtimeModels := make([]runtimeCatalogModel, 0, len(models))
|
||||
customProviders := make(map[string]piProvider)
|
||||
enabled := make([]string, 0)
|
||||
for _, model := range models {
|
||||
runtimeModels = append(runtimeModels, runtimeCatalogModel{
|
||||
ID: model.ID, Provider: model.Provider, Model: model.Model, Label: model.Label,
|
||||
UpstreamModel: model.UpstreamModel, Endpoint: model.Endpoint,
|
||||
Authentication: model.Authentication, SessionAdapter: model.SessionAdapter,
|
||||
MetadataAdapter: model.MetadataAdapter, Session: model.Session,
|
||||
MetadataGeneration: model.MetadataGeneration,
|
||||
})
|
||||
if model.Session == nil {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, model.ID)
|
||||
if model.SessionAdapter == nil || model.SessionAdapter.Mode != "openai_compatible" {
|
||||
continue
|
||||
}
|
||||
provider := customProviders[model.Provider]
|
||||
provider.BaseURL = model.Endpoint.BaseURL
|
||||
provider.API = "openai-completions"
|
||||
if model.Authentication.Mode == "secret_env" {
|
||||
provider.APIKey = "$" + model.Authentication.APIKeyEnv
|
||||
} else {
|
||||
provider.APIKey = "local"
|
||||
}
|
||||
provider.Models = append(provider.Models, piModel{
|
||||
ID: model.UpstreamModel, Name: model.Label, Reasoning: model.Session.Reasoning,
|
||||
Input: model.Session.Input, Cost: model.Session.Cost,
|
||||
ContextWindow: model.Session.ContextWindow, MaxTokens: model.Session.MaxTokens,
|
||||
Compat: model.Session.Compatibility,
|
||||
})
|
||||
customProviders[model.Provider] = provider
|
||||
}
|
||||
for id, provider := range customProviders {
|
||||
sort.Slice(provider.Models, func(left, right int) bool { return provider.Models[left].ID < provider.Models[right].ID })
|
||||
customProviders[id] = provider
|
||||
}
|
||||
sort.Strings(enabled)
|
||||
|
||||
catalogBytes, err := marshalJSON(runtimeCatalog{
|
||||
SchemaVersion: 1, DefaultSession: installation.ModelCatalog.Defaults.Session,
|
||||
DefaultMetadataGeneration: installation.ModelCatalog.Defaults.MetadataGeneration,
|
||||
Embedding: installation.ModelCatalog.Embedding, Models: runtimeModels,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render runtime model catalog: %w", err)
|
||||
}
|
||||
piModelsBytes, err := marshalJSON(piModels{Providers: customProviders})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render Pi model projection: %w", err)
|
||||
}
|
||||
piSettingsBytes, err := marshalJSON(piSettings{DefaultProjectTrust: "always", EnabledModels: enabled})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render Pi settings projection: %w", err)
|
||||
}
|
||||
fingerprint := sha256.Sum256(bytes.Join([][]byte{catalogBytes, piModelsBytes, piSettingsBytes}, nil))
|
||||
composeBytes := renderCompose(installation, fmt.Sprintf("sha256:%x", fingerprint))
|
||||
return map[string][]byte{
|
||||
CatalogFile: catalogBytes, PiModelsFile: piModelsBytes,
|
||||
PiSettingsFile: piSettingsBytes, ComposeFile: composeBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate atomically replaces each generated adapter after the complete candidate has rendered.
|
||||
func Generate(installation config.Installation) error {
|
||||
artifacts, err := Render(installation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := sortedArtifactPaths(artifacts)
|
||||
for _, relative := range paths {
|
||||
destination := filepath.Join(installation.GeneratedDirectory(), filepath.FromSlash(relative))
|
||||
// The generated catalog contains references to secret environment variable names, never
|
||||
// secret values. Core runs as an unprivileged container user and must be able to traverse
|
||||
// the bind-mounted host directories and read the projections.
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
return fmt.Errorf("create model projection directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(filepath.Dir(destination), 0o755); err != nil {
|
||||
return fmt.Errorf("protect model projection directory: %w", err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(destination), ".projection-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create model projection candidate: %w", err)
|
||||
}
|
||||
temporaryName := temporary.Name()
|
||||
published := false
|
||||
defer func() {
|
||||
if !published {
|
||||
_ = os.Remove(temporaryName)
|
||||
}
|
||||
}()
|
||||
if err := temporary.Chmod(0o644); err == nil {
|
||||
_, err = temporary.Write(artifacts[relative])
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("write model projection candidate: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryName, destination); err != nil {
|
||||
return fmt.Errorf("publish model projection: %w", err)
|
||||
}
|
||||
published = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check returns relative artifact names whose current bytes differ from the catalog projection.
|
||||
func Check(installation config.Installation) ([]string, error) {
|
||||
artifacts, err := Render(installation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
drift := make([]string, 0)
|
||||
for _, relative := range sortedArtifactPaths(artifacts) {
|
||||
actual, readErr := os.ReadFile(filepath.Join(installation.GeneratedDirectory(), filepath.FromSlash(relative)))
|
||||
if readErr != nil || !bytes.Equal(actual, artifacts[relative]) {
|
||||
drift = append(drift, relative)
|
||||
}
|
||||
}
|
||||
return drift, nil
|
||||
}
|
||||
|
||||
func marshalJSON(value any) ([]byte, error) {
|
||||
contents, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(contents, '\n'), nil
|
||||
}
|
||||
|
||||
func renderCompose(installation config.Installation, fingerprint string) []byte {
|
||||
embedding := installation.ModelCatalog.Embedding
|
||||
return []byte(fmt.Sprintf(`services:
|
||||
core:
|
||||
environment:
|
||||
THT_MODEL_CATALOG_FILE: /run/thothii-model-catalog/catalog.json
|
||||
THT_MODEL_CATALOG_REVISION: %s
|
||||
THT_DEFAULT_SESSION_MODEL: %s
|
||||
THT_INTERNAL_EMBEDDING_ID: %s
|
||||
THT_INTERNAL_EMBEDDING_MODEL: %s
|
||||
THT_INTERNAL_EMBEDDING_DIMENSIONS: %s
|
||||
volumes:
|
||||
- type: bind
|
||||
source: %s
|
||||
target: /run/thothii-model-catalog/catalog.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: %s
|
||||
target: /home/thoth/.pi/agent/models.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: %s
|
||||
target: /home/thoth/.pi/agent/settings.json
|
||||
read_only: true
|
||||
workspace-maintenance:
|
||||
environment:
|
||||
THT_INTERNAL_EMBEDDING_ID: %s
|
||||
THT_INTERNAL_EMBEDDING_MODEL: %s
|
||||
THT_INTERNAL_EMBEDDING_DIMENSIONS: %s
|
||||
embedding-model-init:
|
||||
environment:
|
||||
OLLAMA_MODEL: %s
|
||||
`, strconv.Quote(fingerprint), strconv.Quote(installation.ModelCatalog.Defaults.Session),
|
||||
strconv.Quote(embedding.ID), strconv.Quote(embeddingModelName(embedding.ID)), strconv.Quote(strconv.Itoa(embedding.Dimensions)),
|
||||
strconv.Quote(installation.GeneratedModelCatalogPath()), strconv.Quote(installation.GeneratedPiModelsPath()),
|
||||
strconv.Quote(installation.GeneratedPiSettingsPath()), strconv.Quote(embedding.ID),
|
||||
strconv.Quote(embeddingModelName(embedding.ID)), strconv.Quote(strconv.Itoa(embedding.Dimensions)),
|
||||
strconv.Quote(embeddingModelName(embedding.ID))))
|
||||
}
|
||||
|
||||
func embeddingModelName(canonical string) string {
|
||||
for index := 0; index < len(canonical); index++ {
|
||||
if canonical[index] == '/' {
|
||||
return canonical[index+1:]
|
||||
}
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
func sortedArtifactPaths(artifacts map[string][]byte) []string {
|
||||
paths := make([]string, 0, len(artifacts))
|
||||
for path := range artifacts {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package modelprojection
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
)
|
||||
|
||||
func TestRenderProducesDeterministicCatalogPiAndComposeProjections(t *testing.T) {
|
||||
installation := projectionFixture(t)
|
||||
|
||||
first, err := Render(installation)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
second, err := Render(installation)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() second error = %v", err)
|
||||
}
|
||||
if len(first) != 4 {
|
||||
t.Fatalf("artifact count = %d, want 4", len(first))
|
||||
}
|
||||
for path, contents := range first {
|
||||
if !bytes.Equal(contents, second[path]) {
|
||||
t.Fatalf("artifact %q is not deterministic", path)
|
||||
}
|
||||
}
|
||||
|
||||
catalog := string(first[CatalogFile])
|
||||
for _, expected := range []string{`"id": "local/qwen"`, `"defaultSession": "zai/glm-5.3"`, `"embedding"`} {
|
||||
if !strings.Contains(catalog, expected) {
|
||||
t.Fatalf("catalog projection missing %s:\n%s", expected, catalog)
|
||||
}
|
||||
}
|
||||
piModels := string(first[PiModelsFile])
|
||||
if !strings.Contains(piModels, `"zai"`) || strings.Contains(piModels, `"deepseek"`) {
|
||||
t.Fatalf("Pi models projection must contain only custom providers:\n%s", piModels)
|
||||
}
|
||||
settings := string(first[PiSettingsFile])
|
||||
if !strings.Contains(settings, `"deepseek/deepseek-v4-pro"`) || !strings.Contains(settings, `"local/qwen"`) {
|
||||
t.Fatalf("Pi settings projection missing enabled session models:\n%s", settings)
|
||||
}
|
||||
compose := string(first[ComposeFile])
|
||||
for _, expected := range []string{"THT_MODEL_CATALOG_FILE", "THT_DEFAULT_SESSION_MODEL", "zai/glm-5.3", "THT_INTERNAL_EMBEDDING_ID", "THT_INTERNAL_EMBEDDING_MODEL", "OLLAMA_MODEL", installation.GeneratedModelCatalogPath()} {
|
||||
if !strings.Contains(compose, expected) {
|
||||
t.Fatalf("Compose projection missing %q:\n%s", expected, compose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePublishesAllArtifactsAndCheckDetectsDrift(t *testing.T) {
|
||||
installation := projectionFixture(t)
|
||||
if err := Generate(installation); err != nil {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
for _, path := range []string{installation.GeneratedModelCatalogPath(), installation.GeneratedPiModelsPath(), installation.GeneratedPiSettingsPath(), installation.ModelProjectionComposePath()} {
|
||||
if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() {
|
||||
t.Fatalf("generated artifact %q: info=%v err=%v", path, info, err)
|
||||
}
|
||||
}
|
||||
if drift, err := Check(installation); err != nil || len(drift) != 0 {
|
||||
t.Fatalf("Check() = %v, %v; want no drift", drift, err)
|
||||
}
|
||||
if err := os.WriteFile(installation.GeneratedPiSettingsPath(), []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if drift, err := Check(installation); err != nil || len(drift) != 1 || drift[0] != PiSettingsFile {
|
||||
t.Fatalf("Check() = %v, %v; want [%s]", drift, err, PiSettingsFile)
|
||||
}
|
||||
}
|
||||
|
||||
func projectionFixture(t *testing.T) config.Installation {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
return config.Installation{
|
||||
Path: filepath.Join(root, "thothii-installation.yaml"),
|
||||
ModelCatalog: config.ModelCatalog{
|
||||
Defaults: config.ModelCatalogDefaults{Session: "zai/glm-5.3", MetadataGeneration: "local/qwen"},
|
||||
Embedding: config.ModelCatalogEmbedding{ID: "ollama/qwen3-embedding:0.6b", Dimensions: 1024},
|
||||
Providers: map[string]config.ModelProvider{
|
||||
"deepseek": {Authentication: config.ModelAuthentication{Mode: "pi_auth"}, Session: &config.ModelSessionAdapter{Mode: "pi_builtin"}, Models: map[string]config.CatalogModel{"deepseek-v4-pro": {Session: &config.SessionModel{}}}},
|
||||
"zai": {Endpoint: &config.ModelEndpoint{BaseURL: "https://api.z.ai/v1"}, Authentication: config.ModelAuthentication{Mode: "none"}, Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, Models: map[string]config.CatalogModel{"glm-5.3": {Label: "GLM-5.3", Session: &config.SessionModel{Reasoning: true, ContextWindow: 200000, MaxTokens: 131072}}}},
|
||||
"local": {Endpoint: &config.ModelEndpoint{BaseURL: "http://ollama:11434/v1"}, Authentication: config.ModelAuthentication{Mode: "none"}, Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, MetadataGeneration: &config.ModelMetadataAdapter{LiteLLMProvider: "openai"}, Models: map[string]config.CatalogModel{"qwen": {Session: &config.SessionModel{ContextWindow: 32768, MaxTokens: 8192}, MetadataGeneration: &config.MetadataGenerationModel{DisableThinking: true}}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user