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"
+1
View File
@@ -46,6 +46,7 @@ func TestVersionCommandIsDescriptorFreeAndMachineReadable(t *testing.T) {
func TestDoctorJSONWritesOnlyOneReportDocument(t *testing.T) {
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
fixture.setEnvironment(t)
fixture.generateModelProjection(t)
fullHealth := `[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"},{"Service":"qdrant","State":"running","Health":"healthy"},{"Service":"embedding","State":"running","Health":"healthy"},{"Service":"embedding-model-init","State":"exited","ExitCode":0}]`
t.Setenv("THT_FAKE_PS", fullHealth)
+23 -70
View File
@@ -17,6 +17,7 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
"github.com/aritmolab/thothii/tools/tht/internal/modelprojection"
"github.com/aritmolab/thothii/tools/tht/internal/pi"
"github.com/aritmolab/thothii/tools/tht/internal/setup"
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
@@ -175,7 +176,6 @@ func TestPiMutationsUseTheSharedInstallationLifecycleLock(t *testing.T) {
{args: []string{"doctor"}, want: false},
{args: []string{"test"}, want: false},
{args: []string{"logs"}, want: false},
{args: []string{"configure"}, want: true},
{args: []string{"restart"}, want: true},
{args: []string{"update"}, want: true},
{args: []string{"rollback"}, want: true},
@@ -322,39 +322,6 @@ func TestSetupCommandBuildsAndStartsUnlessConfigureOnlyIsRequested(t *testing.T)
}
}
// Catches interactive configuration prompts that use retired model-only data instead of the
// provider, model, and reasoning choices supplied by the dedicated Pi Management API.
func TestResolvePiConfigureUsesNumberedClosedChoicesOnlyForTTY(t *testing.T) {
runner := &wizardRunner{}
var prompt bytes.Buffer
defaults, err := resolvePiConfigure(
context.Background(), runner, nil, strings.NewReader("2\n1\n3\n"), &prompt, true,
)
if err != nil {
t.Fatal(err)
}
want := pi.Defaults{Provider: "zai", Model: "glm-5.2", Thinking: "high"}
if defaults != want {
t.Fatalf("defaults = %#v", defaults)
}
for _, expected := range []string{"1) deepseek", "2) zai", "1) glm-5.2", "3) high"} {
if !strings.Contains(prompt.String(), expected) {
t.Errorf("prompt %q missing %q", prompt.String(), expected)
}
}
}
func TestResolvePiConfigureRequiresExplicitFlagsWithoutTTY(t *testing.T) {
runner := &wizardRunner{}
_, err := resolvePiConfigure(context.Background(), runner, nil, strings.NewReader("1\n1\n1\n"), io.Discard, false)
if err == nil || !strings.Contains(err.Error(), "non-interactive") {
t.Fatalf("resolvePiConfigure() error = %v, want explicit non-interactive guidance", err)
}
if len(runner.calls) != 0 {
t.Fatalf("Docker calls = %v, want none", runner.calls)
}
}
func TestPiLifecycleContractErrorsExitTwo(t *testing.T) {
for _, lifecycleErr := range []error{pi.ErrActiveSessions, pi.ErrInterruptedUpdate} {
var stderr bytes.Buffer
@@ -421,12 +388,11 @@ func TestRunPiStatusUsesInstallationEnvironmentWithoutFlag(t *testing.T) {
}
}
func TestUsageDocumentsClosedConfigureUpdateSourcesRestartAndMaintenanceRecovery(t *testing.T) {
func TestUsageDocumentsUpdateSourcesRestartAndMaintenanceRecovery(t *testing.T) {
if strings.Contains(usage, "--follow") {
t.Fatal("usage still advertises unbounded log following")
}
for _, required := range []string{
"--provider P --model M --thinking low|medium|high",
"--source build",
"--source pull --image IMAGE@sha256:DIGEST",
"pi restart --yes [--drain]",
@@ -610,13 +576,6 @@ func TestRunRemoveDisplaysExactInstallationTargetsBeforeConfirmation(t *testing.
assertInvocationContains(t, calls, "ps", "--all", "--format", "json", "core", "frontend")
}
type wizardRunner struct{ calls []string }
func (r *wizardRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
r.calls = append(r.calls, strings.Join(args, " "))
return compose.Result{Stdout: `{"providers":["deepseek","zai"],"models":[{"provider":"deepseek","id":"deepseek-v4"},{"provider":"zai","id":"glm-5.2"}],"reasoning":["low","medium","high"]}`}, nil
}
func TestRunLogsRedactsAnUnlabelledDeclaredSecret(t *testing.T) {
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
secretPath := filepath.Join(fixture.root, "operator-secret")
@@ -941,6 +900,7 @@ func TestRunStatusUsesStableComposeArguments(t *testing.T) {
"--env-file", fixture.envFile,
"-f", filepath.Join(fixture.projectDirectory, "compose.yaml"),
"-f", filepath.Join(fixture.projectDirectory, "deploy", "compose.local.yaml"),
"-f", filepath.Join(fixture.root, "generated", "compose.models.yaml"),
"ps", "--format", "json",
}
got := invocations[0]
@@ -998,6 +958,7 @@ func TestRunExplainsWhenDockerIsNotAvailable(t *testing.T) {
func TestRunDoctorValidatesTheRenderedInstallation(t *testing.T) {
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
fixture.setEnvironment(t)
fixture.generateModelProjection(t)
report := runDoctorJSON(t, fixture)
assertDoctorCheck(t, report, "configuration", doctor.StatusPassed)
@@ -1007,6 +968,7 @@ func TestRunDoctorValidatesTheRenderedInstallation(t *testing.T) {
func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T) {
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
fixture.setEnvironment(t)
fixture.generateModelProjection(t)
testsupport.SymlinkOrSkip(
t,
filepath.Join(fixture.projectDirectory, "missing-workspace.yaml"),
@@ -1021,6 +983,7 @@ func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T)
func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) {
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
fixture.setEnvironment(t)
fixture.generateModelProjection(t)
t.Setenv(
"THT_FAKE_PS",
"{\"Service\":\"core\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
@@ -1189,31 +1152,6 @@ func TestRunPiUpdateAcceptsExplicitVersionWithoutAdvancedFlags(t *testing.T) {
}
}
func TestRunPiConfigureReportsTheActualHostAuthFile(t *testing.T) {
fixture := newCLIFixture(t, "")
authFile := filepath.Join(fixture.root, "pi-auth.json")
if err := os.WriteFile(authFile, []byte(`{"provider":"credential"}`), 0o600); err != nil {
t.Fatal(err)
}
fixture.setEnvContents(t, "THT_LLM_URL=https://llm.example.invalid\nPI_AUTH_FILE="+authFile+"\n")
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{
"--installation", fixture.installationPath, "pi", "configure",
"--provider", "provider", "--model", "model", "--thinking", "medium",
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("run() exit = %d, stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), authFile) {
t.Fatalf("stdout = %q, want host auth path", stdout.String())
}
if strings.Contains(stdout.String(), "/home/thoth/.pi") {
t.Fatalf("stdout exposed container-only auth path: %q", stdout.String())
}
}
func TestRunPiMaintenanceStatusAndRecoverConfirmationContract(t *testing.T) {
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t)
@@ -1493,7 +1431,9 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
t.Fatal(err)
}
installationPath := filepath.Join(root, "thothii-installation.yaml")
contents := "profile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n"
contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\n" +
"modelCatalog:\n defaults:\n session: deepseek/deepseek-v4-pro\n embedding:\n id: ollama/qwen3-embedding:0.6b\n dimensions: 1024\n providers:\n deepseek:\n authentication: {mode: pi_auth}\n session: {mode: pi_builtin}\n models:\n deepseek-v4-pro:\n session: {}\n" +
"authentication:\n configDirectory: " + authDirectory + "\n"
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
@@ -1589,13 +1529,26 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) {
t.Setenv("THT_FAKE_PI_VERSION", "0.80.3")
}
func (f cliFixture) generateModelProjection(t *testing.T) {
t.Helper()
installation, err := config.Load(f.installationPath)
if err != nil {
t.Fatal(err)
}
if err := modelprojection.Generate(installation); err != nil {
t.Fatal(err)
}
}
func (f cliFixture) setProfile(t *testing.T, profile string) {
t.Helper()
composePath := filepath.Join(f.projectDirectory, "deploy", "compose."+profile+".yaml")
if err := os.WriteFile(composePath, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
contents := "profile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\nauthentication:\n configDirectory: " + filepath.Join(f.root, "auth") + "\n"
contents := "schemaVersion: 2\nprofile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\n" +
"modelCatalog:\n defaults:\n session: deepseek/deepseek-v4-pro\n embedding:\n id: ollama/qwen3-embedding:0.6b\n dimensions: 1024\n providers:\n deepseek:\n authentication: {mode: pi_auth}\n session: {mode: pi_builtin}\n models:\n deepseek-v4-pro:\n session: {}\n" +
"authentication:\n configDirectory: " + filepath.Join(f.root, "auth") + "\n"
if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}