Files
sm-golem-plugin/classes/providers/providers_test.go
Jakub Zych c93e2d9142 feat: AI models admin, settings import, Anthropic adapter and the rest of AIService
- 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
2026-10-03 23:09:02 +02:00

314 lines
12 KiB
Go

package providers
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
)
func payloadMap(t *testing.T, p *valueobjects.Payload) map[string]any {
t.Helper()
raw, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
// TestOpenAIAdapterPayload ports OpenAIAdapterTest.php.
func TestOpenAIAdapterPayload(t *testing.T) {
a := OpenAIAdapter{}
t.Run("strips response_format and migrates max_tokens", func(t *testing.T) {
prompt := valueobjects.NewPrompt().
WithSystem("You catalog physical items.").
AddUserMessage("List the items.").
WithOptions(map[string]any{"response_format": map[string]any{"type": "json_object"}, "max_tokens": 800}, "response_format", "max_tokens")
p := a.BuildChatPayload(prompt, "gpt-4o")
if p.Has("response_format") {
t.Fatal("response_format must be stripped")
}
if p.Has("max_tokens") {
t.Fatal("legacy max_tokens must be removed")
}
if v, _ := p.Get("max_completion_tokens"); v != 800 {
t.Fatalf("max_completion_tokens = %v, want 800", v)
}
m := payloadMap(t, p)
msgs := m["messages"].([]any)
if first := msgs[0].(map[string]any); first["role"] != "system" || first["content"] != "You catalog physical items." {
t.Fatalf("system message first: %v", first)
}
if strings.Join(p.Keys(), ",") != "model,messages,max_completion_tokens" {
t.Fatalf("keys = %v", p.Keys())
}
})
t.Run("explicit max_completion_tokens wins", func(t *testing.T) {
prompt := valueobjects.NewPrompt().AddUserMessage("List the items.").
WithOptions(map[string]any{"max_tokens": 800, "max_completion_tokens": 1200}, "max_tokens", "max_completion_tokens")
p := a.BuildChatPayload(prompt, "gpt-4o")
if p.Has("max_tokens") {
t.Fatal("max_tokens leaked")
}
if v, _ := p.Get("max_completion_tokens"); v != 1200 {
t.Fatalf("max_completion_tokens = %v, want 1200", v)
}
})
t.Run("reasoning models get reasoning_effort low", func(t *testing.T) {
prompt := valueobjects.NewPrompt().AddUserMessage("List the items.")
for _, model := range []string{"gpt-5.5", "gpt-5", "o1", "o3-mini", "o4-mini", "O3"} {
if v, _ := a.BuildChatPayload(prompt, model).Get("reasoning_effort"); v != "low" {
t.Fatalf("%s: reasoning_effort = %v", model, v)
}
}
})
t.Run("non-reasoning models get none and a caller value wins", func(t *testing.T) {
prompt := valueobjects.NewPrompt().AddUserMessage("List the items.")
for _, model := range []string{"gpt-4o", "gpt-4.1", "gpt-4o-mini"} {
if a.BuildChatPayload(prompt, model).Has("reasoning_effort") {
t.Fatalf("%s: reasoning_effort must not be set", model)
}
}
explicit := prompt.WithOptions(map[string]any{"reasoning_effort": "high"})
if v, _ := a.BuildChatPayload(explicit, "gpt-5.5").Get("reasoning_effort"); v != "high" {
t.Fatalf("caller reasoning_effort overridden: %v", v)
}
})
t.Run("native image_url block passes through", func(t *testing.T) {
b64 := base64.StdEncoding.EncodeToString([]byte("fake-image-bytes"))
prompt := valueobjects.NewPrompt().AddUserMessageWithImage("What is in this photo?", b64, "image/jpeg")
m := payloadMap(t, a.BuildChatPayload(prompt, "gpt-4o"))
parts := m["messages"].([]any)[0].(map[string]any)["content"].([]any)
img := parts[1].(map[string]any)
if img["type"] != "image_url" {
t.Fatalf("block = %v", img)
}
iu := img["image_url"].(map[string]any)
if iu["url"] != "data:image/jpeg;base64,"+b64 || iu["detail"] != "high" {
t.Fatalf("image_url = %v", iu)
}
if text := parts[0].(map[string]any); text["type"] != "text" || text["text"] != "What is in this photo?" {
t.Fatalf("text block = %v", text)
}
})
t.Run("prompt model override", func(t *testing.T) {
prompt := valueobjects.NewPrompt().AddUserMessage("hi").WithModel("gpt-4.1")
if v, _ := a.BuildChatPayload(prompt, "gpt-4o").Get("model"); v != "gpt-4.1" {
t.Fatalf("model = %v", v)
}
})
t.Run("parse surfaces the API error", func(t *testing.T) {
r := a.ParseChatResponse(map[string]any{"error": map[string]any{"message": "invalid API key"}})
if r.Success || r.Error != "invalid API key" {
t.Fatalf("response = %+v", r)
}
r = a.ParseChatResponse(map[string]any{"error": map[string]any{"code": "x"}})
if r.Error != "Unknown API error" {
t.Fatalf("error without message = %q", r.Error)
}
})
t.Run("parse content and usage", func(t *testing.T) {
var data map[string]any
_ = json.Unmarshal([]byte(`{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`), &data)
r := a.ParseChatResponse(data)
if !r.Success || r.Content != "pong" || r.TotalTokens() != 4 {
t.Fatalf("response = %+v", r)
}
if dig(r.Raw, "choices", 0, "finish_reason") != "stop" {
t.Fatal("raw keeps finish_reason")
}
r = a.ParseChatResponse(map[string]any{"choices": []any{}})
if r.Success || r.Error != "No content in AI response." {
t.Fatalf("no content = %+v", r)
}
})
t.Run("endpoints and headers", func(t *testing.T) {
if got := a.ChatEndpoint(""); got != "https://api.openai.com/v1/chat/completions" {
t.Fatal(got)
}
if got := a.ChatEndpoint("https://llm.example.test/v1/"); got != "https://llm.example.test/v1/chat/completions" {
t.Fatal(got)
}
if got := a.FilesEndpoint(""); got != "https://api.openai.com/v1/files" {
t.Fatal(got)
}
if got := a.ImageEndpoint(""); got != "https://api.openai.com/v1/images/generations" {
t.Fatal(got)
}
if got := a.Headers("sk-test").Get("Authorization"); got != "Bearer sk-test" {
t.Fatal(got)
}
if f := a.FileUploadFields("assistants"); len(f) != 1 || f[0].Name != "purpose" || f[0].Value != "assistants" {
t.Fatalf("fields = %v", f)
}
})
t.Run("stream chunks", func(t *testing.T) {
cases := []struct {
line string
delta string
done bool
}{
{"", "", false},
{": keep-alive", "", false},
{`data: {"choices":[{"delta":{"content":"Hel"}}]}`, "Hel", false},
{`data: {"choices":[{"delta":{}}]}`, "", false},
{"data: not json", "", false},
{"data: [DONE]", "", true},
}
for _, c := range cases {
d, done := a.ParseStreamChunk(c.line)
if d != c.delta || done != c.done {
t.Fatalf("%q: (%q,%v)", c.line, d, done)
}
}
})
}
// TestAnthropicAdapterPayload pins AnthropicAdapter.php: headers,
// endpoints, the max_tokens fallback chain, the dropped options, image
// and file block rewriting, response parsing and the event stream.
func TestAnthropicAdapterPayload(t *testing.T) {
a := AnthropicAdapter{}
if _, ok := ForName("anthropic").(AnthropicAdapter); !ok {
t.Fatal("ForName(anthropic) is not the Anthropic adapter")
}
if _, ok := ForName("").(OpenAIAdapter); !ok {
t.Fatal("ForName(\"\") is not the OpenAI adapter")
}
t.Run("headers and endpoints", func(t *testing.T) {
h := a.Headers("sk-ant-test")
if h.Get("X-Api-Key") != "sk-ant-test" || h.Get("Anthropic-Version") != "2023-06-01" || h.Get("Anthropic-Beta") != "files-api-2025-04-14" || h.Get("Authorization") != "" {
t.Fatalf("headers = %v", h)
}
if a.FileUploadHeaders("k").Get("X-Api-Key") != "k" {
t.Fatal("file upload headers")
}
if got := a.ChatEndpoint(""); got != "https://api.anthropic.com/v1/messages" {
t.Fatal(got)
}
if got := a.ChatEndpoint("https://proxy.example.test/v1///"); got != "https://proxy.example.test/v1/messages" {
t.Fatal(got)
}
if got := a.FilesEndpoint(""); got != "https://api.anthropic.com/v1/files" {
t.Fatal(got)
}
if a.ImageEndpoint("") != "" || a.FileUploadFields("assistants") != nil {
t.Fatal("anthropic has no image endpoint and no upload fields")
}
})
t.Run("max_tokens chain and dropped options", func(t *testing.T) {
base := valueobjects.NewPrompt().AddUserMessage("hi")
if v, _ := a.BuildChatPayload(base, "claude-sonnet-4-6").Get("max_tokens"); v != 4096 {
t.Fatalf("default max_tokens = %v", v)
}
p := a.BuildChatPayload(base.WithOptions(map[string]any{"max_completion_tokens": 8192, "response_format": map[string]any{"type": "json_object"}, "temperature": 0.2}), "claude-sonnet-4-6")
if v, _ := p.Get("max_tokens"); v != 8192 {
t.Fatalf("max_completion_tokens fallback = %v", v)
}
if p.Has("max_completion_tokens") || p.Has("response_format") {
t.Fatalf("dropped options leaked: %v", p.Keys())
}
if v, _ := p.Get("temperature"); v != 0.2 {
t.Fatal("other options pass through")
}
p = a.BuildChatPayload(base.WithOptions(map[string]any{"max_tokens": 100, "max_completion_tokens": 200}, "max_tokens", "max_completion_tokens"), "m")
if v, _ := p.Get("max_tokens"); v != 100 {
t.Fatalf("explicit max_tokens wins: %v", v)
}
if p.Has("system") {
t.Fatal("system without a system prompt")
}
if got := strings.Join(a.BuildChatPayload(base.WithSystem("sys"), "m").Keys(), ","); got != "model,messages,system,max_tokens" {
t.Fatalf("keys = %s", got)
}
})
t.Run("image and file blocks", func(t *testing.T) {
prompt := valueobjects.NewPrompt().
WithSystem("You catalog physical items.").
AddUserMessageWithImage("What is this?", "QUJD", "image/png").
AddUserMessageWithFileID("Read this.", "file_123").
WithModel("claude-opus")
m := payloadMap(t, a.BuildChatPayload(prompt, "claude-sonnet-4-6"))
if m["model"] != "claude-opus" || m["system"] != "You catalog physical items." {
t.Fatalf("payload = %v", m)
}
msgs := m["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("messages = %v (the system prompt is not a message)", msgs)
}
img := msgs[0].(map[string]any)["content"].([]any)[1].(map[string]any)
src := img["source"].(map[string]any)
if img["type"] != "image" || src["type"] != "base64" || src["media_type"] != "image/png" || src["data"] != "QUJD" {
t.Fatalf("image block = %v", img)
}
doc := msgs[1].(map[string]any)["content"].([]any)[1].(map[string]any)
if doc["type"] != "document" || doc["source"].(map[string]any)["file_id"] != "file_123" {
t.Fatalf("document block = %v", doc)
}
remote := valueobjects.Message{Role: "user", Parts: []valueobjects.ContentPart{{Type: "image_url", ImageURL: &valueobjects.ImageURL{URL: "https://img.example.test/a.jpg"}}}}
blk := anthropicContent(remote).([]any)[0].(map[string]any)["source"].(map[string]any)
if blk["type"] != "url" || blk["url"] != "https://img.example.test/a.jpg" {
t.Fatalf("remote image = %v", blk)
}
})
t.Run("parse", func(t *testing.T) {
var data map[string]any
_ = json.Unmarshal([]byte(`{"content":[{"type":"text","text":"[]"}],"stop_reason":"max_tokens","usage":{"input_tokens":10,"output_tokens":5}}`), &data)
r := a.ParseChatResponse(data)
if !r.Success || r.Content != "[]" || r.Raw["stop_reason"] != "max_tokens" {
t.Fatalf("response = %+v", r)
}
if r.PromptTokens() != 10 || r.CompletionTokens() != 5 || r.TotalTokens() != 15 || r.Usage["input_tokens"] != float64(10) {
t.Fatalf("usage = %v", r.Usage)
}
r = a.ParseChatResponse(map[string]any{"type": "error", "error": map[string]any{"type": "authentication_error", "message": "invalid x-api-key"}})
if r.Success || r.Error != "invalid x-api-key" {
t.Fatalf("error = %+v", r)
}
r = a.ParseChatResponse(map[string]any{"content": []any{}})
if r.Error != "No content in AI response." {
t.Fatalf("empty = %+v", r)
}
})
t.Run("stream", func(t *testing.T) {
for _, c := range []struct {
line string
delta string
done bool
}{
{"", "", false},
{"event: content_block_delta", "", false},
{`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}`, "Hi", false},
{`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}`, "", false},
{"data: {}", "", false},
{"event: message_stop", "", true},
} {
d, done := a.ParseStreamChunk(c.line)
if d != c.delta || done != c.done {
t.Fatalf("%q: (%q,%v)", c.line, d, done)
}
}
})
}