- golem15_golem_models (encrypted write-only api_key) with its cabana list and form under golem15.golem.access_settings, and the Settings lookups (Models, DefaultModel with PHP's position-0 quirk, Vision, Image, File, ByName), every one Trusted - golem:import-settings copies the WinterCMS golem_settings repeater once - AnthropicAdapter; SendStream, SendFile/SendFilePath/SendFileReader, SendWithModel, SendToImageModel, SendToVisionModel, GenerateImage, Ask - PromptFactory; README documents the config keys, the environment variable, the command and the admin screen
255 lines
9.6 KiB
Go
255 lines
9.6 KiB
Go
package golem
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-golem-plugin/console"
|
|
"git.golem15.com/golem15/sm-golem-plugin/internal/pgtest"
|
|
"git.golem15.com/golem15/sm-golem-plugin/models"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
)
|
|
|
|
func testConfig(t testing.TB) *compass.Config {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: golem-test\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// TestGolemPluginBoot activates the plugin alone (no golem15.user: it
|
|
// requires no other plugin) and checks its ID, its config defaults and its
|
|
// lang namespace.
|
|
func TestGolemPluginBoot(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
app := backpack.New(cfg)
|
|
plugins, err := party.Activate(app, []string{"golem15.golem"})
|
|
if err != nil {
|
|
t.Fatalf("activate golem15.golem alone: %v", err)
|
|
}
|
|
if len(plugins) != 1 || plugins[0].ID() != "golem15.golem" {
|
|
t.Fatalf("activated plugins = %v", plugins)
|
|
}
|
|
if got := (&Plugin{}).Requires(); len(got) != 0 {
|
|
t.Fatalf("Requires() = %v, want none", got)
|
|
}
|
|
if got := cfg.Int("golem15.golem.ai.timeout_seconds"); got != 120 {
|
|
t.Fatalf("golem15.golem.ai.timeout_seconds = %d, want 120", got)
|
|
}
|
|
hosts, ok := cfg.Lookup("golem15.golem.ssrf.allowed_hosts")
|
|
if !ok {
|
|
t.Fatal("golem15.golem.ssrf.allowed_hosts is not set")
|
|
}
|
|
list, _ := hosts.([]any)
|
|
if len(list) != 2 || list[0] != "oaidalleapiprodscus.blob.core.windows.net" || list[1] != ".openai.com" {
|
|
t.Fatalf("allowed_hosts = %#v", hosts)
|
|
}
|
|
tr, ok := app.Lookup[*phrasebook.Translator]()
|
|
if !ok {
|
|
t.Fatal("no translator published")
|
|
}
|
|
if got := tr.Get(context.Background(), "golem15.golem::lang.plugin.name", nil); got != "Golem" {
|
|
t.Fatalf("golem15.golem::lang.plugin.name = %q", got)
|
|
}
|
|
if got := tr.Get(context.Background(), "golem15.golem::lang.permissions.access_settings", nil); got != "Manage AI settings" {
|
|
t.Fatalf("access_settings label = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestImportSettings: golem:import-settings copies the WinterCMS
|
|
// golem_settings repeater once, in order, encrypting each plaintext key;
|
|
// a second run is refused; a missing system_settings table is a clean
|
|
// notice.
|
|
func TestImportSettings(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := pgtest.DB(t)
|
|
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`DROP TABLE IF EXISTS system_settings`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { db.Exec(`DROP TABLE IF EXISTS system_settings`) })
|
|
|
|
if cmd := console.ImportSettings(nil); cmd.Name != "golem:import-settings" || len(cmd.Flags) != 1 || cmd.Flags[0].Name != "file" {
|
|
t.Fatalf("command = %+v", cmd)
|
|
}
|
|
if (&Plugin{}).Commands()[0].Name != "golem:import-settings" {
|
|
t.Fatal("the plugin does not register golem:import-settings")
|
|
}
|
|
|
|
_, err := console.ImportModels(ctx, db, "")
|
|
if !console.IsNothingToImport(err) || err.Error() != "No system_settings table: nothing to import." {
|
|
t.Fatalf("missing table = %v", err)
|
|
}
|
|
if err := db.Exec(`CREATE TABLE system_settings (id SERIAL PRIMARY KEY, item TEXT, value TEXT)`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := console.ImportModels(ctx, db, ""); !console.IsNothingToImport(err) || err.Error() != "No golem_settings row in system_settings: nothing to import." {
|
|
t.Fatalf("missing row = %v", err)
|
|
}
|
|
const plain = "sk-test-plain-01"
|
|
value := `{"models":[` +
|
|
`{"name":"Chat","adapter":"openai","api_key":"` + plain + `","base_url":"https://api.openai.com/v1","model":"gpt-4o","system_prompt":"","is_enabled":"1","is_default":"1","generates_images":"0","accepts_images":true,"has_files_endpoint":"0","max_completion_tokens":"4096"},` +
|
|
`{"name":"Claude","adapter":"anthropic","api_key":"sk-ant-parity-0002","base_url":"","model":"claude-sonnet-4-6","system_prompt":"Be terse.","is_enabled":"0","is_default":"0","max_completion_tokens":8192}` +
|
|
`]}`
|
|
if err := db.Exec(`INSERT INTO system_settings (item, value) VALUES ('golem_settings', ?)`, value).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n, err := console.ImportModels(ctx, db, "")
|
|
if err != nil || n != 2 {
|
|
t.Fatalf("import = %d, %v", n, err)
|
|
}
|
|
var rows []models.AiModel
|
|
if err := db.Order("sort_order").Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 2 || rows[0].Name != "Chat" || rows[0].SortOrder != 0 || rows[1].Name != "Claude" || rows[1].SortOrder != 1 {
|
|
t.Fatalf("rows = %+v", rows)
|
|
}
|
|
r0, r1 := rows[0], rows[1]
|
|
if !r0.IsEnabled || !r0.IsDefault || r0.GeneratesImages || !r0.AcceptsImages || r0.HasFilesEndpoint || r0.MaxCompletionTokens == nil || *r0.MaxCompletionTokens != 4096 || r0.BaseURL == nil || *r0.BaseURL != "https://api.openai.com/v1" || r0.SystemPrompt != nil {
|
|
t.Fatalf("row 0 = %+v", r0)
|
|
}
|
|
if r1.Adapter != "anthropic" || r1.IsEnabled || r1.BaseURL != nil || r1.SystemPrompt == nil || *r1.SystemPrompt != "Be terse." || *r1.MaxCompletionTokens != 8192 {
|
|
t.Fatalf("row 1 = %+v", r1)
|
|
}
|
|
if r0.APIKey.Reveal() != plain {
|
|
t.Fatal("the imported key does not decrypt to the plaintext")
|
|
}
|
|
var stored string
|
|
if err := db.Raw(`SELECT api_key FROM golem15_golem_models WHERE name = 'Chat'`).Scan(&stored).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored == "" || strings.Contains(stored, plain) || strings.Contains(stored, "sk-") {
|
|
t.Fatalf("the key is stored in plaintext: %q", stored)
|
|
}
|
|
|
|
if _, err := console.ImportModels(ctx, db, ""); !errors.Is(err, console.ErrModelsNotEmpty) {
|
|
t.Fatalf("second run = %v", err)
|
|
}
|
|
var count int64
|
|
db.Model(&models.AiModel{}).Count(&count)
|
|
if count != 2 {
|
|
t.Fatalf("second run wrote rows: %d", count)
|
|
}
|
|
|
|
// --file reads a JSON file instead (a bare models list here).
|
|
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file := filepath.Join(t.TempDir(), "golem_settings.json")
|
|
if err := os.WriteFile(file, []byte(`[{"name":"Local","model":"llama3","api_key":"local-key","base_url":"http://10.0.0.5:11434/v1","is_enabled":true}]`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n, err := console.ImportModels(ctx, db, file); err != nil || n != 1 {
|
|
t.Fatalf("file import = %d, %v", n, err)
|
|
}
|
|
var local models.AiModel
|
|
if err := db.Take(&local).Error; err != nil || local.Adapter != "openai" || local.APIKey.Reveal() != "local-key" {
|
|
t.Fatalf("file row = %+v, %v", local, err)
|
|
}
|
|
if _, err := console.ImportModels(ctx, db, filepath.Join(t.TempDir(), "missing.json")); err == nil {
|
|
t.Fatal("a missing file must fail")
|
|
}
|
|
}
|
|
|
|
// TestAdminModelsForm compiles the AI models list and form and saves
|
|
// through cabana's CRUD service: the key is required on create, shown
|
|
// redacted, kept when an update sends it back redacted or empty, and
|
|
// replaced by a new value.
|
|
func TestAdminModelsForm(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := pgtest.DB(t)
|
|
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := &Plugin{}
|
|
ctls := p.AdminControllers()
|
|
if len(ctls) != 1 || ctls[0].ID() != "golem15.golem.models" {
|
|
t.Fatalf("controllers = %v", ctls)
|
|
}
|
|
ctl := ctls[0]
|
|
if _, err := cabana.CompileList("golem15.golem", ctl, p.AdminFS()); err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
form, err := cabana.CompileForm("golem15.golem", ctl, p.AdminFS())
|
|
if err != nil {
|
|
t.Fatalf("form: %v", err)
|
|
}
|
|
cc := &cabana.CompiledController{PluginID: "golem15.golem", Controller: ctl, Form: form}
|
|
if err := cabana.BindWritableFields(cc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if perms := p.Permissions(); len(perms) != 1 || perms[0].Code != "golem15.golem.access_settings" {
|
|
t.Fatalf("permissions = %+v", perms)
|
|
}
|
|
if nav := p.Navigation(); len(nav) != 1 || nav[0].SideMenu[0].Controller != "golem15.golem.models" {
|
|
t.Fatalf("navigation = %+v", nav)
|
|
}
|
|
svc := cabana.CRUDService{DB: db}
|
|
body := map[string]any{"name": "Vision", "adapter": "anthropic", "model": "claude-sonnet-4-6", "is_enabled": true, "accepts_images": true}
|
|
_, err = svc.Create(ctx, cc, cabana.RecordInput{Body: body})
|
|
var ve *cabana.ValidationError
|
|
if !errors.As(err, &ve) || ve.Details["api_key"] == nil {
|
|
t.Fatalf("create without a key = %v", err)
|
|
}
|
|
body["api_key"] = "sk-ant-admin-0001"
|
|
rec, err := svc.Create(ctx, cc, cabana.RecordInput{Body: body})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := json.Marshal(rec)
|
|
if strings.Contains(string(raw), "sk-ant-admin-0001") {
|
|
t.Fatalf("the record echoes the key: %s", raw)
|
|
}
|
|
id := rec["id"]
|
|
keyOf := func() string {
|
|
var m models.AiModel
|
|
if err := db.Where("id = ?", id).Take(&m).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m.APIKey.Reveal()
|
|
}
|
|
if keyOf() != "sk-ant-admin-0001" {
|
|
t.Fatal("created key")
|
|
}
|
|
for _, sent := range []any{"[redacted]", ""} {
|
|
body["api_key"] = sent
|
|
body["model"] = "claude-opus"
|
|
if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: body}); err != nil {
|
|
t.Fatalf("update with %q: %v", sent, err)
|
|
}
|
|
if keyOf() != "sk-ant-admin-0001" {
|
|
t.Fatalf("an update with %q replaced the key", sent)
|
|
}
|
|
}
|
|
body["api_key"] = "sk-ant-admin-0002"
|
|
if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: body}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if keyOf() != "sk-ant-admin-0002" {
|
|
t.Fatal("a new key was not stored")
|
|
}
|
|
body["adapter"] = "gemini"
|
|
if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: body}); !errors.As(err, &ve) {
|
|
t.Fatalf("unknown adapter = %v", err)
|
|
}
|
|
}
|