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
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user