Files
sm-golem-plugin/classes/providers/providers_test.go
Jakub Zych cd3f0f6c1c feat: golem15.golem plugin with the OpenAI-compatible adapter and AIService.Send
- Prompt, Payload and AIResponse value objects ported from Golem15.Golem
- OpenAIAdapter: chat completions mapping with PHP's response_format,
  max_completion_tokens and reasoning_effort rules
- AIService.Send through fetchguard (TrustedMode for operator models,
  PublicOnlyMode otherwise) with PHP's failure messages
- config defaults for the SSRF allowlist and the AI timeout, lang, README
2026-10-03 22:41:49 +02:00

182 lines
6.3 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)
}
}
})
}