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) } } }) }