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
105 lines
3.3 KiB
Go
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")
|
|
}
|
|
}
|