From 0aeb3aa99eb936af477c6ea5e47738c525421c6e Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 17:01:56 +0200 Subject: [PATCH] feat(adapters): copy stdlib llm package (Client + Router) per ADR-004 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy hyperguild/ingestion/internal/llm into internal/adapters/llm and own it. Tapir owes that repo nothing at the dependency level — no module dep added. Router gives the local-Primary -> BYO-Fallback path needed for ai_routing.feature. Copied tests rewritten from testify to stdlib testing to keep go.mod dependency-free (repo has zero deps; acceptance tests are stdlib too). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/llm/client.go | 124 +++++++++++++++++++++++++++ internal/adapters/llm/client_test.go | 103 ++++++++++++++++++++++ internal/adapters/llm/router.go | 29 +++++++ internal/adapters/llm/router_test.go | 78 +++++++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 internal/adapters/llm/client.go create mode 100644 internal/adapters/llm/client_test.go create mode 100644 internal/adapters/llm/router.go create mode 100644 internal/adapters/llm/router_test.go diff --git a/internal/adapters/llm/client.go b/internal/adapters/llm/client.go new file mode 100644 index 0000000..0c84a36 --- /dev/null +++ b/internal/adapters/llm/client.go @@ -0,0 +1,124 @@ +// Package llm is a COPY of hyperguild/ingestion/internal/llm (ADR-004). Tapir +// owns this copy; it is not a module dependency on that repo. The package is +// stdlib-only. Client calls an OpenAI-compatible chat completions endpoint; +// Router (router.go) implements the local-Primary -> BYO-Fallback pattern Tapir +// needs for docs/use-cases/ai_routing.feature. +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// Client calls an OpenAI-compatible chat completions endpoint. +type Client struct { + baseURL string + apiKey string + model string + httpClient *http.Client +} + +// New constructs a Client. +func New(baseURL, apiKey, model string, timeout time.Duration) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + model: model, + httpClient: &http.Client{Timeout: timeout}, + } +} + +type chatRequest struct { + Model string `json:"model"` + Messages []message `json:"messages"` + Temperature float64 `json:"temperature"` +} + +type message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatResponse struct { + Choices []struct { + Message message `json:"message"` + } `json:"choices"` +} + +// Complete sends a system + user message and returns the assistant's reply. +// Retries once on HTTP 429 using Retry-After header or 5s backoff. +func (c *Client) Complete(ctx context.Context, system, user string) (string, error) { + body := chatRequest{ + Model: c.model, + Messages: []message{ + {Role: "system", Content: system}, + {Role: "user", Content: user}, + }, + Temperature: 0.2, + } + b, err := json.Marshal(body) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + do := func() (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + return c.httpClient.Do(req) + } + + resp, err := do() + if err != nil { + return "", fmt.Errorf("call LLM: %w", err) + } + + if resp.StatusCode == http.StatusTooManyRequests { + _ = resp.Body.Close() + wait := 5 * time.Second + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil { + wait = time.Duration(secs) * time.Second + } + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(wait): + } + resp, err = do() + if err != nil { + return "", fmt.Errorf("retry LLM call: %w", err) + } + } + defer resp.Body.Close() //nolint:errcheck + + out, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("LLM returned %d: %s", resp.StatusCode, out) + } + + var cr chatResponse + if err := json.Unmarshal(out, &cr); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + if len(cr.Choices) == 0 { + return "", fmt.Errorf("LLM returned no choices") + } + return cr.Choices[0].Message.Content, nil +} diff --git a/internal/adapters/llm/client_test.go b/internal/adapters/llm/client_test.go new file mode 100644 index 0000000..742bc96 --- /dev/null +++ b/internal/adapters/llm/client_test.go @@ -0,0 +1,103 @@ +package llm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// mockServer returns an OpenAI-compatible endpoint that always replies with the +// given assistant content. +func mockServer(t *testing.T, response string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Errorf("path = %q, want /chat/completions", r.URL.Path) + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"role": "assistant", "content": response}}, + }, + }) + })) +} + +func TestClient_Complete(t *testing.T) { + srv := mockServer(t, "hello world") + defer srv.Close() + + c := New(srv.URL, "", "test-model", 10*time.Second) + got, err := c.Complete(context.Background(), "you are helpful", "say hello") + if err != nil { + t.Fatalf("Complete: %v", err) + } + if got != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestClient_ReturnsErrorOnNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "overloaded", http.StatusServiceUnavailable) + })) + defer srv.Close() + + c := New(srv.URL, "", "test-model", 10*time.Second) + if _, err := c.Complete(context.Background(), "sys", "user"); err == nil { + t.Fatal("want error on non-200, got nil") + } +} + +func TestClient_SendsAuthHeader(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{{"message": map[string]any{"content": "ok"}}}, + }) + })) + defer srv.Close() + + c := New(srv.URL, "my-key", "test-model", 10*time.Second) + if _, err := c.Complete(context.Background(), "sys", "user"); err != nil { + t.Fatalf("Complete: %v", err) + } + if gotAuth != "Bearer my-key" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer my-key") + } +} + +func TestClient_Retries429(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{{"message": map[string]any{"content": "retried"}}}, + }) + })) + defer srv.Close() + + c := New(srv.URL, "", "test-model", 10*time.Second) + got, err := c.Complete(context.Background(), "sys", "user") + if err != nil { + t.Fatalf("Complete: %v", err) + } + if got != "retried" { + t.Errorf("got %q, want %q", got, "retried") + } + if calls != 2 { + t.Errorf("calls = %d, want 2", calls) + } +} diff --git a/internal/adapters/llm/router.go b/internal/adapters/llm/router.go new file mode 100644 index 0000000..6d46d43 --- /dev/null +++ b/internal/adapters/llm/router.go @@ -0,0 +1,29 @@ +package llm + +import ( + "context" + "fmt" +) + +// Router calls Primary first; on any error falls back to Fallback. +// Fallback may be nil, in which case primary errors are returned directly. +type Router struct { + Primary *Client + Fallback *Client +} + +// Complete routes through Primary then Fallback. +func (r *Router) Complete(ctx context.Context, system, user string) (string, error) { + out, err := r.Primary.Complete(ctx, system, user) + if err == nil { + return out, nil + } + if r.Fallback == nil { + return "", fmt.Errorf("primary llm: %w", err) + } + out, err2 := r.Fallback.Complete(ctx, system, user) + if err2 != nil { + return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2) + } + return out, nil +} diff --git a/internal/adapters/llm/router_test.go b/internal/adapters/llm/router_test.go new file mode 100644 index 0000000..5ae31ac --- /dev/null +++ b/internal/adapters/llm/router_test.go @@ -0,0 +1,78 @@ +package llm + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestRouter_PrimarySucceeds(t *testing.T) { + primary := mockServer(t, "from-primary") + defer primary.Close() + fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("fallback must not be called when primary succeeds") + })) + defer fallback.Close() + + r := &Router{ + Primary: New(primary.URL, "", "m", time.Second), + Fallback: New(fallback.URL, "", "m", time.Second), + } + out, err := r.Complete(context.Background(), "sys", "user") + if err != nil { + t.Fatalf("Complete: %v", err) + } + if out != "from-primary" { + t.Errorf("got %q, want %q", out, "from-primary") + } +} + +func TestRouter_FallsBackOnPrimaryError(t *testing.T) { + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer primary.Close() + fallback := mockServer(t, "from-fallback") + defer fallback.Close() + + r := &Router{ + Primary: New(primary.URL, "", "m", time.Second), + Fallback: New(fallback.URL, "", "m", time.Second), + } + out, err := r.Complete(context.Background(), "sys", "user") + if err != nil { + t.Fatalf("Complete: %v", err) + } + if out != "from-fallback" { + t.Errorf("got %q, want %q", out, "from-fallback") + } +} + +func TestRouter_BothFail(t *testing.T) { + fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "err", http.StatusBadGateway) + })) + defer fail.Close() + + r := &Router{ + Primary: New(fail.URL, "", "m", time.Second), + Fallback: New(fail.URL, "", "m", time.Second), + } + if _, err := r.Complete(context.Background(), "sys", "user"); err == nil { + t.Fatal("want error when both fail, got nil") + } +} + +func TestRouter_NilFallback(t *testing.T) { + fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "err", http.StatusBadGateway) + })) + defer fail.Close() + + r := &Router{Primary: New(fail.URL, "", "m", time.Second)} + if _, err := r.Complete(context.Background(), "sys", "user"); err == nil { + t.Fatal("want error with nil fallback, got nil") + } +}