mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 05:16:29 +02:00
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>
175 lines
5.7 KiB
Go
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())
|
|
}
|
|
}
|