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