Files
MOPAC/internal/llm/client_test.go
T
mrcharles aefa73aa90 harness: LiteLLM client, Redmine intake, REPORT writeback, gated bash tool
OpenAI-compatible chat client for the LiteLLM proxy (base_url normalization,
Bearer auth, retry/backoff on 429/5xx/transport, usage accounting) - stdlib
http only. Intake lists issues in scope from /issues.json with the task
class read from a configurable custom field. Writeback lands each turn as
REPORT-<vertical>-<task>-<ts>.md plus REPORT-latest.md (atomic rename) with
model/tier/token telemetry. The bash tool ports maki's permission semantics
without tree-sitter: segment-by-segment compound-command checks, deny beats
allow, word-boundary "cmd *" matching, $()/backtick/subshell denied, output
truncation, per-command timeout with process-group cleanup.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-28 19:20:57 -05:00

105 lines
3.3 KiB
Go

package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestEndpointNormalization(t *testing.T) {
cases := map[string]string{
"http://h:4001": "http://h:4001/v1/chat/completions",
"http://h:4001/": "http://h:4001/v1/chat/completions",
"http://h:4001/v1": "http://h:4001/v1/chat/completions",
"http://h:4001/v1/": "http://h:4001/v1/chat/completions",
"http://h/prefix": "http://h/prefix/v1/chat/completions",
"http://h/prefix/v1/": "http://h/prefix/v1/chat/completions",
}
for in, want := range cases {
c := NewClient(in, "k", time.Second, 0)
if c.endpoint != want {
t.Errorf("NewClient(%q).endpoint = %q, want %q", in, c.endpoint, want)
}
}
}
func TestChatSendsAuthAndModel(t *testing.T) {
var gotAuth, gotModel string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request: %v", err)
}
gotModel = req.Model
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`))
}))
defer srv.Close()
c := NewClient(srv.URL, "sk-test", time.Second, 0)
resp, err := c.Chat(context.Background(), ChatRequest{Model: "glm-5.3", Messages: []Message{{Role: "user", Content: "x"}}})
if err != nil {
t.Fatalf("Chat: %v", err)
}
if gotAuth != "Bearer sk-test" {
t.Errorf("auth = %q", gotAuth)
}
if gotModel != "glm-5.3" {
t.Errorf("model = %q", gotModel)
}
if resp.Choices[0].Message.Content != "hi" || resp.Usage.TotalTokens != 3 {
t.Errorf("resp = %+v", resp)
}
}
func TestChatRetriesOn5xxThenSucceeds(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
http.Error(w, "upstream lost", http.StatusBadGateway)
return
}
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
}))
defer srv.Close()
c := NewClient(srv.URL, "k", time.Second, 2)
resp, err := c.Chat(context.Background(), ChatRequest{Model: "m"})
if err != nil {
t.Fatalf("Chat: %v", err)
}
if resp.Choices[0].Message.Content != "ok" || calls.Load() != 2 {
t.Errorf("resp=%v calls=%d", resp, calls.Load())
}
}
func TestChatNoRetryOn4xx(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
http.Error(w, `{"error":"bad model"}`, http.StatusBadRequest)
}))
defer srv.Close()
c := NewClient(srv.URL, "k", time.Second, 3)
_, err := c.Chat(context.Background(), ChatRequest{Model: "m"})
if err == nil {
t.Fatal("expected error")
}
if calls.Load() != 1 {
t.Errorf("4xx must not retry, calls=%d", calls.Load())
}
}
func TestChatEmptyChoicesIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"choices":[]}`))
}))
defer srv.Close()
c := NewClient(srv.URL, "k", time.Second, 0)
if _, err := c.Chat(context.Background(), ChatRequest{Model: "m"}); err == nil {
t.Fatal("expected empty-choices error")
}
}