diff --git a/README.md b/README.md
index e2a4678..23d72d9 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ The plugin's config keys, lang namespace and plugin ID are a contract shared by
- **OpenAI-compatible adapter.** `providers.OpenAIAdapter` posts to `/chat/completions` with `Authorization: Bearer`, drops `response_format`, maps `max_tokens` to `max_completion_tokens` and adds `reasoning_effort: low` for o-series and gpt-5 models. It also serves compatible providers through their base URL. `providers.ForName` picks the adapter by name.
- **Anthropic adapter.** `providers.AnthropicAdapter` posts to `/messages` with `x-api-key`, `anthropic-version: 2023-06-01` and `anthropic-beta: files-api-2025-04-14`. It sends `max_tokens` (the prompt's `max_tokens`, else `max_completion_tokens`, else 4096), rewrites image blocks to base64 or URL image sources and file blocks to document blocks, and maps Anthropic's usage to `prompt_tokens`, `completion_tokens` and `total_tokens`. `Raw` keeps `stop_reason`.
- **AI service.** `services.AIService.Send` applies the model's system prompt when the prompt has none and posts the prompt to the config's chat endpoint; a nil config uses the default model. It returns the WinterCMS plugin's failure messages: no model configured, no API key, no connection, and an answer that is not JSON. The other calls are `services.AIService.SendStream` (server-sent events, one `{"content":…}` chunk per delta), `services.AIService.SendFile`, `services.AIService.SendFilePath` and `services.AIService.SendFileReader` (multipart upload to the files endpoint of a model with `has_files_endpoint`), `services.AIService.SendWithModel`, `services.AIService.SendToImageModel`, `services.AIService.SendToVisionModel`, `services.AIService.GenerateImage` (the images endpoint; check the returned URL with `security.AssertSafeURL` before fetching it) and `services.AIService.Ask`. `services.ForApp` returns the application's shared service.
-- **AI models.** Operators configure models in the admin under Golem › AI Models (controller `golem15.golem.models`, permission `golem15.golem.access_settings`). Each model has a name, an adapter (`openai` or `anthropic`), an API key, a base URL, a model id, a default system prompt, flags (enabled, default, generates images, accepts images, has a files endpoint), a completion token budget and an order. The key is encrypted at rest and write-only: the form shows it redacted, and an update that sends it back empty or redacted keeps the stored key. `services.Models`, `services.DefaultModel`, `services.VisionModel`, `services.ImageModel`, `services.FileModel` and `services.ModelByName` read them like the WinterCMS Settings model. Their configs are `Trusted`. `services.DefaultModel` keeps the WinterCMS quirk: without a model marked default it returns the first model only when that one is enabled.
+- **AI models.** Operators configure models in the admin under Golem › AI Models (controller `golem15.golem.models`, permission `golem15.golem.access_settings`). Each model has a name, an adapter (`openai` or `anthropic`), an API key, a base URL, a model id, a default system prompt, flags (enabled, default, generates images, accepts images, has a files endpoint), a completion token budget and an order. The key is encrypted at rest and write-only: the form shows it redacted, and an update that sends it back empty or redacted keeps the stored key, unless the same update changes the adapter or base URL: then the key must be entered again, so a kept key never follows a new endpoint. `services.Models`, `services.DefaultModel`, `services.VisionModel`, `services.ImageModel`, `services.FileModel` and `services.ModelByName` read them like the WinterCMS Settings model. Their configs are `Trusted`. `services.DefaultModel` keeps the WinterCMS quirk: without a model marked default it returns the first model only when that one is enabled.
- **Settings import.** `golem:import-settings` copies the AI models of a WinterCMS installation (the `models` repeater of the `system_settings` row `golem_settings`, or a JSON file) into the table once, encrypting each plaintext key.
- **SSRF guard.** `security.AssertSafeURL` ports the WinterCMS SSRFGuard: https only, a host on the allowlist (`security.AllowedHosts`), and a host that resolves to public addresses only (`fetchguard.IsPrivateAddr`). A failure is a `security.UnsafeURLError` with the WinterCMS message. Apply it to every base URL a user or an organisation supplies and to every URL a provider returns; the guarded client checks the address again when it connects.
- **Prompt factory.** `factories.PromptFactory` builds the WinterCMS plugin's ready-made prompts: a form field suggestion, values for several fields, categories for an item, a chat and a completion.
diff --git a/controllers/models_admin_controller.go b/controllers/models_admin_controller.go
index 0faf3ef..e48ef81 100644
--- a/controllers/models_admin_controller.go
+++ b/controllers/models_admin_controller.go
@@ -3,6 +3,7 @@ package controllers
import (
"context"
"errors"
+ "strings"
"git.golem15.com/golem15/sm-golem-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
@@ -15,7 +16,7 @@ const PermissionAccessSettings = "golem15.golem.access_settings"
// ModelsAdminController is the AI models list and form
// (golem15.golem.models). The API key is write-only: the form shows it
// redacted, and an update that sends it back empty or redacted keeps the
-// stored key.
+// stored key, unless the same update changes the adapter or base URL.
type ModelsAdminController struct{}
var (
@@ -64,8 +65,19 @@ func (ModelsAdminController) FormBeforeCreate(_ context.Context, model any) erro
return nil
}
+// baseURLOf is a model's base URL with nil and blank alike.
+func baseURLOf(m *models.AiModel) string {
+ if m.BaseURL == nil {
+ return ""
+ }
+ return strings.TrimSpace(*m.BaseURL)
+}
+
// FormBeforeUpdate keeps the stored key when the form sent it back empty
-// or redacted.
+// or redacted. A kept key never follows a changed endpoint: when the
+// adapter or base URL differs from the stored one, the key must be entered
+// again, so an operator who cannot read the key cannot redirect it to a
+// host they control either.
func (ModelsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
m, ok := model.(*models.AiModel)
if !ok {
@@ -79,9 +91,12 @@ func (ModelsAdminController) FormBeforeUpdate(ctx context.Context, model any) er
return errors.New("golem: no transaction for the model update")
}
var stored models.AiModel
- if err := tx.Select("api_key").Where("id = ?", m.ID).Take(&stored).Error; err != nil {
+ if err := tx.Select("api_key", "adapter", "base_url").Where("id = ?", m.ID).Take(&stored).Error; err != nil {
return err
}
+ if stored.Adapter != m.Adapter || baseURLOf(&stored) != baseURLOf(m) {
+ return &cabana.ValidationError{Details: map[string]any{"api_key": []string{"Re-enter the API key when changing the adapter or base URL."}}}
+ }
m.APIKey = stored.APIKey
if keyMissing(m) {
return &cabana.ValidationError{Details: map[string]any{"api_key": []string{"The api key field is required."}}}
diff --git a/golem_test.go b/golem_test.go
index 0da0d29..991cf3d 100644
--- a/golem_test.go
+++ b/golem_test.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
+ "maps"
"os"
"path/filepath"
"strings"
@@ -247,6 +248,32 @@ func TestAdminModelsForm(t *testing.T) {
if keyOf() != "sk-ant-admin-0002" {
t.Fatal("a new key was not stored")
}
+ // A kept key never follows a changed endpoint: changing the base URL
+ // or the adapter without re-entering the key is refused (WR-04).
+ for field, value := range map[string]any{"base_url": "https://attacker.example/v1", "adapter": "openai"} {
+ changed := maps.Clone(body)
+ changed["api_key"] = "[redacted]"
+ changed[field] = value
+ _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: changed})
+ if !errors.As(err, &ve) || ve.Details["api_key"] == nil {
+ t.Fatalf("a %s change with the redacted key = %v", field, err)
+ }
+ if keyOf() != "sk-ant-admin-0002" {
+ t.Fatalf("the refused %s change touched the key", field)
+ }
+ changed["api_key"] = "sk-ant-admin-0003"
+ if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: changed}); err != nil {
+ t.Fatalf("a %s change with a new key: %v", field, err)
+ }
+ if keyOf() != "sk-ant-admin-0003" {
+ t.Fatalf("the %s change did not store the new key", field)
+ }
+ restore := maps.Clone(body)
+ restore["base_url"] = ""
+ if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: restore}); err != nil {
+ t.Fatalf("restoring the endpoint: %v", err)
+ }
+ }
body["adapter"] = "gemini"
if _, err := svc.Update(ctx, cc, id, cabana.RecordInput{Body: body}); !errors.As(err, &ve) {
t.Fatalf("unknown adapter = %v", err)