feat: unify installation model catalog
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user