feat: unify installation model catalog

This commit is contained in:
Codex
2026-09-02 18:45:33 +02:00
parent ae053961a3
commit 7b7927bfe5
169 changed files with 3696 additions and 4572 deletions
+59 -124
View File
@@ -2,7 +2,6 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
@@ -11,7 +10,6 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
@@ -21,6 +19,8 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
"github.com/aritmolab/thothii/tools/tht/internal/lifecycle"
"github.com/aritmolab/thothii/tools/tht/internal/modelmigration"
"github.com/aritmolab/thothii/tools/tht/internal/modelprojection"
"github.com/aritmolab/thothii/tools/tht/internal/output"
"github.com/aritmolab/thothii/tools/tht/internal/pi"
"github.com/aritmolab/thothii/tools/tht/internal/project"
@@ -42,6 +42,9 @@ descriptor in the current project tree.
Commands:
setup [--configure-only] [--installation-id ID] [--profile local|server]
Create or validate the local non-secret installation configuration.
installation migrate --output PATH --session-default PROVIDER/MODEL
--embedding-id PROVIDER/MODEL --embedding-dimensions N
Create a review-only schema-v2 candidate from all three legacy model sources.
version [--json] Show the host CLI build identity.
auth configure --mode local|oidc ...
Configure local users or OIDC group mapping; see tht auth for exact options.
@@ -65,8 +68,6 @@ Commands:
pi doctor Check Pi preconditions without changing the installation.
pi test Run the temporary Pi/core smoke checks.
pi check Alias for pi test.
pi configure [--provider P --model M --thinking low|medium|high]
Select closed backend defaults interactively on a TTY; all flags are required otherwise.
pi restart --yes [--drain]
Recreate only core with the currently selected Pi image and verify readiness.
pi update [--version V]
@@ -125,6 +126,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if command == "version" {
return versionCommand(commandArgs, stdout, stderr)
}
if command == "installation" {
return installationMigrationCommand(installationPath, commandArgs, stdout, stderr)
}
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
}
workingDirectory, err := os.Getwd()
@@ -251,7 +255,51 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
func isBootstrapCommand(command string) bool {
return command == "help" || command == "setup" || command == "version"
return command == "help" || command == "setup" || command == "version" || command == "installation"
}
func installationMigrationCommand(installationPath string, args []string, stdout, stderr io.Writer) int {
if installationPath == "" {
return commandUsageError(stderr, "installation migrate requires --installation with the legacy descriptor")
}
if len(args) == 0 || args[0] != "migrate" {
return commandUsageError(stderr, "installation requires migrate")
}
values := make(map[string]string)
for index := 1; index < len(args); index += 2 {
if index+1 >= len(args) || !strings.HasPrefix(args[index], "--") {
return commandUsageError(stderr, "installation migrate requires flag/value pairs")
}
if _, duplicate := values[args[index]]; duplicate {
return commandUsageError(stderr, args[index]+" may be supplied once")
}
values[args[index]] = args[index+1]
}
for _, required := range []string{"--output", "--session-default", "--embedding-id", "--embedding-dimensions"} {
if values[required] == "" {
return commandUsageError(stderr, "installation migrate requires "+required)
}
}
if len(values) != 4 {
return commandUsageError(stderr, "installation migrate received an unknown option")
}
dimensions, err := modelmigration.ParseDimensions(values["--embedding-dimensions"])
if err != nil {
return commandUsageError(stderr, err.Error())
}
request := modelmigration.Request{
InstallationPath: installationPath,
OutputPath: values["--output"],
SessionDefault: values["--session-default"],
EmbeddingID: values["--embedding-id"],
EmbeddingDimensions: dimensions,
}
if err := modelmigration.Run(request); err != nil {
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
return 2
}
fmt.Fprintf(stdout, "Installation schema-v2 candidate written to %s. Legacy inputs were not changed.\n", request.OutputPath)
return 0
}
type setupExecutor func(context.Context, compose.Runner, setup.Request, io.Reader, io.Writer) (setup.Result, error)
@@ -479,6 +527,11 @@ func piCommand(ctx context.Context, installation config.Installation, runner com
}
defer func() { _ = lock.Release() }()
}
if args[0] == "restart" || args[0] == "update" || args[0] == "rollback" {
if err := modelprojection.Generate(installation); err != nil {
return commandUsageError(stderr, "installation model catalog could not be projected: "+err.Error())
}
}
controlled := compose.InstallationRunner{Installation: installation, Runner: runner}
switch args[0] {
case "status":
@@ -516,20 +569,6 @@ func piCommand(ctx context.Context, installation config.Installation, runner com
logArgs := []string{"logs", "--tail", "200", "core"}
result, err := controlled.Run(ctx, append([]string{"compose"}, logArgs...), nil)
return writeResult(result, err, secretValues, stdout, stderr)
case "configure":
authFile, authErr := installation.EnvironmentValue("PI_AUTH_FILE")
if authErr != nil || strings.TrimSpace(authFile) == "" {
return commandUsageError(stderr, "PI_AUTH_FILE must name the actual protected host credential file")
}
defaults, err := resolvePiConfigure(ctx, controlled, args[1:], os.Stdin, stdout, stdinIsTTY(os.Stdin))
if err != nil {
return commandUsageError(stderr, err.Error())
}
if err := pi.Configure(ctx, controlled, defaults); err != nil {
return piFailure(stderr, err, secretValues)
}
fmt.Fprintf(stdout, "Pi defaults applied and read back. Provider credentials remain only in the host file %s (mode 0600). Never pass credentials to tht.\n", authFile)
return 0
case "restart":
request, err := parsePiRestartArgs(
args[1:],
@@ -617,110 +656,6 @@ func piCommand(ctx context.Context, installation config.Installation, runner com
}
}
func resolvePiConfigure(
ctx context.Context,
runner pi.Runner,
args []string,
input io.Reader,
prompt io.Writer,
isTTY bool,
) (pi.Defaults, error) {
if len(args) > 0 {
return parsePiConfigureArgs(args)
}
if !isTTY {
return pi.Defaults{}, errors.New("non-interactive pi configure requires --provider --model --thinking")
}
options, err := pi.ConfigurationOptions(ctx, runner)
if err != nil {
return pi.Defaults{}, err
}
providers := uniqueProviders(options)
scanner := bufio.NewScanner(input)
provider, err := numberedChoice(scanner, prompt, "provider", providers)
if err != nil {
return pi.Defaults{}, err
}
models := make([]string, 0)
for _, option := range options {
if option.Provider == provider {
models = append(models, option.ID)
}
}
model, err := numberedChoice(scanner, prompt, "model", models)
if err != nil {
return pi.Defaults{}, err
}
thinking, err := numberedChoice(scanner, prompt, "thinking level", []string{"low", "medium", "high"})
if err != nil {
return pi.Defaults{}, err
}
return pi.Defaults{Provider: provider, Model: model, Thinking: thinking}, nil
}
func uniqueProviders(options []pi.ModelOption) []string {
seen := make(map[string]bool)
providers := make([]string, 0)
for _, option := range options {
if !seen[option.Provider] {
seen[option.Provider] = true
providers = append(providers, option.Provider)
}
}
return providers
}
func numberedChoice(scanner *bufio.Scanner, output io.Writer, label string, choices []string) (string, error) {
if len(choices) == 0 {
return "", fmt.Errorf("Pi returned no %s choices", label)
}
fmt.Fprintf(output, "Select %s:\n", label)
for index, choice := range choices {
fmt.Fprintf(output, " %d) %s\n", index+1, choice)
}
for {
fmt.Fprintf(output, "Choice [1-%d]: ", len(choices))
if !scanner.Scan() {
return "", fmt.Errorf("interactive %s selection ended before a choice was entered", label)
}
selected, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
if err == nil && selected >= 1 && selected <= len(choices) {
return choices[selected-1], nil
}
fmt.Fprintln(output, "Enter one of the listed numbers.")
}
}
func stdinIsTTY(input *os.File) bool {
info, err := input.Stat()
return err == nil && info.Mode()&os.ModeCharDevice != 0
}
func parsePiConfigureArgs(args []string) (pi.Defaults, error) {
var value pi.Defaults
for len(args) > 0 {
if len(args) < 2 {
return pi.Defaults{}, errors.New("configure options require values")
}
key, v := args[0], args[1]
args = args[2:]
switch key {
case "--provider":
value.Provider = v
case "--model":
value.Model = v
case "--thinking":
value.Thinking = v
default:
return pi.Defaults{}, fmt.Errorf("unknown pi configure option %q", key)
}
}
if value.Provider == "" || value.Model == "" || value.Thinking == "" {
return pi.Defaults{}, errors.New("pi configure requires --provider --model --thinking; THT_LLM_URL stays Compose-managed")
}
return value, nil
}
func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Request, error) {
request := pi.Request{StatePath: statePath, RestartStatePath: restartStatePath}
sourceSpecified := false
@@ -1008,7 +943,7 @@ func piMutationRequiresLifecycleLock(args []string) bool {
return false
}
switch args[0] {
case "configure", "restart", "update", "rollback":
case "restart", "update", "rollback":
return true
case "maintenance":
return len(args) > 1 && args[1] == "recover"