Files
multica/server/pkg/llm/client_test.go
Multica Eve 22a71bafe3 feat(server): add basic LLM API layer with OpenAI-compatible endpoints (#5138)
Integrate the official openai-go SDK (v3) as a thin, reusable LLM layer
(pkg/llm) backing lightweight utility calls that do not need the agent
runtime (chat titles, quick-create drafts, ...).

Expose two user-authenticated, OpenAI-compatible chat-completions
endpoints:
  - POST /api/llm/v1/chat/completions         (JSON response)
  - POST /api/llm/v1/chat/completions/stream  (SSE stream)

Requests decode directly into the SDK's ChatCompletionNewParams and
responses are relayed via RawJSON() for byte-exact OpenAI-format
compatibility. Base URL and API key are configurable (MULTICA_LLM_*),
and the model is taken from the request with a configurable default
fallback (MULTICA_LLM_DEFAULT_MODEL, else gpt-4o-mini). When unconfigured
the endpoints return 503.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 12:42:59 +08:00

175 lines
5.7 KiB
Go

package llm
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
openai "github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/shared"
)
// stubUpstream returns an httptest server that mimics the OpenAI
// chat-completions endpoint. handler receives the decoded request body.
func stubUpstream(t *testing.T, handler func(w http.ResponseWriter, body map[string]any)) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var body map[string]any
_ = json.Unmarshal(raw, &body)
handler(w, body)
}))
t.Cleanup(srv.Close)
return srv
}
func TestNewDisabledClient(t *testing.T) {
c := New(Config{})
if c.Enabled() {
t.Fatal("expected disabled client with empty config")
}
if c.DefaultModel() != FallbackModel {
t.Fatalf("expected fallback model %q, got %q", FallbackModel, c.DefaultModel())
}
if _, err := c.Chat(context.Background(), openai.ChatCompletionNewParams{}); err != ErrNotConfigured {
t.Fatalf("expected ErrNotConfigured, got %v", err)
}
if _, err := c.GenerateText(context.Background(), "", "", "hi"); err != ErrNotConfigured {
t.Fatalf("expected ErrNotConfigured from GenerateText, got %v", err)
}
}
func TestEnabledWithBaseURLOnly(t *testing.T) {
c := New(Config{BaseURL: "http://localhost:1234"})
if !c.Enabled() {
t.Fatal("expected enabled client when only base URL is set (keyless gateway)")
}
}
func TestConfiguredDefaultModel(t *testing.T) {
c := New(Config{APIKey: "k", DefaultModel: "my-model"})
if c.DefaultModel() != "my-model" {
t.Fatalf("expected configured default model, got %q", c.DefaultModel())
}
}
func TestChatAppliesDefaultModel(t *testing.T) {
var gotModel string
srv := stubUpstream(t, func(w http.ResponseWriter, body map[string]any) {
gotModel, _ = body["model"].(string)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"cmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`)
})
c := New(Config{APIKey: "test-key", BaseURL: srv.URL, DefaultModel: "default-x"})
// Request omits the model -> the configured default must be applied.
completion, err := c.Chat(context.Background(), openai.ChatCompletionNewParams{
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("hi")},
})
if err != nil {
t.Fatalf("Chat failed: %v", err)
}
if gotModel != "default-x" {
t.Fatalf("expected default model forwarded upstream, got %q", gotModel)
}
if len(completion.Choices) != 1 || completion.Choices[0].Message.Content != "hello" {
t.Fatalf("unexpected completion: %+v", completion.Choices)
}
if completion.RawJSON() == "" {
t.Fatal("expected non-empty RawJSON for passthrough")
}
}
func TestChatRespectsRequestModel(t *testing.T) {
var gotModel string
srv := stubUpstream(t, func(w http.ResponseWriter, body map[string]any) {
gotModel, _ = body["model"].(string)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"cmpl-1","object":"chat.completion","choices":[]}`)
})
c := New(Config{APIKey: "test-key", BaseURL: srv.URL, DefaultModel: "default-x"})
_, err := c.Chat(context.Background(), openai.ChatCompletionNewParams{
Model: shared.ChatModel("caller-model"),
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("hi")},
})
if err != nil {
t.Fatalf("Chat failed: %v", err)
}
if gotModel != "caller-model" {
t.Fatalf("expected caller model preserved, got %q", gotModel)
}
}
func TestGenerateText(t *testing.T) {
var sawSystem bool
srv := stubUpstream(t, func(w http.ResponseWriter, body map[string]any) {
if msgs, ok := body["messages"].([]any); ok {
for _, m := range msgs {
if mm, ok := m.(map[string]any); ok && mm["role"] == "system" {
sawSystem = true
}
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"cmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"a title"},"finish_reason":"stop"}]}`)
})
c := New(Config{APIKey: "k", BaseURL: srv.URL})
out, err := c.GenerateText(context.Background(), "", "you are helpful", "make a title")
if err != nil {
t.Fatalf("GenerateText failed: %v", err)
}
if out != "a title" {
t.Fatalf("expected %q, got %q", "a title", out)
}
if !sawSystem {
t.Fatal("expected system message to be sent")
}
}
func TestChatStream(t *testing.T) {
srv := stubUpstream(t, func(w http.ResponseWriter, _ map[string]any) {
w.Header().Set("Content-Type", "text/event-stream")
flusher, _ := w.(http.Flusher)
chunks := []string{
`{"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hel"}}]}`,
`{"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"lo"}}]}`,
}
for _, ch := range chunks {
_, _ = io.WriteString(w, "data: "+ch+"\n\n")
if flusher != nil {
flusher.Flush()
}
}
_, _ = io.WriteString(w, "data: [DONE]\n\n")
})
c := New(Config{APIKey: "k", BaseURL: srv.URL})
stream, err := c.ChatStream(context.Background(), openai.ChatCompletionNewParams{
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("hi")},
})
if err != nil {
t.Fatalf("ChatStream failed: %v", err)
}
defer stream.Close()
var content strings.Builder
for stream.Next() {
chunk := stream.Current()
if len(chunk.Choices) > 0 {
content.WriteString(chunk.Choices[0].Delta.Content)
}
}
if err := stream.Err(); err != nil {
t.Fatalf("stream error: %v", err)
}
if content.String() != "Hello" {
t.Fatalf("expected assembled content %q, got %q", "Hello", content.String())
}
}