feat: unify installation model catalog
This commit is contained in:
+59
-124
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,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/lifecycle"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/modelprojection"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/service"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -734,6 +735,11 @@ func activeSessions(value string) (bool, error) {
|
||||
}
|
||||
|
||||
func composeStartAndVerify(ctx context.Context, installation config.Installation, runner archiveRunner) error {
|
||||
// Projections are disposable and deliberately excluded from archives. Regenerate them from
|
||||
// the restored installation catalog immediately before any service is started.
|
||||
if err := modelprojection.Generate(installation); err != nil {
|
||||
return fmt.Errorf("regenerate model projections: %w", err)
|
||||
}
|
||||
if err := runCompose(ctx, installation, runner, "start"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -949,8 +955,6 @@ func configurationInputs(installation config.Installation) []configurationInput
|
||||
inputs := []configurationInput{
|
||||
{"configuration/installation/thothii-installation.yaml", "installation", installation.Path, false},
|
||||
{"configuration/environment/operator.env", "installation", installation.EnvFile, false},
|
||||
{"configuration/pi/models.json", "pi", filepath.Join(installation.ProjectDirectory, "deploy", "pi", "models.json"), false},
|
||||
{"configuration/pi/settings.json", "pi", filepath.Join(installation.ProjectDirectory, "deploy", "pi", "settings.json"), false},
|
||||
{"configuration/generated/current-image.yaml", "installation", installation.CurrentImageOverridePath(), true},
|
||||
}
|
||||
for index, source := range installation.Overrides {
|
||||
|
||||
@@ -143,8 +143,6 @@ func TestCreateWritesManifestLastWithConfigurationMetadataAndSevenVolumes(t *tes
|
||||
for _, path := range []string{
|
||||
"configuration/installation/thothii-installation.yaml",
|
||||
"configuration/environment/operator.env",
|
||||
"configuration/pi/models.json",
|
||||
"configuration/pi/settings.json",
|
||||
"configuration/generated/current-image.yaml",
|
||||
} {
|
||||
if _, exists := archive.files[path]; !exists {
|
||||
|
||||
@@ -276,10 +276,6 @@ func restoreFileTarget(installation config.Installation, entry ArchiveEntryMetad
|
||||
return installation.Path, nil
|
||||
case "configuration/environment/operator.env":
|
||||
return installation.EnvFile, nil
|
||||
case "configuration/pi/models.json":
|
||||
return filepath.Join(installation.ProjectDirectory, "deploy", "pi", "models.json"), nil
|
||||
case "configuration/pi/settings.json":
|
||||
return filepath.Join(installation.ProjectDirectory, "deploy", "pi", "settings.json"), nil
|
||||
case "configuration/generated/current-image.yaml":
|
||||
return installation.CurrentImageOverridePath(), nil
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ func writeDiscoverableInstallation(t *testing.T, projectRoot, directory string)
|
||||
t.Fatal(err)
|
||||
}
|
||||
installationPath := filepath.Join(directory, "thothii-installation.yaml")
|
||||
contents := "profile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n"
|
||||
contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n" + minimalModelCatalogYAML()
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -28,46 +28,21 @@ const maxEnvironmentFileBytes = 1 << 20
|
||||
|
||||
const maxMetadataSecretBundleBytes = 64 << 10
|
||||
|
||||
const maxMetadataGenerationModels = 64
|
||||
|
||||
const maxSecretSources = 32
|
||||
|
||||
var dotenvParseMu sync.Mutex
|
||||
|
||||
type descriptor struct {
|
||||
SchemaVersion int `yaml:"schemaVersion"`
|
||||
Profile string `yaml:"profile"`
|
||||
ProjectDirectory string `yaml:"projectDirectory"`
|
||||
EnvFile string `yaml:"envFile"`
|
||||
WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"`
|
||||
Authentication authenticationDescriptor `yaml:"authentication"`
|
||||
MetadataGeneration metadataGenerationDescriptor `yaml:"metadataGeneration"`
|
||||
ModelCatalog ModelCatalog `yaml:"modelCatalog"`
|
||||
Overrides []string `yaml:"overrides"`
|
||||
}
|
||||
|
||||
type metadataGenerationDescriptor struct {
|
||||
Default string `yaml:"default"`
|
||||
Models []metadataGenerationModelDescriptor `yaml:"models"`
|
||||
}
|
||||
|
||||
type metadataGenerationModelDescriptor struct {
|
||||
ID string `yaml:"id"`
|
||||
Label string `yaml:"label"`
|
||||
LiteLLM liteLLMDescriptor `yaml:"litellm"`
|
||||
APIKeyEnv string `yaml:"apiKeyEnv"`
|
||||
}
|
||||
|
||||
type liteLLMDescriptor struct {
|
||||
Provider string `yaml:"provider"`
|
||||
Model string `yaml:"model"`
|
||||
DisableThinking bool `yaml:"disableThinking"`
|
||||
Endpoint *metadataEndpointDescriptor `yaml:"endpoint"`
|
||||
}
|
||||
|
||||
type metadataEndpointDescriptor struct {
|
||||
BaseURL string `yaml:"baseUrl"`
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
}
|
||||
|
||||
type authenticationDescriptor struct {
|
||||
ConfigDirectory string `yaml:"configDirectory"`
|
||||
RuntimeProjection *runtimeProjectionDescriptor `yaml:"runtimeProjection"`
|
||||
@@ -105,46 +80,17 @@ type Authentication struct {
|
||||
RuntimeProjection *RuntimeProjection
|
||||
}
|
||||
|
||||
// MetadataGenerationEndpoint contains optional provider endpoint settings for one LiteLLM model.
|
||||
type MetadataGenerationEndpoint struct {
|
||||
BaseURL string
|
||||
APIVersion string
|
||||
}
|
||||
|
||||
// MetadataGenerationLiteLLM identifies the provider/model pair used by the internal completion helper.
|
||||
type MetadataGenerationLiteLLM struct {
|
||||
Provider string
|
||||
Model string
|
||||
DisableThinking bool
|
||||
Endpoint *MetadataGenerationEndpoint
|
||||
}
|
||||
|
||||
// MetadataGenerationModel is one selectable installation-owned metadata-generation model.
|
||||
// APIKeyEnv is an optional reference only; credential values never enter Installation.
|
||||
// An empty value is allowed only for a model with an explicit keyless endpoint.
|
||||
type MetadataGenerationModel struct {
|
||||
ID string
|
||||
Label string
|
||||
LiteLLM MetadataGenerationLiteLLM
|
||||
APIKeyEnv string
|
||||
}
|
||||
|
||||
// MetadataGeneration is the installation-owned model list and its default selection.
|
||||
type MetadataGeneration struct {
|
||||
Default string
|
||||
Models []MetadataGenerationModel
|
||||
}
|
||||
|
||||
// Installation is a validated local Compose installation. It intentionally contains paths, not
|
||||
// environment values or secret content.
|
||||
type Installation struct {
|
||||
Path string
|
||||
SchemaVersion int
|
||||
Profile string
|
||||
ProjectDirectory string
|
||||
EnvFile string
|
||||
WorkspaceRepository WorkspaceRepository
|
||||
Authentication Authentication
|
||||
MetadataGeneration MetadataGeneration
|
||||
ModelCatalog ModelCatalog
|
||||
Overrides []string
|
||||
}
|
||||
|
||||
@@ -171,18 +117,34 @@ func Load(path string) (Installation, error) {
|
||||
decoder := yaml.NewDecoder(file)
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
if strings.Contains(err.Error(), "field metadataGeneration not found") {
|
||||
return Installation{}, errors.New("migration_required: metadataGeneration was replaced by modelCatalog in installation schema version 2")
|
||||
}
|
||||
return Installation{}, fmt.Errorf("read installation file: %w", err)
|
||||
}
|
||||
if err := ensureOnlyOneDocument(decoder); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
|
||||
if raw.SchemaVersion != 2 {
|
||||
return Installation{}, errors.New("migration_required: installation schemaVersion must be 2")
|
||||
}
|
||||
if raw.Profile != "local" && raw.Profile != "server" {
|
||||
return Installation{}, fmt.Errorf("profile must be local or server")
|
||||
}
|
||||
if err := requireDirectory(raw.ProjectDirectory, "projectDirectory"); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
for _, legacyProjection := range []string{
|
||||
filepath.Join(raw.ProjectDirectory, "deploy", "pi", "models.json"),
|
||||
filepath.Join(raw.ProjectDirectory, "deploy", "pi", "settings.json"),
|
||||
} {
|
||||
if _, err := os.Lstat(legacyProjection); err == nil {
|
||||
return Installation{}, errors.New("migration_required: remove legacy deploy/pi model sources after reviewing the schema version 2 candidate")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return Installation{}, errors.New("legacy deploy/pi model sources could not be inspected")
|
||||
}
|
||||
}
|
||||
if err := requireRegularFile(raw.EnvFile, "envFile"); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
@@ -198,31 +160,9 @@ func Load(path string) (Installation, error) {
|
||||
GID: raw.Authentication.RuntimeProjection.GID,
|
||||
}
|
||||
}
|
||||
metadataGeneration := MetadataGeneration{
|
||||
Default: raw.MetadataGeneration.Default,
|
||||
Models: make([]MetadataGenerationModel, 0, len(raw.MetadataGeneration.Models)),
|
||||
}
|
||||
for _, rawModel := range raw.MetadataGeneration.Models {
|
||||
model := MetadataGenerationModel{
|
||||
ID: rawModel.ID,
|
||||
Label: rawModel.Label,
|
||||
LiteLLM: MetadataGenerationLiteLLM{
|
||||
Provider: rawModel.LiteLLM.Provider,
|
||||
Model: rawModel.LiteLLM.Model,
|
||||
DisableThinking: rawModel.LiteLLM.DisableThinking,
|
||||
},
|
||||
APIKeyEnv: rawModel.APIKeyEnv,
|
||||
}
|
||||
if rawModel.LiteLLM.Endpoint != nil {
|
||||
model.LiteLLM.Endpoint = &MetadataGenerationEndpoint{
|
||||
BaseURL: rawModel.LiteLLM.Endpoint.BaseURL,
|
||||
APIVersion: rawModel.LiteLLM.Endpoint.APIVersion,
|
||||
}
|
||||
}
|
||||
metadataGeneration.Models = append(metadataGeneration.Models, model)
|
||||
}
|
||||
installation := Installation{
|
||||
Path: path,
|
||||
SchemaVersion: raw.SchemaVersion,
|
||||
Profile: raw.Profile,
|
||||
ProjectDirectory: filepath.Clean(raw.ProjectDirectory),
|
||||
EnvFile: filepath.Clean(raw.EnvFile),
|
||||
@@ -231,15 +171,15 @@ func Load(path string) (Installation, error) {
|
||||
Branch: raw.WorkspaceRepository.Branch,
|
||||
Access: raw.WorkspaceRepository.Access,
|
||||
},
|
||||
Authentication: authentication,
|
||||
MetadataGeneration: metadataGeneration,
|
||||
Overrides: make([]string, 0, len(raw.Overrides)),
|
||||
Authentication: authentication,
|
||||
ModelCatalog: raw.ModelCatalog,
|
||||
Overrides: make([]string, 0, len(raw.Overrides)),
|
||||
}
|
||||
values, err := installation.environmentValues()
|
||||
if err != nil {
|
||||
return Installation{}, errors.New("installation secret declarations could not be read")
|
||||
}
|
||||
if err := installation.validateMetadataGeneration(values); err != nil {
|
||||
if err := installation.ModelCatalog.Validate(values); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
if values["THT_AUTH_CONFIG_ROOT"] != installation.AuthenticationDirectory() {
|
||||
@@ -277,88 +217,9 @@ func Load(path string) (Installation, error) {
|
||||
return installation, nil
|
||||
}
|
||||
|
||||
func (i Installation) validateMetadataGeneration(values map[string]string) error {
|
||||
if len(i.MetadataGeneration.Models) > maxMetadataGenerationModels {
|
||||
return fmt.Errorf(
|
||||
"metadataGeneration.models must contain at most %d entries",
|
||||
maxMetadataGenerationModels,
|
||||
)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(i.MetadataGeneration.Models))
|
||||
for index, model := range i.MetadataGeneration.Models {
|
||||
if err := validateMetadataGenerationModel(index, model); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := seen[model.ID]; exists {
|
||||
return fmt.Errorf("duplicate metadataGeneration model id %q", model.ID)
|
||||
}
|
||||
seen[model.ID] = struct{}{}
|
||||
}
|
||||
if len(i.MetadataGeneration.Models) > 0 && i.MetadataGeneration.Default == "" {
|
||||
return errors.New("metadataGeneration.default is required when models are configured")
|
||||
}
|
||||
if i.MetadataGeneration.Default != "" {
|
||||
if _, exists := seen[i.MetadataGeneration.Default]; !exists {
|
||||
return fmt.Errorf(
|
||||
"metadataGeneration.default %q does not identify a configured model",
|
||||
i.MetadataGeneration.Default,
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(i.MetadataGeneration.Models) == 0 {
|
||||
return nil
|
||||
}
|
||||
if values["THT_INSTALLATION_CONFIG_SOURCE"] != i.Path {
|
||||
return errors.New("metadataGeneration requires THT_INSTALLATION_CONFIG_SOURCE to match the installation file")
|
||||
}
|
||||
requiresSecrets := false
|
||||
for _, model := range i.MetadataGeneration.Models {
|
||||
if model.APIKeyEnv != "" {
|
||||
requiresSecrets = true
|
||||
break
|
||||
}
|
||||
}
|
||||
secrets := map[string]string{}
|
||||
if requiresSecrets {
|
||||
bundlePath := values["THT_SECRETS_FILE"]
|
||||
if bundlePath == "" {
|
||||
return errors.New("metadataGeneration keyed models require THT_SECRETS_FILE")
|
||||
}
|
||||
var err error
|
||||
secrets, err = readMetadataGenerationSecrets(bundlePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, model := range i.MetadataGeneration.Models {
|
||||
if model.APIKeyEnv == "" {
|
||||
continue
|
||||
}
|
||||
value, exists := secrets[model.APIKeyEnv]
|
||||
if !exists {
|
||||
return fmt.Errorf(
|
||||
"metadataGeneration model %q secret %q is missing from THT_SECRETS_FILE",
|
||||
model.ID,
|
||||
model.APIKeyEnv,
|
||||
)
|
||||
}
|
||||
if !usableMetadataGenerationSecret(value) {
|
||||
return fmt.Errorf(
|
||||
"metadataGeneration model %q secret %q is unusable",
|
||||
model.ID,
|
||||
model.APIKeyEnv,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataModelIDPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`)
|
||||
var metadataProviderPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
var metadataProviderModelPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`)
|
||||
var metadataAPIVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
var metadataSecretBundleKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
|
||||
var metadataAPIKeyEnvironments = map[string]struct{}{
|
||||
"THT_MODEL_API_KEY": {},
|
||||
"THT_METADATA_API_KEY": {},
|
||||
"ANTHROPIC_API_KEY": {},
|
||||
"AZURE_API_KEY": {},
|
||||
@@ -392,50 +253,6 @@ var metadataSecretBundleKeys = map[string]struct{}{
|
||||
"ZAI_API_KEY": {},
|
||||
}
|
||||
|
||||
func validateMetadataGenerationModel(index int, model MetadataGenerationModel) error {
|
||||
prefix := fmt.Sprintf("metadataGeneration.models[%d]", index)
|
||||
if !metadataModelIDPattern.MatchString(model.ID) {
|
||||
return fmt.Errorf("%s.id is invalid", prefix)
|
||||
}
|
||||
if len(model.Label) == 0 || len(model.Label) > 128 || strings.TrimSpace(model.Label) != model.Label ||
|
||||
strings.IndexFunc(model.Label, unicode.IsControl) >= 0 {
|
||||
return fmt.Errorf("%s.label is invalid", prefix)
|
||||
}
|
||||
if !metadataProviderPattern.MatchString(model.LiteLLM.Provider) {
|
||||
return fmt.Errorf("%s.litellm.provider is invalid", prefix)
|
||||
}
|
||||
if !metadataProviderModelPattern.MatchString(model.LiteLLM.Model) {
|
||||
return fmt.Errorf("%s.litellm.model is invalid", prefix)
|
||||
}
|
||||
if model.APIKeyEnv == "" {
|
||||
if model.LiteLLM.Endpoint == nil {
|
||||
return fmt.Errorf("%s.apiKeyEnv is required unless an explicit keyless endpoint is configured", prefix)
|
||||
}
|
||||
} else {
|
||||
if !metadataSecretBundleKeyPattern.MatchString(model.APIKeyEnv) {
|
||||
return fmt.Errorf("%s.apiKeyEnv is invalid", prefix)
|
||||
}
|
||||
if _, allowed := metadataAPIKeyEnvironments[model.APIKeyEnv]; !allowed {
|
||||
return fmt.Errorf("%s.apiKeyEnv is invalid", prefix)
|
||||
}
|
||||
}
|
||||
if endpoint := model.LiteLLM.Endpoint; endpoint != 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("%s.litellm.endpoint.baseUrl is invalid", prefix)
|
||||
}
|
||||
if endpoint.APIVersion != "" && !metadataAPIVersionPattern.MatchString(endpoint.APIVersion) {
|
||||
return fmt.Errorf("%s.litellm.endpoint.apiVersion is invalid", prefix)
|
||||
}
|
||||
}
|
||||
if model.LiteLLM.DisableThinking && model.LiteLLM.Endpoint == nil {
|
||||
return fmt.Errorf("%s.litellm.disableThinking requires an explicit endpoint", prefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readMetadataGenerationSecrets(path string) (map[string]string, error) {
|
||||
contents, err := safeio.ReadCanonicalPrivateRegular(path, maxMetadataSecretBundleBytes)
|
||||
if err != nil {
|
||||
@@ -604,6 +421,7 @@ func (i Installation) ComposeFiles() []string {
|
||||
filepath.Join(i.ProjectDirectory, "deploy", "compose."+i.Profile+".yaml"),
|
||||
}
|
||||
files = append(files, i.Overrides...)
|
||||
files = append(files, i.ModelProjectionComposePath())
|
||||
if i.HasRuntimeAuthProjection() {
|
||||
files = append(files, i.runtimeAuthProjectionComposePath())
|
||||
}
|
||||
@@ -614,6 +432,27 @@ func (i Installation) ComposeFiles() []string {
|
||||
return files
|
||||
}
|
||||
|
||||
// GeneratedDirectory contains disposable runtime adapters derived from modelCatalog.
|
||||
func (i Installation) GeneratedDirectory() string {
|
||||
return filepath.Join(filepath.Dir(i.Path), "generated")
|
||||
}
|
||||
|
||||
func (i Installation) GeneratedModelCatalogPath() string {
|
||||
return filepath.Join(i.GeneratedDirectory(), "catalog.json")
|
||||
}
|
||||
|
||||
func (i Installation) GeneratedPiModelsPath() string {
|
||||
return filepath.Join(i.GeneratedDirectory(), "pi", "models.json")
|
||||
}
|
||||
|
||||
func (i Installation) GeneratedPiSettingsPath() string {
|
||||
return filepath.Join(i.GeneratedDirectory(), "pi", "settings.json")
|
||||
}
|
||||
|
||||
func (i Installation) ModelProjectionComposePath() string {
|
||||
return filepath.Join(i.GeneratedDirectory(), "compose.models.yaml")
|
||||
}
|
||||
|
||||
func (i Installation) runtimeAuthProjectionComposePath() string {
|
||||
return filepath.Join(i.ProjectDirectory, "deploy", "compose.auth-runtime-projection.yaml")
|
||||
}
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAcceptsMetadataGenerationModels(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, strings.Join([]string{
|
||||
"THT_SECRETS_FILE=" + strconv.Quote(secretBundle),
|
||||
"THT_INSTALLATION_CONFIG_SOURCE=" + strconv.Quote(installationPath),
|
||||
}, "\n")+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm:
|
||||
provider: openai
|
||||
model: gpt-4.1-mini
|
||||
endpoint:
|
||||
baseUrl: https://api.openai.example/v1
|
||||
apiVersion: "2026-08-01"
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
installation, err := Load(installationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if installation.MetadataGeneration.Default != "openai-mini" {
|
||||
t.Fatalf("metadata default = %q", installation.MetadataGeneration.Default)
|
||||
}
|
||||
if len(installation.MetadataGeneration.Models) != 1 {
|
||||
t.Fatalf("metadata models = %#v", installation.MetadataGeneration.Models)
|
||||
}
|
||||
model := installation.MetadataGeneration.Models[0]
|
||||
if model.ID != "openai-mini" || model.Label != "OpenAI Mini" ||
|
||||
model.LiteLLM.Provider != "openai" || model.LiteLLM.Model != "gpt-4.1-mini" ||
|
||||
model.LiteLLM.Endpoint == nil || model.LiteLLM.Endpoint.BaseURL != "https://api.openai.example/v1" ||
|
||||
model.LiteLLM.Endpoint.APIVersion != "2026-08-01" || model.APIKeyEnv != "OPENAI_API_KEY" {
|
||||
t.Fatalf("metadata model = %#v", model)
|
||||
}
|
||||
if strings.Contains(strings.TrimSpace(model.APIKeyEnv), "provider-secret") {
|
||||
t.Fatal("installation model exposed the credential value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsMixedKeyedAndKeylessMetadataGenerationModels(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(
|
||||
secretBundle,
|
||||
[]byte("DEEPSEEK_API_KEY=deepseek-secret\nZAI_API_KEY=zai-secret\n"),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: glm-53
|
||||
models:
|
||||
- id: deepseek-v4-pro
|
||||
label: DeepSeek V4 Pro
|
||||
litellm: {provider: deepseek, model: deepseek-v4-pro}
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
- id: deepseek-v4-flash
|
||||
label: DeepSeek V4 Flash
|
||||
litellm: {provider: deepseek, model: deepseek-v4-flash}
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
- id: glm-53
|
||||
label: GLM 5.3
|
||||
litellm:
|
||||
provider: openai
|
||||
model: glm-5.3
|
||||
endpoint: {baseUrl: https://api.z.ai/api/coding/paas/v4}
|
||||
apiKeyEnv: ZAI_API_KEY
|
||||
- id: qwen-36
|
||||
label: Qwen 3.6
|
||||
litellm:
|
||||
provider: openai
|
||||
model: qwen3.6-35b-a3b
|
||||
disableThinking: true
|
||||
endpoint: {baseUrl: https://models.internal.example/v1}
|
||||
`)
|
||||
|
||||
installation, err := Load(installationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if len(installation.MetadataGeneration.Models) != 4 {
|
||||
t.Fatalf("metadata models = %#v", installation.MetadataGeneration.Models)
|
||||
}
|
||||
qwen := installation.MetadataGeneration.Models[3]
|
||||
if qwen.APIKeyEnv != "" || !qwen.LiteLLM.DisableThinking || qwen.LiteLLM.Endpoint == nil {
|
||||
t.Fatalf("keyless qwen model = %#v", qwen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsOnlyExplicitKeylessMetadataGenerationModelWithoutBundle(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
appendFile(t, envFile, "THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: qwen-36
|
||||
models:
|
||||
- id: qwen-36
|
||||
label: Qwen 3.6
|
||||
litellm:
|
||||
provider: openai
|
||||
model: qwen3.6-35b-a3b
|
||||
disableThinking: true
|
||||
endpoint: {baseUrl: https://models.internal.example/v1}
|
||||
`)
|
||||
|
||||
if _, err := Load(installationPath); err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsTooManyMetadataGenerationModels(t *testing.T) {
|
||||
installationPath, _, _, _ := writeInstallation(t, "local")
|
||||
var configuration strings.Builder
|
||||
configuration.WriteString("metadataGeneration:\n default: model-0\n models:\n")
|
||||
for index := 0; index <= maxMetadataGenerationModels; index++ {
|
||||
fmt.Fprintf(&configuration, ` - id: model-%d
|
||||
label: Model %d
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`, index, index)
|
||||
}
|
||||
appendFile(t, installationPath, configuration.String())
|
||||
|
||||
_, err := Load(installationPath)
|
||||
want := fmt.Sprintf(
|
||||
"metadataGeneration.models must contain at most %d entries",
|
||||
maxMetadataGenerationModels,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Load() error = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsDuplicateMetadataGenerationModelIDs(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
- id: openai-mini
|
||||
label: Duplicate
|
||||
litellm: {provider: openai, model: gpt-4.1}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate metadataGeneration model id "openai-mini"`) {
|
||||
t.Fatalf("Load() error = %v, want actionable duplicate-id error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingOrUnknownMetadataGenerationDefault(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
defaultYAML string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing",
|
||||
want: "metadataGeneration.default is required when models are configured",
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
defaultYAML: " default: unavailable\n",
|
||||
want: `metadataGeneration.default "unavailable" does not identify a configured model`,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, "metadataGeneration:\n"+test.defaultYAML+` models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMalformedMetadataGenerationModelSettings(t *testing.T) {
|
||||
validPrefix := ` - id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm:
|
||||
`
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unstable id",
|
||||
model: strings.Replace(validPrefix, "openai-mini", "OpenAI Mini", 1) +
|
||||
" provider: openai\n model: gpt-4.1-mini\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].id is invalid",
|
||||
},
|
||||
{
|
||||
name: "blank label",
|
||||
model: strings.Replace(validPrefix, "OpenAI Mini", `" "`, 1) +
|
||||
" provider: openai\n model: gpt-4.1-mini\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].label is invalid",
|
||||
},
|
||||
{
|
||||
name: "provider",
|
||||
model: validPrefix + " provider: open ai\n model: gpt-4.1-mini\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].litellm.provider is invalid",
|
||||
},
|
||||
{
|
||||
name: "model",
|
||||
model: validPrefix + " provider: openai\n model: \" gpt-4.1-mini\"\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].litellm.model is invalid",
|
||||
},
|
||||
{
|
||||
name: "endpoint",
|
||||
model: validPrefix + " provider: openai\n model: gpt-4.1-mini\n" +
|
||||
" endpoint:\n baseUrl: https://operator@api.example/v1\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].litellm.endpoint.baseUrl is invalid",
|
||||
},
|
||||
{
|
||||
name: "api version",
|
||||
model: validPrefix + " provider: openai\n model: gpt-4.1-mini\n" +
|
||||
" endpoint:\n baseUrl: https://api.example/v1\n apiVersion: \"bad version\"\n apiKeyEnv: OPENAI_API_KEY\n",
|
||||
want: "metadataGeneration.models[0].litellm.endpoint.apiVersion is invalid",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, "metadataGeneration:\n default: openai-mini\n models:\n"+test.model)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingOrUnusableMetadataGenerationSecrets(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
apiKeyEnvYAML string
|
||||
bundle string
|
||||
declareBundle bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing reference",
|
||||
apiKeyEnvYAML: "",
|
||||
bundle: "OPENAI_API_KEY=provider-secret\n",
|
||||
declareBundle: true,
|
||||
want: "metadataGeneration.models[0].apiKeyEnv is required unless an explicit keyless endpoint is configured",
|
||||
},
|
||||
{
|
||||
name: "malformed reference",
|
||||
apiKeyEnvYAML: " apiKeyEnv: openai-api-key\n",
|
||||
bundle: "OPENAI_API_KEY=provider-secret\n",
|
||||
declareBundle: true,
|
||||
want: "metadataGeneration.models[0].apiKeyEnv is invalid",
|
||||
},
|
||||
{
|
||||
name: "unallowed reference",
|
||||
apiKeyEnvYAML: " apiKeyEnv: THT_DWH_API_KEY\n",
|
||||
bundle: "THT_DWH_API_KEY=dwh-secret\n",
|
||||
declareBundle: true,
|
||||
want: "metadataGeneration.models[0].apiKeyEnv is invalid",
|
||||
},
|
||||
{
|
||||
name: "bundle not declared",
|
||||
apiKeyEnvYAML: " apiKeyEnv: OPENAI_API_KEY\n",
|
||||
bundle: "OPENAI_API_KEY=provider-secret\n",
|
||||
want: "metadataGeneration keyed models require THT_SECRETS_FILE",
|
||||
},
|
||||
{
|
||||
name: "reference absent from bundle",
|
||||
apiKeyEnvYAML: " apiKeyEnv: OPENAI_API_KEY\n",
|
||||
bundle: "THT_DWH_API_KEY=dwh-secret\n",
|
||||
declareBundle: true,
|
||||
want: "metadataGeneration model \"openai-mini\" secret \"OPENAI_API_KEY\" is missing",
|
||||
},
|
||||
{
|
||||
name: "unusable value",
|
||||
apiKeyEnvYAML: " apiKeyEnv: OPENAI_API_KEY\n",
|
||||
bundle: "OPENAI_API_KEY=secret with whitespace\n",
|
||||
declareBundle: true,
|
||||
want: "metadataGeneration model \"openai-mini\" secret \"OPENAI_API_KEY\" is unusable",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte(test.bundle), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment := "THT_INSTALLATION_CONFIG_SOURCE=" + strconv.Quote(installationPath) + "\n"
|
||||
if test.declareBundle {
|
||||
environment += "THT_SECRETS_FILE=" + strconv.Quote(secretBundle) + "\n"
|
||||
}
|
||||
appendFile(t, envFile, environment)
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
`+test.apiKeyEnvYAML)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "secret with whitespace") ||
|
||||
strings.Contains(strings.ToLower(err.Error()), "provider-secret") {
|
||||
t.Fatalf("Load() exposed secret content: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnprotectedMetadataGenerationSecretBundle(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX mode assertion; Windows ACL coverage lives in safeio")
|
||||
}
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "metadataGeneration secrets in THT_SECRETS_FILE are unavailable") {
|
||||
t.Fatalf("Load() error = %v, want protected-bundle error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownMetadataGenerationSecretBundleKeys(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(
|
||||
secretBundle,
|
||||
[]byte("OPENAI_API_KEY=provider-secret\nUNRECOGNIZED_API_KEY=unknown-secret\n"),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+
|
||||
"THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n")
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "metadataGeneration secrets in THT_SECRETS_FILE are invalid") {
|
||||
t.Fatalf("Load() error = %v, want invalid-bundle error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "UNRECOGNIZED_API_KEY") || strings.Contains(err.Error(), "unknown-secret") {
|
||||
t.Fatalf("Load() exposed rejected bundle content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresConfiguredMetadataGenerationToUseTheSameMountedDescriptor(t *testing.T) {
|
||||
for _, configuredSource := range []string{"", "/different/thothii-installation.yaml"} {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||
secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets")
|
||||
if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment := "THT_SECRETS_FILE=" + strconv.Quote(secretBundle) + "\n"
|
||||
if configuredSource != "" {
|
||||
environment += "THT_INSTALLATION_CONFIG_SOURCE=" + strconv.Quote(configuredSource) + "\n"
|
||||
}
|
||||
appendFile(t, envFile, environment)
|
||||
appendFile(t, installationPath, `metadataGeneration:
|
||||
default: openai-mini
|
||||
models:
|
||||
- id: openai-mini
|
||||
label: OpenAI Mini
|
||||
litellm: {provider: openai, model: gpt-4.1-mini}
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
`)
|
||||
|
||||
_, err := Load(installationPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "metadataGeneration requires THT_INSTALLATION_CONFIG_SOURCE to match the installation file") {
|
||||
t.Fatalf("Load() source %q error = %v", configuredSource, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendFile(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.WriteString(contents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAcceptsInstallationModelCatalog(t *testing.T) {
|
||||
path, _, _, _ := writeInstallation(t, "local")
|
||||
rewriteInstallationCatalog(t, path, validModelCatalogYAML())
|
||||
|
||||
installation, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if installation.SchemaVersion != 2 {
|
||||
t.Fatalf("SchemaVersion = %d, want 2", installation.SchemaVersion)
|
||||
}
|
||||
if installation.ModelCatalog.Defaults.Session != "zai/glm-5.3" {
|
||||
t.Fatalf("session default = %q", installation.ModelCatalog.Defaults.Session)
|
||||
}
|
||||
models := installation.ModelCatalog.RuntimeModels()
|
||||
if len(models) != 2 || models[0].ID != "local/qwen" || models[1].ID != "zai/glm-5.3" {
|
||||
t.Fatalf("RuntimeModels() = %#v", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsLegacyMetadataGenerationWithMigrationRequired(t *testing.T) {
|
||||
path, _, _, _ := writeInstallation(t, "local")
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents = append(contents, []byte("metadataGeneration:\n default: legacy\n models: []\n")...)
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "migration_required") {
|
||||
t.Fatalf("Load() error = %v, want migration_required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsLegacyPiCatalogSourcesWithMigrationRequired(t *testing.T) {
|
||||
path, projectDirectory, _, _ := writeInstallation(t, "local")
|
||||
legacyDirectory := filepath.Join(projectDirectory, "deploy", "pi")
|
||||
if err := os.MkdirAll(legacyDirectory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(legacyDirectory, "models.json"), []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "migration_required") {
|
||||
t.Fatalf("Load() error = %v, want migration_required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidModelCatalogDefaultsAndAuthentication(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
catalog string
|
||||
wantError string
|
||||
secretLine string
|
||||
}{
|
||||
{
|
||||
name: "unknown session default",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), "session: zai/glm-5.3", "session: zai/missing", 1),
|
||||
wantError: "defaults.session",
|
||||
},
|
||||
{
|
||||
name: "embedding must use the installation Ollama service",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), "id: ollama/qwen3-embedding:0.6b", "id: external/qwen3-embedding:0.6b", 1),
|
||||
wantError: "ollama canonical id",
|
||||
},
|
||||
{
|
||||
name: "metadata default required",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), " metadataGeneration: local/qwen\n", "", 1),
|
||||
wantError: "defaults.metadataGeneration",
|
||||
},
|
||||
{
|
||||
name: "pi auth cannot serve metadata",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), "mode: none", "mode: pi_auth", 1),
|
||||
wantError: "pi_auth",
|
||||
},
|
||||
{
|
||||
name: "none requires endpoint",
|
||||
catalog: strings.Replace(strings.Replace(validModelCatalogYAML(), "mode: secret_env\n apiKeyEnv: ZAI_API_KEY", "mode: none", 1), " endpoint:\n baseUrl: https://api.z.ai/v1\n", "", 1),
|
||||
wantError: "explicit endpoint",
|
||||
},
|
||||
{
|
||||
name: "secret must exist",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), "mode: none", "mode: secret_env\n apiKeyEnv: ZAI_API_KEY", 1),
|
||||
wantError: "ZAI_API_KEY",
|
||||
secretLine: "ZAI_API_KEY=\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path, _, envFile, _ := writeInstallation(t, "local")
|
||||
rewriteInstallationCatalog(t, path, test.catalog)
|
||||
if test.secretLine != "" {
|
||||
secretPath := filepath.Join(filepath.Dir(path), "secrets.env")
|
||||
if err := os.WriteFile(secretPath, []byte(test.secretLine), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(envFile, []byte("THT_AUTH_CONFIG_ROOT="+filepath.Join(filepath.Dir(path), "auth")+"\nTHT_INSTALLATION_CONFIG_SOURCE="+path+"\nTHT_SECRETS_FILE="+secretPath+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, err := Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("Load() error = %v, want substring %q", err, test.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteInstallationCatalog(t *testing.T, path, catalog string) {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := strings.Split(string(contents), "modelCatalog:\n")[0]
|
||||
text += catalog
|
||||
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func validModelCatalogYAML() string {
|
||||
return `modelCatalog:
|
||||
defaults:
|
||||
session: zai/glm-5.3
|
||||
metadataGeneration: local/qwen
|
||||
embedding:
|
||||
id: ollama/qwen3-embedding:0.6b
|
||||
dimensions: 1024
|
||||
providers:
|
||||
zai:
|
||||
endpoint:
|
||||
baseUrl: https://api.z.ai/v1
|
||||
authentication:
|
||||
mode: none
|
||||
session:
|
||||
mode: openai_compatible
|
||||
models:
|
||||
glm-5.3:
|
||||
session:
|
||||
reasoning: true
|
||||
contextWindow: 200000
|
||||
maxTokens: 131072
|
||||
local:
|
||||
endpoint:
|
||||
baseUrl: http://ollama:11434/v1
|
||||
authentication:
|
||||
mode: none
|
||||
session:
|
||||
mode: openai_compatible
|
||||
metadataGeneration:
|
||||
litellmProvider: openai
|
||||
models:
|
||||
qwen:
|
||||
session:
|
||||
contextWindow: 32768
|
||||
maxTokens: 8192
|
||||
metadataGeneration:
|
||||
disableThinking: true
|
||||
`
|
||||
}
|
||||
@@ -31,6 +31,7 @@ func TestLoadSelectsLocalComposeFilesForAnInstallationInPathsWithSpaces(t *testi
|
||||
filepath.Join(projectDirectory, "compose.yaml"),
|
||||
filepath.Join(projectDirectory, "deploy", "compose.local.yaml"),
|
||||
override,
|
||||
installation.ModelProjectionComposePath(),
|
||||
}
|
||||
assertStringsEqual(t, installation.ComposeFiles(), want)
|
||||
}
|
||||
@@ -48,6 +49,7 @@ func TestLoadSelectsServerComposeFiles(t *testing.T) {
|
||||
filepath.Join(projectDirectory, "compose.yaml"),
|
||||
filepath.Join(projectDirectory, "deploy", "compose.server.yaml"),
|
||||
override,
|
||||
installation.ModelProjectionComposePath(),
|
||||
}
|
||||
assertStringsEqual(t, installation.ComposeFiles(), want)
|
||||
}
|
||||
@@ -91,6 +93,7 @@ func TestLoadAcceptsServerRuntimeProjectionAndPlacesAutomaticOverrideBeforeCurre
|
||||
filepath.Join(projectDirectory, "compose.yaml"),
|
||||
filepath.Join(projectDirectory, "deploy", "compose.server.yaml"),
|
||||
override,
|
||||
installation.ModelProjectionComposePath(),
|
||||
automaticOverride,
|
||||
currentImage,
|
||||
}
|
||||
@@ -175,11 +178,11 @@ func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *te
|
||||
if err := os.WriteFile(envFile, []byte(environment), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := "profile: local\nprojectDirectory: " + projectDirectory +
|
||||
contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectDirectory +
|
||||
"\nenvFile: " + envFile +
|
||||
"\nauthentication:\n configDirectory: " + filepath.Join(filepath.Dir(envFile), "auth") +
|
||||
"\nworkspaceRepository:\n remote: " + remote +
|
||||
"\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n"
|
||||
"\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n" + minimalModelCatalogYAML()
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -201,9 +204,10 @@ func TestLoadRejectsGitOverrideWithoutTypedWorkspaceRepository(t *testing.T) {
|
||||
if err := os.WriteFile(gitOverride, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := "profile: local\nprojectDirectory: " + projectDirectory +
|
||||
contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectDirectory +
|
||||
"\nenvFile: " + envFile + "\noverrides:\n - " + gitOverride + "\n"
|
||||
contents = strings.Replace(contents, "\noverrides:", "\nauthentication:\n configDirectory: "+filepath.Join(filepath.Dir(envFile), "auth")+"\noverrides:", 1)
|
||||
contents += minimalModelCatalogYAML()
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -388,10 +392,11 @@ func writeRuntimeProjectionFixture(t *testing.T, installationPath, envFile, prof
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectDirectory := filepath.Join(filepath.Dir(installationPath), "project directory with spaces")
|
||||
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + configDirectory + "\n runtimeProjection:\n directory: " + runtimeDirectory + "\n uid: " + strconv.FormatUint(uint64(uid), 10) + "\n gid: " + strconv.FormatUint(uint64(gid), 10) + "\noverrides:\n"
|
||||
contents := "schemaVersion: 2\nprofile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + configDirectory + "\n runtimeProjection:\n directory: " + runtimeDirectory + "\n uid: " + strconv.FormatUint(uint64(uid), 10) + "\n gid: " + strconv.FormatUint(uint64(gid), 10) + "\noverrides:\n"
|
||||
for _, override := range overrides {
|
||||
contents += " - " + override + "\n"
|
||||
}
|
||||
contents += minimalModelCatalogYAML()
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -429,13 +434,32 @@ func writeInstallation(t *testing.T, profile string) (string, string, string, st
|
||||
t.Fatal(err)
|
||||
}
|
||||
installationPath := filepath.Join(root, "thothii-installation.yaml")
|
||||
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\noverrides:\n - " + override + "\n"
|
||||
contents := "schemaVersion: 2\nprofile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\noverrides:\n - " + override + "\n" + minimalModelCatalogYAML()
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return installationPath, projectDirectory, envFile, override
|
||||
}
|
||||
|
||||
func minimalModelCatalogYAML() string {
|
||||
return `modelCatalog:
|
||||
defaults:
|
||||
session: deepseek/deepseek-v4-pro
|
||||
embedding:
|
||||
id: ollama/qwen3-embedding:0.6b
|
||||
dimensions: 1024
|
||||
providers:
|
||||
deepseek:
|
||||
authentication:
|
||||
mode: pi_auth
|
||||
session:
|
||||
mode: pi_builtin
|
||||
models:
|
||||
deepseek-v4-pro:
|
||||
session: {}
|
||||
`
|
||||
}
|
||||
|
||||
func assertStringsEqual(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
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 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 {
|
||||
Session string `yaml:"session" json:"session"`
|
||||
MetadataGeneration string `yaml:"metadataGeneration,omitempty" json:"metadataGeneration,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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.Session == "" {
|
||||
return errors.New("modelCatalog.defaults.session 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
|
||||
for modelID, model := range provider.Models {
|
||||
if !catalogKeyPattern.MatchString(modelID) {
|
||||
return fmt.Errorf("modelCatalog model %q/%q is invalid", providerID, modelID)
|
||||
}
|
||||
canonical := providerID + "/" + modelID
|
||||
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.Session, "session") {
|
||||
return fmt.Errorf("modelCatalog.defaults.session %q is not a session model", c.Defaults.Session)
|
||||
}
|
||||
if hasMetadata && c.Defaults.MetadataGeneration == "" {
|
||||
return errors.New("modelCatalog.defaults.metadataGeneration is required when metadata models are configured")
|
||||
}
|
||||
if !hasMetadata && c.Defaults.MetadataGeneration != "" {
|
||||
return errors.New("modelCatalog.defaults.metadataGeneration must be empty when metadata generation is unavailable")
|
||||
}
|
||||
if hasMetadata && !runtimeModelEligible(models, c.Defaults.MetadataGeneration, "metadata") {
|
||||
return fmt.Errorf("modelCatalog.defaults.metadataGeneration %q is not a metadata-generation model", c.Defaults.MetadataGeneration)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"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/service"
|
||||
@@ -101,7 +102,7 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner
|
||||
return Report{}, errors.New("doctor requires an HTTP probe")
|
||||
}
|
||||
secretValues, secretErr := secretValues(installation)
|
||||
report := Report{Checks: make([]Check, 0, 12)}
|
||||
report := Report{Checks: make([]Check, 0, 13)}
|
||||
add := func(name, status, detail string) {
|
||||
report.Checks = append(report.Checks, Check{Name: name, Status: status, Detail: output.SanitizeDetail(detail, secretValues)})
|
||||
}
|
||||
@@ -111,6 +112,13 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner
|
||||
} else {
|
||||
add("descriptor", StatusPassed, "installation descriptor is loaded")
|
||||
}
|
||||
if drift, err := modelprojection.Check(installation); err != nil {
|
||||
add("model-projection", StatusFailed, "model runtime projections could not be recomputed")
|
||||
} else if len(drift) > 0 {
|
||||
add("model-projection", StatusFailed, "generated model runtime projections differ: "+strings.Join(drift, ", "))
|
||||
} else {
|
||||
add("model-projection", StatusPassed, "generated model runtime projections match modelCatalog")
|
||||
}
|
||||
if err := filePermissions(installation); err != nil || secretErr != nil {
|
||||
if secretErr != nil {
|
||||
add("files", StatusFailed, "declared secret files could not be read")
|
||||
|
||||
@@ -11,6 +11,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/modelprojection"
|
||||
)
|
||||
|
||||
// Catches treating an unavailable Docker executable as a successful diagnosis.
|
||||
@@ -132,7 +133,7 @@ func TestRunFailsAuthenticationWithoutExecWhenCoreIsRunningButUnhealthy(t *testi
|
||||
if report.OK || checkStatus(report, "authentication") != StatusFailed {
|
||||
t.Fatalf("Run() report = %#v, want deterministic failed authentication", report)
|
||||
}
|
||||
assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"})
|
||||
assertChecklist(t, report, []string{"descriptor", "model-projection", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"})
|
||||
if strings.Contains(strings.Join(runner.calls, "\n"), " exec -T ") {
|
||||
t.Fatalf("Run() invoked exec -T while core was unhealthy: %v", runner.calls)
|
||||
}
|
||||
@@ -170,7 +171,7 @@ func TestRunUsesOnlyContainerLocalWorkflowAndPiDiagnosticsWhenCoreRuns(t *testin
|
||||
if !report.OK || checkStatus(report, "authentication") != "passed" || checkStatus(report, "workflow") != "passed" || checkStatus(report, "pi") != "passed" {
|
||||
t.Fatalf("Run() report = %#v, want successful container diagnostics", report)
|
||||
}
|
||||
assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"})
|
||||
assertChecklist(t, report, []string{"descriptor", "model-projection", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"})
|
||||
calls := strings.Join(runner.calls, "\n")
|
||||
if !strings.Contains(calls, "exec -T core node dist/auth/diagnostic-command.js --json") {
|
||||
t.Fatalf("Run() calls = %s, want core-local authentication diagnostic", calls)
|
||||
@@ -261,7 +262,7 @@ func TestRunFailsBeforeDockerWhenDeclaredSecretCorpusIsIncomplete(t *testing.T)
|
||||
if strings.Contains(reportText(report), missing) {
|
||||
t.Fatalf("incomplete corpus report exposed a secret path: %#v", report)
|
||||
}
|
||||
assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"})
|
||||
assertChecklist(t, report, []string{"descriptor", "model-projection", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"})
|
||||
}
|
||||
|
||||
func doctorInstallation(t *testing.T, _ string) config.Installation {
|
||||
@@ -290,7 +291,11 @@ func doctorInstallation(t *testing.T, _ string) config.Installation {
|
||||
if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return config.Installation{Path: filepath.Join(root, "thothii-installation.yaml"), Profile: "local", ProjectDirectory: project, EnvFile: envFile}
|
||||
installation := config.Installation{Path: filepath.Join(root, "thothii-installation.yaml"), Profile: "local", ProjectDirectory: project, EnvFile: envFile}
|
||||
if err := modelprojection.Generate(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return installation
|
||||
}
|
||||
|
||||
type doctorRunner struct {
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
// Package modelmigration creates a reviewable v2 installation candidate from legacy model files.
|
||||
package modelmigration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/compose-spec/compose-go/v2/dotenv"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const maxMigrationInputBytes = 1 << 20
|
||||
|
||||
// Request contains the facts that do not exist unambiguously in the three legacy model sources.
|
||||
type Request struct {
|
||||
InstallationPath string
|
||||
OutputPath string
|
||||
SessionDefault string
|
||||
EmbeddingID string
|
||||
EmbeddingDimensions int
|
||||
}
|
||||
|
||||
type legacyDescriptor struct {
|
||||
SchemaVersion int `yaml:"schemaVersion,omitempty"`
|
||||
Profile string `yaml:"profile"`
|
||||
ProjectDirectory string `yaml:"projectDirectory"`
|
||||
EnvFile string `yaml:"envFile"`
|
||||
WorkspaceRepository workspaceRepository `yaml:"workspaceRepository"`
|
||||
Authentication authenticationDescriptor `yaml:"authentication"`
|
||||
MetadataGeneration legacyMetadataCatalog `yaml:"metadataGeneration"`
|
||||
Overrides []string `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
type workspaceRepository struct {
|
||||
Remote string `yaml:"remote"`
|
||||
Branch string `yaml:"branch"`
|
||||
Access string `yaml:"access"`
|
||||
}
|
||||
|
||||
type authenticationDescriptor struct {
|
||||
ConfigDirectory string `yaml:"configDirectory"`
|
||||
RuntimeProjection *runtimeProjection `yaml:"runtimeProjection,omitempty"`
|
||||
}
|
||||
|
||||
type runtimeProjection struct {
|
||||
Directory string `yaml:"directory"`
|
||||
UID uint32 `yaml:"uid"`
|
||||
GID uint32 `yaml:"gid"`
|
||||
}
|
||||
|
||||
type legacyMetadataCatalog struct {
|
||||
Default string `yaml:"default"`
|
||||
Models []legacyMetadataModel `yaml:"models"`
|
||||
}
|
||||
|
||||
type legacyMetadataModel struct {
|
||||
ID string `yaml:"id"`
|
||||
Label string `yaml:"label"`
|
||||
LiteLLM legacyLiteLLM `yaml:"litellm"`
|
||||
APIKeyEnv string `yaml:"apiKeyEnv"`
|
||||
}
|
||||
|
||||
type legacyLiteLLM struct {
|
||||
Provider string `yaml:"provider"`
|
||||
Model string `yaml:"model"`
|
||||
DisableThinking bool `yaml:"disableThinking"`
|
||||
Endpoint *config.ModelEndpoint `yaml:"endpoint"`
|
||||
}
|
||||
|
||||
type piCatalog 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"`
|
||||
}
|
||||
|
||||
type candidateDescriptor struct {
|
||||
SchemaVersion int `yaml:"schemaVersion"`
|
||||
Profile string `yaml:"profile"`
|
||||
ProjectDirectory string `yaml:"projectDirectory"`
|
||||
EnvFile string `yaml:"envFile"`
|
||||
WorkspaceRepository workspaceRepository `yaml:"workspaceRepository"`
|
||||
ModelCatalog config.ModelCatalog `yaml:"modelCatalog"`
|
||||
Authentication authenticationDescriptor `yaml:"authentication"`
|
||||
Overrides []string `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Run reads every legacy source, reports ambiguity without publishing, and otherwise writes one
|
||||
// new candidate. It never modifies or removes the legacy files.
|
||||
func Run(request Request) error {
|
||||
if !filepath.IsAbs(request.InstallationPath) || filepath.Base(request.InstallationPath) != "thothii-installation.yaml" {
|
||||
return errors.New("installation migration requires an absolute thothii-installation.yaml path")
|
||||
}
|
||||
if !filepath.IsAbs(request.OutputPath) || filepath.Base(request.OutputPath) != "thothii-installation.yaml" ||
|
||||
filepath.Clean(request.OutputPath) == filepath.Clean(request.InstallationPath) {
|
||||
return errors.New("installation migration output must be a different absolute thothii-installation.yaml path")
|
||||
}
|
||||
if request.SessionDefault == "" || request.EmbeddingID == "" || request.EmbeddingDimensions <= 0 {
|
||||
return errors.New("installation migration requires session default, embedding id, and positive embedding dimensions")
|
||||
}
|
||||
if _, err := os.Lstat(request.OutputPath); err == nil {
|
||||
return errors.New("installation migration output already exists")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return errors.New("installation migration output is unavailable")
|
||||
}
|
||||
|
||||
var legacy legacyDescriptor
|
||||
if err := decodeYAML(request.InstallationPath, &legacy); err != nil {
|
||||
return fmt.Errorf("legacy installation: %w", err)
|
||||
}
|
||||
if legacy.SchemaVersion != 0 {
|
||||
return errors.New("legacy installation: schemaVersion must be absent")
|
||||
}
|
||||
var piModels piCatalog
|
||||
if err := decodeJSON(filepath.Join(legacy.ProjectDirectory, "deploy", "pi", "models.json"), &piModels); err != nil {
|
||||
return fmt.Errorf("deploy/pi/models.json: %w", err)
|
||||
}
|
||||
var settings piSettings
|
||||
if err := decodeJSON(filepath.Join(legacy.ProjectDirectory, "deploy", "pi", "settings.json"), &settings); err != nil {
|
||||
return fmt.Errorf("deploy/pi/settings.json: %w", err)
|
||||
}
|
||||
|
||||
catalog, err := reconcileCatalog(legacy, piModels, settings, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
environment, err := parseEnvironment(legacy.EnvFile)
|
||||
if err != nil {
|
||||
return errors.New("legacy installation envFile is unavailable or invalid")
|
||||
}
|
||||
if err := catalog.Validate(environment); err != nil {
|
||||
return fmt.Errorf("modelCatalog candidate: %w", err)
|
||||
}
|
||||
candidate := candidateDescriptor{
|
||||
SchemaVersion: 2, Profile: legacy.Profile, ProjectDirectory: legacy.ProjectDirectory,
|
||||
EnvFile: legacy.EnvFile, WorkspaceRepository: legacy.WorkspaceRepository,
|
||||
ModelCatalog: catalog, Authentication: legacy.Authentication, Overrides: legacy.Overrides,
|
||||
}
|
||||
contents, err := yaml.Marshal(candidate)
|
||||
if err != nil {
|
||||
return errors.New("installation migration candidate could not be encoded")
|
||||
}
|
||||
return publishCandidate(request.OutputPath, contents)
|
||||
}
|
||||
|
||||
func reconcileCatalog(legacy legacyDescriptor, piModels piCatalog, settings piSettings, request Request) (config.ModelCatalog, error) {
|
||||
catalog := config.ModelCatalog{
|
||||
Defaults: config.ModelCatalogDefaults{Session: request.SessionDefault},
|
||||
Embedding: config.ModelCatalogEmbedding{ID: request.EmbeddingID, Dimensions: request.EmbeddingDimensions},
|
||||
Providers: make(map[string]config.ModelProvider),
|
||||
}
|
||||
enabled := make(map[string]struct{}, len(settings.EnabledModels))
|
||||
for index, canonical := range settings.EnabledModels {
|
||||
providerID, modelID, ok := splitCanonical(canonical)
|
||||
if !ok {
|
||||
return catalog, fmt.Errorf("deploy/pi/settings.json enabledModels[%d]: canonical provider/model identity is required", index)
|
||||
}
|
||||
if _, duplicate := enabled[canonical]; duplicate {
|
||||
return catalog, fmt.Errorf("deploy/pi/settings.json enabledModels[%d]: duplicate identity %q", index, canonical)
|
||||
}
|
||||
enabled[canonical] = struct{}{}
|
||||
piProvider, custom := piModels.Providers[providerID]
|
||||
if !custom {
|
||||
provider := catalog.Providers[providerID]
|
||||
if len(provider.Models) == 0 {
|
||||
provider = config.ModelProvider{
|
||||
Authentication: config.ModelAuthentication{Mode: "pi_auth"},
|
||||
Session: &config.ModelSessionAdapter{Mode: "pi_builtin"},
|
||||
Models: make(map[string]config.CatalogModel),
|
||||
}
|
||||
}
|
||||
provider.Models[modelID] = config.CatalogModel{Session: &config.SessionModel{}}
|
||||
catalog.Providers[providerID] = provider
|
||||
continue
|
||||
}
|
||||
model, found := findPiModel(piProvider.Models, modelID)
|
||||
if !found {
|
||||
return catalog, fmt.Errorf("deploy/pi/settings.json enabledModels[%d]: %q is missing from deploy/pi/models.json", index, canonical)
|
||||
}
|
||||
provider, exists := catalog.Providers[providerID]
|
||||
if !exists {
|
||||
auth, authErr := migratePiAuthentication(providerID, piProvider)
|
||||
if authErr != nil {
|
||||
return catalog, authErr
|
||||
}
|
||||
provider = config.ModelProvider{
|
||||
Endpoint: &config.ModelEndpoint{BaseURL: piProvider.BaseURL}, Authentication: auth,
|
||||
Session: &config.ModelSessionAdapter{Mode: "openai_compatible"},
|
||||
Models: make(map[string]config.CatalogModel),
|
||||
}
|
||||
}
|
||||
provider.Models[modelID] = config.CatalogModel{
|
||||
Label: model.Name,
|
||||
Session: &config.SessionModel{
|
||||
Reasoning: model.Reasoning, Input: model.Input, Cost: model.Cost,
|
||||
ContextWindow: model.ContextWindow, MaxTokens: model.MaxTokens,
|
||||
Compatibility: model.Compat,
|
||||
},
|
||||
}
|
||||
catalog.Providers[providerID] = provider
|
||||
}
|
||||
if _, ok := enabled[request.SessionDefault]; !ok {
|
||||
return catalog, fmt.Errorf("session default %q is not enabled by deploy/pi/settings.json", request.SessionDefault)
|
||||
}
|
||||
|
||||
metadataIDs := make(map[string]string, len(legacy.MetadataGeneration.Models))
|
||||
for index, old := range legacy.MetadataGeneration.Models {
|
||||
canonical, mergeErr := mergeMetadataModel(&catalog, old)
|
||||
if mergeErr != nil {
|
||||
return catalog, fmt.Errorf("metadataGeneration.models[%d]: %w", index, mergeErr)
|
||||
}
|
||||
if old.ID == "" {
|
||||
return catalog, fmt.Errorf("metadataGeneration.models[%d]: id is required", index)
|
||||
}
|
||||
if _, duplicate := metadataIDs[old.ID]; duplicate {
|
||||
return catalog, fmt.Errorf("metadataGeneration.models[%d]: duplicate legacy id %q", index, old.ID)
|
||||
}
|
||||
metadataIDs[old.ID] = canonical
|
||||
}
|
||||
if len(metadataIDs) > 0 {
|
||||
mapped, ok := metadataIDs[legacy.MetadataGeneration.Default]
|
||||
if !ok {
|
||||
return catalog, errors.New("metadataGeneration.default does not identify one legacy model")
|
||||
}
|
||||
catalog.Defaults.MetadataGeneration = mapped
|
||||
} else if legacy.MetadataGeneration.Default != "" {
|
||||
return catalog, errors.New("metadataGeneration.default is set without models")
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func mergeMetadataModel(catalog *config.ModelCatalog, old legacyMetadataModel) (string, error) {
|
||||
if old.LiteLLM.Provider == "" || old.LiteLLM.Model == "" {
|
||||
return "", errors.New("litellm.provider and litellm.model are required")
|
||||
}
|
||||
type match struct{ provider, model string }
|
||||
matches := make([]match, 0, 1)
|
||||
for providerID, provider := range catalog.Providers {
|
||||
if old.LiteLLM.Endpoint != nil && !sameEndpoint(provider.Endpoint, old.LiteLLM.Endpoint) {
|
||||
continue
|
||||
}
|
||||
for modelID, model := range provider.Models {
|
||||
upstream := model.UpstreamModel
|
||||
if upstream == "" {
|
||||
upstream = modelID
|
||||
}
|
||||
if upstream == old.LiteLLM.Model && (old.LiteLLM.Endpoint != nil || providerID == old.LiteLLM.Provider) {
|
||||
matches = append(matches, match{providerID, modelID})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return "", errors.New("model identity matches more than one Pi provider")
|
||||
}
|
||||
providerID, modelID := old.LiteLLM.Provider, old.LiteLLM.Model
|
||||
if len(matches) == 1 {
|
||||
providerID, modelID = matches[0].provider, matches[0].model
|
||||
}
|
||||
provider, exists := catalog.Providers[providerID]
|
||||
desiredAuth := config.ModelAuthentication{Mode: "none"}
|
||||
if old.APIKeyEnv != "" {
|
||||
desiredAuth = config.ModelAuthentication{Mode: "secret_env", APIKeyEnv: old.APIKeyEnv}
|
||||
}
|
||||
if !exists {
|
||||
provider = config.ModelProvider{
|
||||
Endpoint: old.LiteLLM.Endpoint, Authentication: desiredAuth,
|
||||
MetadataGeneration: &config.ModelMetadataAdapter{LiteLLMProvider: old.LiteLLM.Provider},
|
||||
Models: make(map[string]config.CatalogModel),
|
||||
}
|
||||
} else {
|
||||
if provider.Authentication != desiredAuth {
|
||||
return "", fmt.Errorf("provider %q authentication conflicts between Pi and metadata generation", providerID)
|
||||
}
|
||||
if old.LiteLLM.Endpoint != nil && !sameEndpoint(provider.Endpoint, old.LiteLLM.Endpoint) {
|
||||
return "", fmt.Errorf("provider %q endpoint conflicts between Pi and metadata generation", providerID)
|
||||
}
|
||||
if provider.MetadataGeneration != nil && provider.MetadataGeneration.LiteLLMProvider != old.LiteLLM.Provider {
|
||||
return "", fmt.Errorf("provider %q LiteLLM adapter is ambiguous", providerID)
|
||||
}
|
||||
provider.MetadataGeneration = &config.ModelMetadataAdapter{LiteLLMProvider: old.LiteLLM.Provider}
|
||||
}
|
||||
model := provider.Models[modelID]
|
||||
if model.MetadataGeneration != nil {
|
||||
return "", fmt.Errorf("canonical identity %q is claimed by more than one legacy metadata model", providerID+"/"+modelID)
|
||||
}
|
||||
if model.Label == "" {
|
||||
model.Label = old.Label
|
||||
}
|
||||
if modelID != old.LiteLLM.Model {
|
||||
model.UpstreamModel = old.LiteLLM.Model
|
||||
}
|
||||
model.MetadataGeneration = &config.MetadataGenerationModel{DisableThinking: old.LiteLLM.DisableThinking}
|
||||
provider.Models[modelID] = model
|
||||
catalog.Providers[providerID] = provider
|
||||
return providerID + "/" + modelID, nil
|
||||
}
|
||||
|
||||
func migratePiAuthentication(providerID string, provider piProvider) (config.ModelAuthentication, error) {
|
||||
if provider.BaseURL == "" || provider.API != "openai-completions" {
|
||||
return config.ModelAuthentication{}, fmt.Errorf("deploy/pi/models.json provider %q: only explicit openai-completions endpoints can migrate", providerID)
|
||||
}
|
||||
if strings.HasPrefix(provider.APIKey, "$") && len(provider.APIKey) > 1 {
|
||||
return config.ModelAuthentication{Mode: "secret_env", APIKeyEnv: provider.APIKey[1:]}, nil
|
||||
}
|
||||
if provider.APIKey == "local" {
|
||||
return config.ModelAuthentication{Mode: "none"}, nil
|
||||
}
|
||||
return config.ModelAuthentication{}, fmt.Errorf("deploy/pi/models.json provider %q: API key cannot be reconciled without guessing", providerID)
|
||||
}
|
||||
|
||||
func findPiModel(models []piModel, id string) (piModel, bool) {
|
||||
var found piModel
|
||||
count := 0
|
||||
for _, model := range models {
|
||||
if model.ID == id {
|
||||
found, count = model, count+1
|
||||
}
|
||||
}
|
||||
return found, count == 1
|
||||
}
|
||||
|
||||
func splitCanonical(value string) (string, string, bool) {
|
||||
parts := strings.Split(value, "/")
|
||||
return first(parts), second(parts), len(parts) == 2 && parts[0] != "" && parts[1] != ""
|
||||
}
|
||||
|
||||
func first(parts []string) string {
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func second(parts []string) string {
|
||||
if len(parts) > 1 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sameEndpoint(left, right *config.ModelEndpoint) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == nil && right == nil
|
||||
}
|
||||
return left.BaseURL == right.BaseURL && left.APIVersion == right.APIVersion
|
||||
}
|
||||
|
||||
func decodeYAML(path string, target any) error {
|
||||
source, err := readBounded(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(source))
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return errors.New("invalid or unsupported YAML")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return errors.New("multiple YAML documents are not supported")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJSON(path string, target any) error {
|
||||
source, err := readBounded(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(source))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return errors.New("invalid or unsupported JSON")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return errors.New("trailing JSON is not supported")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() < 1 || info.Size() > maxMigrationInputBytes {
|
||||
return nil, errors.New("source is unavailable or unsafe")
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func parseEnvironment(path string) (map[string]string, error) {
|
||||
source, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dotenv.ParseWithLookup(bytes.NewReader(source), os.LookupEnv)
|
||||
}
|
||||
|
||||
func publishCandidate(path string, contents []byte) error {
|
||||
directory := filepath.Dir(path)
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return errors.New("create migration output directory")
|
||||
}
|
||||
temporary, err := os.CreateTemp(directory, ".installation-v2-*")
|
||||
if err != nil {
|
||||
return errors.New("create installation migration candidate")
|
||||
}
|
||||
name := temporary.Name()
|
||||
published := false
|
||||
defer func() {
|
||||
if !published {
|
||||
_ = os.Remove(name)
|
||||
}
|
||||
}()
|
||||
if err := temporary.Chmod(0o600); err == nil {
|
||||
_, err = temporary.Write(contents)
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("write installation migration candidate")
|
||||
}
|
||||
if err := os.Link(name, path); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return errors.New("installation migration output already exists")
|
||||
}
|
||||
return errors.New("publish installation migration candidate")
|
||||
}
|
||||
published = true
|
||||
_ = os.Remove(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseDimensions is shared by the host command without accepting floats or signs.
|
||||
func ParseDimensions(value string) (int, error) {
|
||||
dimensions, err := strconv.Atoi(value)
|
||||
if err != nil || dimensions <= 0 {
|
||||
return 0, errors.New("embedding dimensions must be a positive integer")
|
||||
}
|
||||
return dimensions, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package modelmigration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestRunEmitsValidatedCandidateWithoutChangingLegacyInputs(t *testing.T) {
|
||||
fixture := migrationFixture(t, false)
|
||||
original, _ := os.ReadFile(fixture.legacy)
|
||||
request := fixture.request()
|
||||
if err := Run(request); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
after, _ := os.ReadFile(fixture.legacy)
|
||||
if string(after) != string(original) {
|
||||
t.Fatal("legacy installation descriptor changed")
|
||||
}
|
||||
// The strict v2 loader deliberately refuses legacy Pi sources; removing them is the
|
||||
// operator's explicit cut-over after reviewing the candidate.
|
||||
var raw legacyDescriptor
|
||||
decodeYAMLFile(t, fixture.legacy, &raw)
|
||||
if err := os.Remove(filepath.Join(raw.ProjectDirectory, "deploy", "pi", "models.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(raw.ProjectDirectory, "deploy", "pi", "settings.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidate, err := config.Load(request.OutputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("candidate config.Load() error = %v", err)
|
||||
}
|
||||
if candidate.SchemaVersion != 2 || candidate.ModelCatalog.Defaults.Session != "zai/glm-5.3" {
|
||||
t.Fatalf("candidate = %#v", candidate.ModelCatalog)
|
||||
}
|
||||
if got := candidate.ModelCatalog.Defaults.MetadataGeneration; got != "zai/glm-5.3" {
|
||||
t.Fatalf("metadata default = %q", got)
|
||||
}
|
||||
if candidate.ModelCatalog.Providers["deepseek"].Session.Mode != "pi_builtin" {
|
||||
t.Fatal("built-in Pi model was not migrated")
|
||||
}
|
||||
}
|
||||
|
||||
func decodeYAMLFile(t *testing.T, path string, target any) {
|
||||
t.Helper()
|
||||
source, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := yaml.Unmarshal(source, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReportsProviderAuthenticationConflictAndPublishesNothing(t *testing.T) {
|
||||
fixture := migrationFixture(t, true)
|
||||
err := Run(fixture.request())
|
||||
if err == nil || !strings.Contains(err.Error(), "metadataGeneration.models[0]") ||
|
||||
!strings.Contains(err.Error(), "authentication") {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(fixture.request().OutputPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("candidate was published after conflict: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
type migrationTestFixture struct {
|
||||
legacy string
|
||||
output string
|
||||
}
|
||||
|
||||
func (f migrationTestFixture) request() Request {
|
||||
return Request{
|
||||
InstallationPath: f.legacy,
|
||||
OutputPath: f.output,
|
||||
SessionDefault: "zai/glm-5.3",
|
||||
EmbeddingID: "ollama/qwen3-embedding:0.6b",
|
||||
EmbeddingDimensions: 1024,
|
||||
}
|
||||
}
|
||||
|
||||
func migrationFixture(t *testing.T, conflict bool) migrationTestFixture {
|
||||
t.Helper()
|
||||
base, err := filepath.EvalSymlinks(os.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := os.MkdirTemp(base, "tht-model-migration-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
project := filepath.Join(root, "project")
|
||||
if err := os.MkdirAll(filepath.Join(project, "deploy", "pi"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{filepath.Join(project, "compose.yaml"), filepath.Join(project, "deploy", "compose.local.yaml")} {
|
||||
if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
env := filepath.Join(root, "operator.env")
|
||||
secrets := filepath.Join(root, "secrets.env")
|
||||
auth := filepath.Join(root, "auth")
|
||||
if err := os.WriteFile(secrets, []byte("ZAI_API_KEY=test\nDEEPSEEK_API_KEY=test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(env, []byte("THT_SECRETS_FILE="+secrets+"\nTHT_AUTH_CONFIG_ROOT="+auth+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(auth, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := ` default: glm-53
|
||||
models:
|
||||
- id: glm-53
|
||||
label: GLM 5.3
|
||||
litellm:
|
||||
provider: openai
|
||||
model: glm-5.3
|
||||
endpoint:
|
||||
baseUrl: https://api.z.ai/v1
|
||||
apiKeyEnv: ZAI_API_KEY
|
||||
`
|
||||
if conflict {
|
||||
metadata = ` default: deepseek-v4-pro
|
||||
models:
|
||||
- id: deepseek-v4-pro
|
||||
label: DeepSeek
|
||||
litellm: {provider: deepseek, model: deepseek-v4-pro}
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
`
|
||||
}
|
||||
legacy := filepath.Join(root, "legacy", "thothii-installation.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := "profile: local\nprojectDirectory: " + project + "\nenvFile: " + env +
|
||||
"\nmetadataGeneration:\n" + metadata +
|
||||
"authentication:\n configDirectory: " + auth + "\n"
|
||||
if err := os.WriteFile(legacy, []byte(source), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
models := `{"providers":{"zai":{"baseUrl":"https://api.z.ai/v1","api":"openai-completions","apiKey":"$ZAI_API_KEY","models":[{"id":"glm-5.3","name":"GLM 5.3","reasoning":true,"contextWindow":200000,"maxTokens":131072}]}}}`
|
||||
settings := `{"defaultProjectTrust":"always","enabledModels":["deepseek/deepseek-v4-pro","zai/glm-5.3"]}`
|
||||
if err := os.WriteFile(filepath.Join(project, "deploy", "pi", "models.json"), []byte(models), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(project, "deploy", "pi", "settings.json"), []byte(settings), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return migrationTestFixture{legacy: legacy, output: filepath.Join(root, "candidate", "thothii-installation.yaml")}
|
||||
}
|
||||
@@ -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}}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,215 +1,17 @@
|
||||
package pi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
)
|
||||
|
||||
var choicePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
|
||||
|
||||
type Defaults struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Thinking string `json:"thinking"`
|
||||
}
|
||||
|
||||
type ModelOption struct {
|
||||
Provider string `json:"provider"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type piOptions struct {
|
||||
Providers []string `json:"providers"`
|
||||
Models []ModelOption `json:"models"`
|
||||
Reasoning []string `json:"reasoning"`
|
||||
}
|
||||
|
||||
type settingsFileSnapshot struct {
|
||||
Exists bool `json:"exists"`
|
||||
RawBase64 string `json:"rawBase64"`
|
||||
}
|
||||
|
||||
// Configure changes the backend's real installation settings through a core-side helper. It
|
||||
// deliberately has no secret or endpoint input: external endpoints remain Compose-owned.
|
||||
func Configure(ctx context.Context, runner Runner, value Defaults) error {
|
||||
if !choicePattern.MatchString(value.Provider) || !choicePattern.MatchString(value.Model) {
|
||||
return errors.New("provider and model must be supported identifiers")
|
||||
}
|
||||
before, err := renderedCore(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options, err := configurationOptions(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, model := range options.Models {
|
||||
if model.Provider == value.Provider && model.ID == value.Model {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("provider/model is not in Pi options")
|
||||
}
|
||||
thinkingFound := false
|
||||
for _, reasoning := range options.Reasoning {
|
||||
if reasoning == value.Thinking {
|
||||
thinkingFound = true
|
||||
}
|
||||
}
|
||||
if !thinkingFound {
|
||||
return errors.New("thinking is not in Pi options")
|
||||
}
|
||||
old, err := captureSettingsFile(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldEffective, err := readEffectiveSettings(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restore := func(cause error) error {
|
||||
if restoreErr := restoreSettingsFile(context.Background(), runner, old); restoreErr != nil {
|
||||
return fmt.Errorf("%w; previous Pi settings restoration could not be verified: %w", cause, restoreErr)
|
||||
}
|
||||
restoredEffective, restoreErr := readEffectiveSettings(context.Background(), runner)
|
||||
if restoreErr != nil || !bytes.Equal(restoredEffective, oldEffective) {
|
||||
return fmt.Errorf("%w; previous effective Pi settings could not be verified: recovery required", cause)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
result, err := writeDefaults(ctx, runner, value)
|
||||
if err != nil {
|
||||
return restore(commandError("Pi installation settings write", result, err))
|
||||
}
|
||||
settings, err := readEffectiveSettings(ctx, runner)
|
||||
if err != nil {
|
||||
return restore(err)
|
||||
}
|
||||
var saved Defaults
|
||||
if json.Unmarshal(settings, &saved) != nil || saved.Provider != value.Provider || saved.Model != value.Model || saved.Thinking != value.Thinking {
|
||||
return restore(errors.New("Pi installation settings read-back did not match requested provider, model, and thinking"))
|
||||
}
|
||||
after, err := renderedCore(ctx, runner)
|
||||
if err != nil {
|
||||
return restore(err)
|
||||
}
|
||||
if before.ConfigurationSHA != after.ConfigurationSHA {
|
||||
return restore(errors.New("external endpoint configuration changed while configuring Pi"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ConfigurationOptions(ctx context.Context, runner Runner) ([]ModelOption, error) {
|
||||
options, err := configurationOptions(ctx, runner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return options.Models, nil
|
||||
}
|
||||
|
||||
func configurationOptions(ctx context.Context, runner Runner) (piOptions, error) {
|
||||
result, err := runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/operator-command.js", "pi-options")
|
||||
if err != nil {
|
||||
return piOptions{}, commandError("Pi options check", result, err)
|
||||
}
|
||||
var payload piOptions
|
||||
if json.Unmarshal([]byte(result.Stdout), &payload) != nil || len(payload.Providers) == 0 || len(payload.Models) == 0 || len(payload.Reasoning) == 0 {
|
||||
return piOptions{}, errors.New("Pi options response is invalid or empty")
|
||||
}
|
||||
providers := make(map[string]bool, len(payload.Providers))
|
||||
for _, provider := range payload.Providers {
|
||||
if !choicePattern.MatchString(provider) || providers[provider] {
|
||||
return piOptions{}, errors.New("Pi options response contains an invalid provider")
|
||||
}
|
||||
providers[provider] = true
|
||||
}
|
||||
models := make(map[string]bool, len(payload.Models))
|
||||
for _, option := range payload.Models {
|
||||
key := option.Provider + "\x00" + option.ID
|
||||
if !providers[option.Provider] || !choicePattern.MatchString(option.ID) || models[key] {
|
||||
return piOptions{}, errors.New("Pi options response contains an invalid provider/model")
|
||||
}
|
||||
models[key] = true
|
||||
}
|
||||
reasoning := make(map[string]bool, len(payload.Reasoning))
|
||||
for _, value := range payload.Reasoning {
|
||||
if (value != "low" && value != "medium" && value != "high") || reasoning[value] {
|
||||
return piOptions{}, errors.New("Pi options response contains an invalid reasoning choice")
|
||||
}
|
||||
reasoning[value] = true
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func writeDefaults(ctx context.Context, runner Runner, value Defaults) (compose.Result, error) {
|
||||
return runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--provider", value.Provider, "--model", value.Model, "--thinking", value.Thinking)
|
||||
}
|
||||
|
||||
func captureSettingsFile(ctx context.Context, runner Runner) (settingsFileSnapshot, error) {
|
||||
result, err := runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--snapshot")
|
||||
if err != nil {
|
||||
return settingsFileSnapshot{}, commandError("Pi installation settings snapshot", result, err)
|
||||
}
|
||||
var snapshot settingsFileSnapshot
|
||||
if json.Unmarshal([]byte(result.Stdout), &snapshot) != nil {
|
||||
return settingsFileSnapshot{}, errors.New("Pi installation settings snapshot is invalid")
|
||||
}
|
||||
raw, decodeErr := base64.StdEncoding.DecodeString(snapshot.RawBase64)
|
||||
if decodeErr != nil || base64.StdEncoding.EncodeToString(raw) != snapshot.RawBase64 || (!snapshot.Exists && len(raw) != 0) {
|
||||
return settingsFileSnapshot{}, errors.New("Pi installation settings snapshot is invalid")
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func restoreSettingsFile(ctx context.Context, runner Runner, snapshot settingsFileSnapshot) error {
|
||||
payload, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return errors.New("Pi installation settings snapshot could not be encoded")
|
||||
}
|
||||
args := []string{"compose", "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--restore"}
|
||||
result, restoreErr := runner.Run(ctx, args, bytes.NewReader(payload))
|
||||
verified, verifyErr := captureSettingsFile(ctx, runner)
|
||||
if restoreErr != nil {
|
||||
cause := commandError("Pi installation settings restore", result, restoreErr)
|
||||
if verifyErr == nil && verified == snapshot {
|
||||
return recoveryRequired("previous Pi settings bytes were restored but durability was not acknowledged", cause)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
if verifyErr == nil && verified == snapshot {
|
||||
return nil
|
||||
}
|
||||
return errors.New("Pi installation settings restore did not reproduce the exact prior file state")
|
||||
}
|
||||
|
||||
func readEffectiveSettings(ctx context.Context, runner Runner) ([]byte, error) {
|
||||
result, err := runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/operator-command.js", "effective-settings")
|
||||
if err != nil {
|
||||
return nil, commandError("Pi installation settings read-back", result, err)
|
||||
}
|
||||
var settings map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(result.Stdout), &settings) != nil || settings == nil {
|
||||
return nil, errors.New("Pi installation settings read-back is invalid")
|
||||
}
|
||||
canonical, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return nil, errors.New("Pi installation settings read-back could not be normalized")
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
// Runner is the narrow, shell-free command boundary shared with tht.
|
||||
type Runner interface {
|
||||
Run(context.Context, []string, io.Reader) (compose.Result, error)
|
||||
|
||||
@@ -2,14 +2,8 @@ package pi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
)
|
||||
|
||||
func TestDoctorRequiresExternalEndpointAuthPiStateAndHealth(t *testing.T) {
|
||||
@@ -41,206 +35,6 @@ func TestDoctorRejectsActualEnvironmentAndImageLabelVersionMismatches(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRestoresAndVerifiesOldSettingsAfterEveryPostSnapshotFailure(t *testing.T) {
|
||||
for _, failure := range []string{"helper", "readback", "digest"} {
|
||||
t.Run(failure, func(t *testing.T) {
|
||||
old := Defaults{Provider: "old", Model: "old-model", Thinking: "low"}
|
||||
raw, _ := json.Marshal(old)
|
||||
fake := &configureRunner{failure: failure, settings: old, settingsExist: true, settingsRaw: raw}
|
||||
err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"})
|
||||
if err == nil {
|
||||
t.Fatal("Configure() error = nil, want injected failure")
|
||||
}
|
||||
if fake.settings != (Defaults{Provider: "old", Model: "old-model", Thinking: "low"}) {
|
||||
t.Fatalf("settings after failure = %#v, want old snapshot", fake.settings)
|
||||
}
|
||||
if !fake.settingsExist || string(fake.settingsRaw) != string(raw) {
|
||||
t.Fatalf("settings raw snapshot after failure = exists:%t raw:%q, want %q", fake.settingsExist, fake.settingsRaw, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsRestoreDoesNotMaskExplicitDurabilityFailureWithMatchingReadback(t *testing.T) {
|
||||
old := Defaults{Provider: "old", Model: "old-model", Thinking: "low"}
|
||||
raw, _ := json.Marshal(old)
|
||||
fake := &configureRunner{
|
||||
failure: "restore-durability",
|
||||
settings: Defaults{Provider: "new", Model: "new-model", Thinking: "high"},
|
||||
settingsExist: true,
|
||||
settingsRaw: []byte(`{"provider":"new","model":"new-model","thinking":"high"}`),
|
||||
}
|
||||
snapshot := settingsFileSnapshot{Exists: true, RawBase64: base64.StdEncoding.EncodeToString(raw)}
|
||||
|
||||
err := restoreSettingsFile(context.Background(), fake, snapshot)
|
||||
|
||||
var recovery interface{ RecoveryRequired() bool }
|
||||
if err == nil || !errors.As(err, &recovery) || !recovery.RecoveryRequired() {
|
||||
t.Fatalf("restore error = %v; want typed recovery-required result", err)
|
||||
}
|
||||
if !fake.settingsExist || string(fake.settingsRaw) != string(raw) || fake.settings != old {
|
||||
t.Fatalf("restored state = exists:%t raw:%q value:%#v; want exact old bytes", fake.settingsExist, fake.settingsRaw, fake.settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurePreservesTypedRecoveryRequiredErrorFromSettingsRestore(t *testing.T) {
|
||||
old := Defaults{Provider: "old", Model: "old-model", Thinking: "low"}
|
||||
raw, _ := json.Marshal(old)
|
||||
fake := &configureRunner{
|
||||
failure: "helper",
|
||||
restoreDurabilityFailure: true,
|
||||
settings: old,
|
||||
settingsExist: true,
|
||||
settingsRaw: raw,
|
||||
}
|
||||
|
||||
err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"})
|
||||
|
||||
var recovery interface{ RecoveryRequired() bool }
|
||||
if err == nil || !errors.As(err, &recovery) || !recovery.RecoveryRequired() {
|
||||
t.Fatalf("Configure() error = %v; want typed recovery-required result", err)
|
||||
}
|
||||
}
|
||||
|
||||
type configureRunner struct {
|
||||
calls []string
|
||||
failure string
|
||||
restoreDurabilityFailure bool
|
||||
settings Defaults
|
||||
settingsExist bool
|
||||
settingsRaw []byte
|
||||
settingsReads int
|
||||
configReads int
|
||||
writes int
|
||||
}
|
||||
|
||||
func (f *configureRunner) Run(_ context.Context, args []string, stdin io.Reader) (compose.Result, error) {
|
||||
call := strings.Join(args, " ")
|
||||
f.calls = append(f.calls, call)
|
||||
switch {
|
||||
case strings.Contains(call, "config --format json"):
|
||||
f.configReads++
|
||||
endpoint := "https://llm.example.invalid"
|
||||
if f.failure == "digest" && f.configReads > 1 {
|
||||
endpoint = "https://drift.example.invalid"
|
||||
}
|
||||
return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"` + endpoint + `"}}}}`}, nil
|
||||
case strings.Contains(call, "operator-command.js pi-options"):
|
||||
return compose.Result{Stdout: `{"providers":["old","new"],"models":[{"provider":"old","id":"old-model"},{"provider":"new","id":"new-model"}],"reasoning":["low","medium","high"]}`}, nil
|
||||
case strings.Contains(call, "settings-cli.js --snapshot"):
|
||||
raw := f.settingsRaw
|
||||
payload := map[string]any{"exists": f.settingsExist, "rawBase64": base64.StdEncoding.EncodeToString(raw)}
|
||||
contents, _ := json.Marshal(payload)
|
||||
return compose.Result{Stdout: string(contents)}, nil
|
||||
case strings.Contains(call, "settings-cli.js --restore"):
|
||||
var payload struct {
|
||||
Exists bool `json:"exists"`
|
||||
RawBase64 string `json:"rawBase64"`
|
||||
}
|
||||
contents, _ := io.ReadAll(stdin)
|
||||
if json.Unmarshal(contents, &payload) != nil {
|
||||
return compose.Result{ExitCode: 2}, errors.New("invalid restore payload")
|
||||
}
|
||||
f.settingsExist = payload.Exists
|
||||
f.settingsRaw, _ = base64.StdEncoding.DecodeString(payload.RawBase64)
|
||||
f.settings = Defaults{Provider: "old", Model: "old-model", Thinking: "low"}
|
||||
if payload.Exists {
|
||||
_ = json.Unmarshal(f.settingsRaw, &f.settings)
|
||||
}
|
||||
if f.failure == "restore-durability" || f.restoreDurabilityFailure {
|
||||
return compose.Result{ExitCode: 2}, errors.New("injected post-rename directory fsync failure")
|
||||
}
|
||||
return compose.Result{}, nil
|
||||
case strings.Contains(call, "settings-cli.js"):
|
||||
if strings.Contains(call, "--provider new") {
|
||||
f.settings = Defaults{Provider: "new", Model: "new-model", Thinking: "high"}
|
||||
f.settingsExist = true
|
||||
f.settingsRaw, _ = json.MarshalIndent(f.settings, "", " ")
|
||||
f.writes++
|
||||
if f.failure == "helper" {
|
||||
return compose.Result{ExitCode: 17}, errors.New("injected helper failure")
|
||||
}
|
||||
} else {
|
||||
f.settings = Defaults{Provider: "old", Model: "old-model", Thinking: "low"}
|
||||
f.settingsExist = true
|
||||
f.settingsRaw, _ = json.Marshal(f.settings)
|
||||
}
|
||||
return compose.Result{}, nil
|
||||
case strings.Contains(call, "operator-command.js effective-settings"):
|
||||
f.settingsReads++
|
||||
if f.failure == "readback" && f.settings.Provider == "new" {
|
||||
return compose.Result{Stdout: `{}`}, nil
|
||||
}
|
||||
if !f.settingsExist {
|
||||
return compose.Result{Stdout: `{"provider":"old","model":"old-model","thinking":"low"}`}, nil
|
||||
}
|
||||
contents, _ := json.Marshal(f.settings)
|
||||
return compose.Result{Stdout: string(contents)}, nil
|
||||
default:
|
||||
return compose.Result{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureAllowsAFirstRunWithoutAnExistingSettingsFile(t *testing.T) {
|
||||
fake := &configureRunner{}
|
||||
if err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}); err != nil {
|
||||
t.Fatalf("Configure() clean install error = %v", err)
|
||||
}
|
||||
if !fake.settingsExist || fake.writes != 1 || fake.settings.Provider != "new" {
|
||||
t.Fatalf("clean settings = exists:%t writes:%d value:%#v", fake.settingsExist, fake.writes, fake.settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureCompensationRestoresAbsentAndExactEmptyPriorFiles(t *testing.T) {
|
||||
for _, prior := range []struct {
|
||||
name string
|
||||
exists bool
|
||||
raw []byte
|
||||
}{
|
||||
{name: "absent"},
|
||||
{name: "empty", exists: true, raw: []byte{}},
|
||||
{name: "exact raw", exists: true, raw: []byte("{\n \"workspace\": \"kept\",\n \"provider\": \"old\",\n \"model\": \"old-model\",\n \"thinking\": \"low\"\n}\n")},
|
||||
} {
|
||||
t.Run(prior.name, func(t *testing.T) {
|
||||
fake := &configureRunner{failure: "digest", settingsExist: prior.exists, settingsRaw: append([]byte{}, prior.raw...), settings: Defaults{Provider: "old", Model: "old-model", Thinking: "low"}}
|
||||
err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"})
|
||||
if err == nil {
|
||||
t.Fatal("Configure() error = nil, want compensated digest failure")
|
||||
}
|
||||
if fake.writes != 1 {
|
||||
t.Fatalf("settings writes = %d, want selected values written before compensation", fake.writes)
|
||||
}
|
||||
if fake.settingsExist != prior.exists || string(fake.settingsRaw) != string(prior.raw) {
|
||||
t.Fatalf("restored exists/raw = %t/%q, want %t/%q", fake.settingsExist, fake.settingsRaw, prior.exists, prior.raw)
|
||||
}
|
||||
if fake.settingsReads < 3 {
|
||||
t.Fatalf("settings reads = %d, want prior effective state, requested readback, and restored default verification", fake.settingsReads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Catches tht reading the legacy public model route instead of the admin-only closed Pi
|
||||
// Management choices before it writes shared installation defaults.
|
||||
func TestConfigureUsesScopedCoreCommandWithoutMintingAnHTTPIdentity(t *testing.T) {
|
||||
fake := &configureRunner{
|
||||
settings: Defaults{Provider: "old", Model: "old-model", Thinking: "low"},
|
||||
settingsExist: true,
|
||||
settingsRaw: []byte(`{"provider":"old","model":"old-model","thinking":"low"}`),
|
||||
}
|
||||
if err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCalled(t, fake.calls, "operator-command.js pi-options")
|
||||
assertCalled(t, fake.calls, "node /app/backend/dist/settings/settings-cli.js --provider new --model new-model --thinking high")
|
||||
if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "x-thoth-principal") || strings.Contains(got, "x-thoth-is-admin") || strings.Contains(got, "pi-defaults.json") || strings.Contains(got, "secret") {
|
||||
t.Fatalf("commands=%q", got)
|
||||
}
|
||||
if err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "unknown", Thinking: "medium"}); err == nil {
|
||||
t.Fatal("expected unknown model rejection")
|
||||
}
|
||||
}
|
||||
|
||||
// Catches a smoke check that composes health/models/settings itself and drifts from the dedicated
|
||||
// backend contract, rather than retaining only the independent in-container version signal.
|
||||
func TestTestUsesDedicatedSmokeEndpointAndIndependentImageVersionProbe(t *testing.T) {
|
||||
@@ -275,13 +69,3 @@ func TestTestRequiresDedicatedSmokeEndpointToReportReady(t *testing.T) {
|
||||
}
|
||||
assertCalled(t, fake.calls, "pi --version")
|
||||
}
|
||||
|
||||
// Catches tht accepting a reasoning level that the backend did not publish as a closed
|
||||
// installation option, which would bypass the Pi Management validation surface.
|
||||
func TestConfigureRejectsReasoningOutsideDedicatedClosedOptions(t *testing.T) {
|
||||
fake := newFakeRunner()
|
||||
fake.piManagementOptionsWire = `{"providers":["provider"],"models":[{"provider":"provider","id":"model"}],"reasoning":["low"]}`
|
||||
if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "model", Thinking: "high"}); err == nil || !strings.Contains(err.Error(), "Pi options") {
|
||||
t.Fatalf("Configure() error = %v, want closed reasoning rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ import (
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/modelprojection"
|
||||
)
|
||||
|
||||
const healthTimeout = 5 * time.Minute
|
||||
|
||||
var healthPollInterval = time.Second
|
||||
var requireRuntimeAuthProjectionReady = authconfig.RequireRuntimeAuthProjectionReady
|
||||
var generateModelProjections = modelprojection.Generate
|
||||
|
||||
// HealthFailure identifies the last non-ready service after a bounded health wait.
|
||||
type HealthFailure struct {
|
||||
@@ -46,6 +48,9 @@ func Start(ctx context.Context, installation config.Installation, runner compose
|
||||
if err := requireRuntimeAuthProjectionReady(installation); err != nil {
|
||||
return errors.New("runtime authentication projection is unavailable")
|
||||
}
|
||||
if err := generateModelProjections(installation); err != nil {
|
||||
return fmt.Errorf("model runtime projection: %w", err)
|
||||
}
|
||||
if build {
|
||||
if err := runCompose(ctx, installation, runner, "build"); err != nil {
|
||||
return fmt.Errorf("image build: %w", err)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
// Catches --build being ignored, which would run stale images after a checkout update.
|
||||
func TestStartBuildsBeforeStartingAndCheckingHealth(t *testing.T) {
|
||||
stubModelProjection(t, nil)
|
||||
runner := &recordingRunner{}
|
||||
installation := testInstallation()
|
||||
|
||||
@@ -26,6 +27,7 @@ func TestStartBuildsBeforeStartingAndCheckingHealth(t *testing.T) {
|
||||
|
||||
// Catches a normal start unnecessarily rebuilding images.
|
||||
func TestStartSkipsBuildUnlessRequested(t *testing.T) {
|
||||
stubModelProjection(t, nil)
|
||||
runner := &recordingRunner{}
|
||||
installation := testInstallation()
|
||||
|
||||
@@ -38,6 +40,7 @@ func TestStartSkipsBuildUnlessRequested(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStartRefusesProjectedAuthenticationBeforeComposeWhenNotReady(t *testing.T) {
|
||||
stubModelProjection(t, nil)
|
||||
for _, state := range []string{"missing", "blocked", "divergent"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
previous := requireRuntimeAuthProjectionReady
|
||||
@@ -61,6 +64,26 @@ func TestStartRefusesProjectedAuthenticationBeforeComposeWhenNotReady(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRefusesProjectionFailureBeforeCompose(t *testing.T) {
|
||||
stubModelProjection(t, errors.New("synthetic projection failure"))
|
||||
runner := &recordingRunner{}
|
||||
|
||||
err := Start(context.Background(), testInstallation(), runner, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "model runtime projection") {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
if len(runner.stages) != 0 {
|
||||
t.Fatalf("Start() reached Compose: %v", runner.stages)
|
||||
}
|
||||
}
|
||||
|
||||
func stubModelProjection(t *testing.T, result error) {
|
||||
t.Helper()
|
||||
previous := generateModelProjections
|
||||
generateModelProjections = func(config.Installation) error { return result }
|
||||
t.Cleanup(func() { generateModelProjections = previous })
|
||||
}
|
||||
|
||||
type recordingRunner struct{ stages []string }
|
||||
|
||||
func (r *recordingRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -46,6 +47,7 @@ type answers struct {
|
||||
}
|
||||
|
||||
type generatedDescriptor struct {
|
||||
SchemaVersion int `yaml:"schemaVersion"`
|
||||
Profile string `yaml:"profile"`
|
||||
ProjectDirectory string `yaml:"projectDirectory"`
|
||||
EnvFile string `yaml:"envFile"`
|
||||
@@ -62,7 +64,8 @@ type generatedDescriptor struct {
|
||||
GID uint32 `yaml:"gid"`
|
||||
} `yaml:"runtimeProjection,omitempty"`
|
||||
} `yaml:"authentication"`
|
||||
Overrides []string `yaml:"overrides"`
|
||||
ModelCatalog config.ModelCatalog `yaml:"modelCatalog"`
|
||||
Overrides []string `yaml:"overrides"`
|
||||
}
|
||||
|
||||
// EnsureFiles writes a descriptor and non-secret environment file below deploy/<installation-id>.
|
||||
@@ -318,7 +321,7 @@ func installationDirectory(root, id string) (string, error) {
|
||||
}
|
||||
|
||||
func render(root, descriptorPath string, value answers) ([]byte, []byte, error) {
|
||||
descriptor := generatedDescriptor{Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName)}
|
||||
descriptor := generatedDescriptor{SchemaVersion: 2, Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName), ModelCatalog: defaultModelCatalog()}
|
||||
descriptor.Workspace.Remote, descriptor.Workspace.Branch, descriptor.Workspace.Access = value.workspaceRemote, value.workspaceBranch, value.workspaceAccess
|
||||
descriptor.Authentication.ConfigDirectory = filepath.Join(filepath.Dir(descriptorPath), "auth")
|
||||
if value.profile == "server" {
|
||||
@@ -372,6 +375,22 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error)
|
||||
return descriptorBytes, []byte(strings.Join(lines, "\n") + "\n"), nil
|
||||
}
|
||||
|
||||
func defaultModelCatalog() config.ModelCatalog {
|
||||
return config.ModelCatalog{
|
||||
Defaults: config.ModelCatalogDefaults{Session: "deepseek/deepseek-v4-pro"},
|
||||
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{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dotenvValue(value string) string { return strconv.Quote(value) }
|
||||
|
||||
func requireCompatibleOrAbsent(path string, expected []byte) error {
|
||||
|
||||
@@ -15,6 +15,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/project"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/service"
|
||||
)
|
||||
@@ -60,6 +61,9 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
|
||||
if err := configureAuthentication(ctx, installation, request, input, output); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := modelprojection.Generate(installation); err != nil {
|
||||
return Result{}, fmt.Errorf("setup model runtime projection: %w", err)
|
||||
}
|
||||
if err := runCompose(ctx, runner, installation, "config", "--quiet"); err != nil {
|
||||
return Result{}, fmt.Errorf("setup Compose configuration: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user