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,183 @@
|
||||
// Package llm is a minimal OpenAI-compatible chat client pointed at the
|
||||
// LiteLLM proxy. Non-streaming v1: requests carry the concrete model name
|
||||
// resolved by the models router, with retry/backoff on 429/5xx/transport
|
||||
// errors (the whole request is retried; stream-resume lands later).
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function ToolDefinition `json:"function"`
|
||||
}
|
||||
|
||||
type ToolDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type ChatResponse struct {
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
type Choice struct {
|
||||
Message Message `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// Client talks to one LiteLLM (or any OpenAI-compatible) endpoint.
|
||||
type Client struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
// NewClient normalizes base_url (with or without a trailing /v1) and returns
|
||||
// a client. apiKey is used only for the Authorization header; never log it.
|
||||
func NewClient(baseURL, apiKey string, timeout time.Duration, maxRetries int) *Client {
|
||||
endpoint := strings.TrimSuffix(baseURL, "/")
|
||||
if !strings.HasSuffix(endpoint, "/v1") {
|
||||
endpoint += "/v1"
|
||||
}
|
||||
endpoint += "/chat/completions"
|
||||
return &Client{
|
||||
endpoint: endpoint,
|
||||
apiKey: apiKey,
|
||||
http: &http.Client{Timeout: timeout},
|
||||
maxRetries: maxRetries,
|
||||
}
|
||||
}
|
||||
|
||||
// StatusError marks HTTP failures; Code makes 429/5xx retryable.
|
||||
type StatusError struct {
|
||||
Code int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *StatusError) Error() string {
|
||||
return fmt.Sprintf("llm: HTTP %d: %s", e.Code, truncate(e.Body, 400))
|
||||
}
|
||||
|
||||
// Chat performs one chat-completions round trip with retry/backoff.
|
||||
func (c *Client) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: marshal request: %w", err)
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff(attempt)):
|
||||
}
|
||||
}
|
||||
resp, err := c.post(ctx, body)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !retryable(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("llm: giving up after %d attempts: %w", c.maxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, body []byte) (*ChatResponse, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
httpResp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: transport: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(httpResp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: read response: %w", err)
|
||||
}
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, &StatusError{Code: httpResp.StatusCode, Body: string(raw)}
|
||||
}
|
||||
var out ChatResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("llm: decode response: %w", err)
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return nil, fmt.Errorf("llm: response has no choices: %s", truncate(string(raw), 400))
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func retryable(err error) bool {
|
||||
var se *StatusError
|
||||
if errors.As(err, &se) {
|
||||
return se.Code == http.StatusTooManyRequests || se.Code >= 500
|
||||
}
|
||||
// Transport-level failures (connection reset, unexpected EOF, ...) retry.
|
||||
return true
|
||||
}
|
||||
|
||||
func backoff(attempt int) time.Duration {
|
||||
d := 500 * time.Millisecond << (attempt - 1)
|
||||
if d > 8*time.Second {
|
||||
d = 8 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -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