Detailed changes
@@ -99,13 +99,10 @@ func runServe(global GlobalConfig, args []string) {
server.DBPath = global.DBPath
// Build LLM configuration
- llmConfig := buildLLMConfig(logger, global.ConfigPath, global.TerminalURL, global.DefaultModel)
-
- // Create request history for debugging
- llmHistory := models.NewLLMRequestHistory(10)
+ llmConfig := buildLLMConfig(logger, global.ConfigPath, global.TerminalURL, global.DefaultModel, database)
// Initialize LLM service manager
- llmManager := server.NewLLMServiceManager(llmConfig, llmHistory)
+ llmManager := server.NewLLMServiceManager(llmConfig)
// Log available models
availableModels := llmManager.GetAvailableModels()
@@ -251,7 +248,7 @@ func setupToolSetConfig(llmProvider claudetool.LLMServiceProvider) claudetool.To
}
// buildLLMConfig constructs LLMConfig from environment variables and optional config file
-func buildLLMConfig(logger *slog.Logger, configPath, terminalURL, defaultModel string) *server.LLMConfig {
+func buildLLMConfig(logger *slog.Logger, configPath, terminalURL, defaultModel string, database *db.DB) *server.LLMConfig {
llmCfg := &server.LLMConfig{
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"),
@@ -259,6 +256,7 @@ func buildLLMConfig(logger *slog.Logger, configPath, terminalURL, defaultModel s
FireworksAPIKey: os.Getenv("FIREWORKS_API_KEY"),
TerminalURL: terminalURL,
DefaultModel: defaultModel,
+ DB: database,
Logger: logger,
}
@@ -689,3 +689,15 @@ func (a *SubagentDBAdapter) GetOrCreateSubagentConversation(ctx context.Context,
return "", "", fmt.Errorf("failed to create unique subagent slug after 100 attempts")
}
+
+// InsertLLMRequest inserts a new LLM request record
+func (db *DB) InsertLLMRequest(ctx context.Context, params generated.InsertLLMRequestParams) (*generated.LlmRequest, error) {
+ var request generated.LlmRequest
+ err := db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ q := generated.New(tx.Conn())
+ var err error
+ request, err = q.InsertLLMRequest(ctx, params)
+ return err
+ })
+ return &request, err
+}
@@ -0,0 +1,66 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: llm_requests.sql
+
+package generated
+
+import (
+ "context"
+)
+
+const insertLLMRequest = `-- name: InsertLLMRequest :one
+INSERT INTO llm_requests (
+ conversation_id,
+ model,
+ provider,
+ url,
+ request_body,
+ response_body,
+ status_code,
+ error,
+ duration_ms
+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+RETURNING id, conversation_id, model, provider, url, request_body, response_body, status_code, error, duration_ms, created_at
+`
+
+type InsertLLMRequestParams struct {
+ ConversationID *string `json:"conversation_id"`
+ Model string `json:"model"`
+ Provider string `json:"provider"`
+ Url string `json:"url"`
+ RequestBody *string `json:"request_body"`
+ ResponseBody *string `json:"response_body"`
+ StatusCode *int64 `json:"status_code"`
+ Error *string `json:"error"`
+ DurationMs *int64 `json:"duration_ms"`
+}
+
+func (q *Queries) InsertLLMRequest(ctx context.Context, arg InsertLLMRequestParams) (LlmRequest, error) {
+ row := q.db.QueryRowContext(ctx, insertLLMRequest,
+ arg.ConversationID,
+ arg.Model,
+ arg.Provider,
+ arg.Url,
+ arg.RequestBody,
+ arg.ResponseBody,
+ arg.StatusCode,
+ arg.Error,
+ arg.DurationMs,
+ )
+ var i LlmRequest
+ err := row.Scan(
+ &i.ID,
+ &i.ConversationID,
+ &i.Model,
+ &i.Provider,
+ &i.Url,
+ &i.RequestBody,
+ &i.ResponseBody,
+ &i.StatusCode,
+ &i.Error,
+ &i.DurationMs,
+ &i.CreatedAt,
+ )
+ return i, err
+}
@@ -19,6 +19,20 @@ type Conversation struct {
ParentConversationID *string `json:"parent_conversation_id"`
}
+type LlmRequest struct {
+ ID int64 `json:"id"`
+ ConversationID *string `json:"conversation_id"`
+ Model string `json:"model"`
+ Provider string `json:"provider"`
+ Url string `json:"url"`
+ RequestBody *string `json:"request_body"`
+ ResponseBody *string `json:"response_body"`
+ StatusCode *int64 `json:"status_code"`
+ Error *string `json:"error"`
+ DurationMs *int64 `json:"duration_ms"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
type Message struct {
MessageID string `json:"message_id"`
ConversationID string `json:"conversation_id"`
@@ -0,0 +1,13 @@
+-- name: InsertLLMRequest :one
+INSERT INTO llm_requests (
+ conversation_id,
+ model,
+ provider,
+ url,
+ request_body,
+ response_body,
+ status_code,
+ error,
+ duration_ms
+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+RETURNING *;
@@ -0,0 +1,25 @@
+-- LLM Requests table for tracking/debugging API calls
+-- Each row represents one HTTP request/response to an LLM provider
+
+CREATE TABLE llm_requests (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ conversation_id TEXT, -- optional, may be NULL for requests outside conversations
+ model TEXT NOT NULL, -- model ID used for the request
+ provider TEXT NOT NULL, -- e.g., "anthropic", "openai", "gemini"
+ url TEXT NOT NULL,
+ request_body TEXT, -- JSON request body
+ response_body TEXT, -- JSON response body
+ status_code INTEGER,
+ error TEXT, -- error message if any
+ duration_ms INTEGER, -- request duration in milliseconds
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Index on conversation_id for debugging specific conversations
+CREATE INDEX idx_llm_requests_conversation_id ON llm_requests(conversation_id);
+
+-- Index on created_at for time-based queries
+CREATE INDEX idx_llm_requests_created_at ON llm_requests(created_at DESC);
+
+-- Index on model for filtering by model
+CREATE INDEX idx_llm_requests_model ON llm_requests(model);
@@ -12,7 +12,6 @@ import (
"math/rand/v2"
"net/http"
"strings"
- "testing"
"time"
"shelley.exe.dev/llm"
@@ -80,19 +79,14 @@ func (s *Service) MaxImageDimension() int {
return 2000
}
-// HTTPRecorder is a callback for recording HTTP request/response data for debugging
-type HTTPRecorder func(url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration)
-
// Service provides Claude completions.
// Fields should not be altered concurrently with calling any method on Service.
type Service struct {
- HTTPC *http.Client // defaults to http.DefaultClient if nil
- URL string // defaults to DefaultURL if empty
- APIKey string // must be non-empty
- Model string // defaults to DefaultModel if empty
- MaxTokens int // defaults to DefaultMaxTokens if zero
- DumpLLM bool // whether to dump request/response text to files for debugging; defaults to false
- HTTPRecorder HTTPRecorder // optional callback for recording HTTP requests/responses
+ HTTPC *http.Client // defaults to http.DefaultClient if nil
+ URL string // defaults to DefaultURL if empty
+ APIKey string // must be non-empty
+ Model string // defaults to DefaultModel if empty
+ MaxTokens int // defaults to DefaultMaxTokens if zero
}
var _ llm.Service = (*Service)(nil)
@@ -462,37 +456,17 @@ func toLLMResponse(r *response) *llm.Response {
func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error) {
startTime := time.Now()
request := s.fromLLMRequest(ir)
- var payload []byte
- var err error
- if s.DumpLLM || testing.Testing() {
- payload, err = json.MarshalIndent(request, "", " ")
- } else {
- payload, err = json.Marshal(request)
- payload = append(payload, '\n')
- }
+ payload, err := json.Marshal(request)
if err != nil {
return nil, err
}
-
- if false {
- fmt.Printf("claude request payload:\n%s\n", payload)
- }
+ payload = append(payload, '\n')
backoff := []time.Duration{15 * time.Second, 30 * time.Second, time.Minute}
url := cmp.Or(s.URL, DefaultURL)
httpc := cmp.Or(s.HTTPC, http.DefaultClient)
- // For recording the last attempt's response
- var lastResponseBody []byte
- var lastStatusCode int
- var finalErr error
- defer func() {
- if s.HTTPRecorder != nil {
- s.HTTPRecorder(url, payload, lastResponseBody, lastStatusCode, finalErr, time.Since(startTime))
- }
- }()
-
// retry loop
var errs error // accumulated errors across all attempts
for attempts := 0; ; attempts++ {
@@ -504,11 +478,6 @@ func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error
slog.WarnContext(ctx, "anthropic request sleep before retry", "sleep", sleep, "attempts", attempts)
time.Sleep(sleep)
}
- if s.DumpLLM {
- if err := llm.DumpToFile("request", url, payload); err != nil {
- slog.WarnContext(ctx, "failed to dump request to file", "error", err)
- }
- }
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
if err != nil {
return nil, errors.Join(errs, err)
@@ -534,17 +503,8 @@ func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error
continue
}
- // Record response for HTTPRecorder callback
- lastResponseBody = buf
- lastStatusCode = resp.StatusCode
-
switch {
case resp.StatusCode == http.StatusOK:
- if s.DumpLLM {
- if err := llm.DumpToFile("response", "", buf); err != nil {
- slog.WarnContext(ctx, "failed to dump response to file", "error", err)
- }
- }
var response response
err = json.NewDecoder(bytes.NewReader(buf)).Decode(&response)
if err != nil {
@@ -562,13 +522,11 @@ func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error
// server error, retry
slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode, "url", url, "model", s.Model)
errs = errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
- finalErr = errs
continue
case resp.StatusCode == 429:
// rate limited, retry
slog.WarnContext(ctx, "anthropic_request_rate_limited", "response", string(buf), "url", url, "model", s.Model)
errs = errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
- finalErr = errs
continue
case resp.StatusCode >= 400 && resp.StatusCode < 500:
// some other 400, probably unrecoverable
@@ -578,7 +536,6 @@ func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error
// ...retry, I guess?
slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode, "url", url, "model", s.Model)
errs = errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
- finalErr = errs
continue
}
}
@@ -7,7 +7,6 @@ import (
"net/http"
"strings"
"testing"
- "time"
"shelley.exe.dev/llm"
)
@@ -1046,87 +1045,6 @@ func TestToLLMContentWithNestedToolResults(t *testing.T) {
}
}
-func TestDoWithHTTPRecorder(t *testing.T) {
- // Create a mock HTTP client that returns a predefined response
- mockResponse := `{
- "id": "msg_123",
- "type": "message",
- "role": "assistant",
- "model": "claude-sonnet-4-5-20250929",
- "content": [
- {
- "type": "text",
- "text": "Hello, world!"
- }
- ],
- "stop_reason": "end_turn",
- "usage": {
- "input_tokens": 100,
- "output_tokens": 50,
- "cost_usd": 0.01
- }
- }`
-
- // Variables to capture HTTPRecorder calls
- var recorded bool
- var recordedURL string
- var recordedStatusCode int
-
- // Create a service with a mock HTTP client and HTTPRecorder
- client := &http.Client{
- Transport: &mockHTTPTransport{responseBody: mockResponse, statusCode: 200},
- }
-
- s := &Service{
- APIKey: "test-key",
- HTTPC: client,
- HTTPRecorder: func(url string, payload, response []byte, statusCode int, err error, duration time.Duration) {
- recorded = true
- recordedURL = url
- recordedStatusCode = statusCode
- },
- }
-
- // Create a request
- req := &llm.Request{
- Messages: []llm.Message{
- {
- Role: llm.MessageRoleUser,
- Content: []llm.Content{
- {
- Type: llm.ContentTypeText,
- Text: "Hello, Claude!",
- },
- },
- },
- },
- }
-
- // Call Do
- resp, err := s.Do(context.Background(), req)
- if err != nil {
- t.Fatalf("Do() error = %v, want nil", err)
- }
-
- // Check the response
- if resp == nil {
- t.Fatalf("Do() response = nil, want not nil")
- }
-
- // Check that HTTPRecorder was called
- if !recorded {
- t.Error("HTTPRecorder was not called")
- }
-
- if recordedURL == "" {
- t.Error("HTTPRecorder did not record URL")
- }
-
- if recordedStatusCode != 200 {
- t.Errorf("HTTPRecorder recordedStatusCode = %v, want %v", recordedStatusCode, 200)
- }
-}
-
func TestDoClientError(t *testing.T) {
// Create a mock HTTP client that returns a client error
mockResponse := `{"error": "bad request"}`
@@ -1167,65 +1085,6 @@ func TestDoClientError(t *testing.T) {
}
}
-func TestDoWithDumpLLM(t *testing.T) {
- // Create a mock HTTP client that returns a predefined response
- mockResponse := `{
- "id": "msg_123",
- "type": "message",
- "role": "assistant",
- "model": "claude-sonnet-4-5-20250929",
- "content": [
- {
- "type": "text",
- "text": "Hello, world!"
- }
- ],
- "stop_reason": "end_turn",
- "usage": {
- "input_tokens": 100,
- "output_tokens": 50,
- "cost_usd": 0.01
- }
- }`
-
- // Create a service with a mock HTTP client and DumpLLM enabled
- client := &http.Client{
- Transport: &mockHTTPTransport{responseBody: mockResponse, statusCode: 200},
- }
-
- s := &Service{
- APIKey: "test-key",
- HTTPC: client,
- DumpLLM: true,
- }
-
- // Create a request
- req := &llm.Request{
- Messages: []llm.Message{
- {
- Role: llm.MessageRoleUser,
- Content: []llm.Content{
- {
- Type: llm.ContentTypeText,
- Text: "Hello, Claude!",
- },
- },
- },
- },
- }
-
- // Call Do
- resp, err := s.Do(context.Background(), req)
- if err != nil {
- t.Fatalf("Do() error = %v, want nil", err)
- }
-
- // Check the response
- if resp == nil {
- t.Fatalf("Do() response = nil, want not nil")
- }
-}
-
func TestServiceConfigDetails(t *testing.T) {
tests := []struct {
name string
@@ -25,6 +25,8 @@ func TestBasicConvo(t *testing.T) {
}
rr.ScrubReq(func(req *http.Request) error {
req.Header.Del("x-api-key")
+ req.Header.Del("User-Agent")
+ req.Header.Del("Shelley-Conversation-Id")
return nil
})
@@ -1,118 +1,62 @@
httprr trace v1
-455 1424
+379 1422
POST https://api.anthropic.com/v1/messages HTTP/1.1
Host: api.anthropic.com
User-Agent: Go-http-client/1.1
-Content-Length: 259
+Content-Length: 183
Anthropic-Version: 2023-06-01
Content-Type: application/json
-{
- "model": "claude-sonnet-4-20250514",
- "messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "Hi, my name is Cornelius",
- "cache_control": {
- "type": "ephemeral"
- }
- }
- ]
- }
- ],
- "max_tokens": 8192
-}HTTP/2.0 200 OK
+{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":[{"type":"text","text":"Hi, my name is Cornelius","cache_control":{"type":"ephemeral"}}]}],"max_tokens":8192}
+HTTP/2.0 200 OK
Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
-Anthropic-Ratelimit-Input-Tokens-Limit: 200000
-Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-24T19:27:38Z
-Anthropic-Ratelimit-Output-Tokens-Limit: 80000
-Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-24T19:27:38Z
-Anthropic-Ratelimit-Requests-Limit: 4000
-Anthropic-Ratelimit-Requests-Remaining: 3999
-Anthropic-Ratelimit-Requests-Reset: 2025-05-24T19:27:36Z
-Anthropic-Ratelimit-Tokens-Limit: 280000
-Anthropic-Ratelimit-Tokens-Remaining: 280000
-Anthropic-Ratelimit-Tokens-Reset: 2025-05-24T19:27:38Z
+Anthropic-Ratelimit-Input-Tokens-Limit: 4000000
+Anthropic-Ratelimit-Input-Tokens-Remaining: 4000000
+Anthropic-Ratelimit-Input-Tokens-Reset: 2026-01-20T05:02:18Z
+Anthropic-Ratelimit-Output-Tokens-Limit: 400000
+Anthropic-Ratelimit-Output-Tokens-Remaining: 400000
+Anthropic-Ratelimit-Output-Tokens-Reset: 2026-01-20T05:02:19Z
+Anthropic-Ratelimit-Tokens-Limit: 4400000
+Anthropic-Ratelimit-Tokens-Remaining: 4400000
+Anthropic-Ratelimit-Tokens-Reset: 2026-01-20T05:02:18Z
Cf-Cache-Status: DYNAMIC
-Cf-Ray: 944f30fd0f0a15d4-SJC
+Cf-Ray: 9c0c04d42abfefa4-PDX
Content-Type: application/json
-Date: Sat, 24 May 2025 19:27:38 GMT
-Request-Id: req_011CPSuX337qwfNzNzGSwG3b
+Date: Tue, 20 Jan 2026 05:02:19 GMT
+Request-Id: req_011CXJ3Uban5HaKm7cjTPc4V
Server: cloudflare
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
-Via: 1.1 google
+X-Envoy-Upstream-Service-Time: 1234
X-Robots-Tag: none
-{"id":"msg_01L127Hi3H8X613Fh8HojDgk","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Hello Cornelius! It's nice to meet you. How are you doing today? Is there anything I can help you with?"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":15,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":30,"service_tier":"standard"}}775 1394
+{"model":"claude-sonnet-4-20250514","id":"msg_01VwKDeEZjChwVGi6FdWjcWU","type":"message","role":"assistant","content":[{"type":"text","text":"Hello Cornelius! It's nice to meet you. That's a distinctive and classic name. How are you doing today? Is there anything I can help you with?"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":15,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":38,"service_tier":"standard"}}650 1341
POST https://api.anthropic.com/v1/messages HTTP/1.1
Host: api.anthropic.com
User-Agent: Go-http-client/1.1
-Content-Length: 579
+Content-Length: 454
Anthropic-Version: 2023-06-01
Content-Type: application/json
-{
- "model": "claude-sonnet-4-20250514",
- "messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "Hi, my name is Cornelius"
- }
- ]
- },
- {
- "role": "assistant",
- "content": [
- {
- "type": "text",
- "text": "Hello Cornelius! It's nice to meet you. How are you doing today? Is there anything I can help you with?"
- }
- ]
- },
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "What is my name?",
- "cache_control": {
- "type": "ephemeral"
- }
- }
- ]
- }
- ],
- "max_tokens": 8192
-}HTTP/2.0 200 OK
+{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":[{"type":"text","text":"Hi, my name is Cornelius"}]},{"role":"assistant","content":[{"type":"text","text":"Hello Cornelius! It's nice to meet you. That's a distinctive and classic name. How are you doing today? Is there anything I can help you with?"}]},{"role":"user","content":[{"type":"text","text":"What is my name?","cache_control":{"type":"ephemeral"}}]}],"max_tokens":8192}
+HTTP/2.0 200 OK
Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
-Anthropic-Ratelimit-Input-Tokens-Limit: 200000
-Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-24T19:27:39Z
-Anthropic-Ratelimit-Output-Tokens-Limit: 80000
-Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-24T19:27:40Z
-Anthropic-Ratelimit-Requests-Limit: 4000
-Anthropic-Ratelimit-Requests-Remaining: 3999
-Anthropic-Ratelimit-Requests-Reset: 2025-05-24T19:27:38Z
-Anthropic-Ratelimit-Tokens-Limit: 280000
-Anthropic-Ratelimit-Tokens-Remaining: 280000
-Anthropic-Ratelimit-Tokens-Reset: 2025-05-24T19:27:39Z
+Anthropic-Ratelimit-Input-Tokens-Limit: 4000000
+Anthropic-Ratelimit-Input-Tokens-Remaining: 4000000
+Anthropic-Ratelimit-Input-Tokens-Reset: 2026-01-20T05:02:21Z
+Anthropic-Ratelimit-Output-Tokens-Limit: 400000
+Anthropic-Ratelimit-Output-Tokens-Remaining: 400000
+Anthropic-Ratelimit-Output-Tokens-Reset: 2026-01-20T05:02:22Z
+Anthropic-Ratelimit-Tokens-Limit: 4400000
+Anthropic-Ratelimit-Tokens-Remaining: 4400000
+Anthropic-Ratelimit-Tokens-Reset: 2026-01-20T05:02:21Z
Cf-Cache-Status: DYNAMIC
-Cf-Ray: 944f31098c9e15d4-SJC
+Cf-Ray: 9c0c04dc7fd5efa4-PDX
Content-Type: application/json
-Date: Sat, 24 May 2025 19:27:40 GMT
-Request-Id: req_011CPSuXBim8ntiKJDjvFUWG
+Date: Tue, 20 Jan 2026 05:02:22 GMT
+Request-Id: req_011CXJ3UhEMLAi1P8AyXy9X3
Server: cloudflare
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
-Via: 1.1 google
+X-Envoy-Upstream-Service-Time: 2424
X-Robots-Tag: none
-{"id":"msg_01TiEuRrzLgJEfBUNhZ9Am3B","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Your name is Cornelius, as you introduced yourself in your first message."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":53,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":19,"service_tier":"standard"}}
+{"model":"claude-sonnet-4-20250514","id":"msg_01XdV1M6Kpkvcjc3yDhPCj2u","type":"message","role":"assistant","content":[{"type":"text","text":"Your name is Cornelius, as you told me in your first message."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":61,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":19,"service_tier":"standard"}}
@@ -23,11 +23,10 @@ const (
// Service provides Gemini completions.
// Fields should not be altered concurrently with calling any method on Service.
type Service struct {
- HTTPC *http.Client // defaults to http.DefaultClient if nil
- URL string // Gemini API URL, uses the gemini package default if empty
- APIKey string // must be non-empty
- Model string // defaults to DefaultModel if empty
- DumpLLM bool // whether to dump request/response text to files for debugging; defaults to false
+ HTTPC *http.Client // defaults to http.DefaultClient if nil
+ URL string // Gemini API URL, uses the gemini package default if empty
+ APIKey string // must be non-empty
+ Model string // defaults to DefaultModel if empty
}
var _ llm.Service = (*Service)(nil)
@@ -520,14 +519,6 @@ func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error
// Log the structured Gemini request for debugging
if reqJSON, err := json.MarshalIndent(gemReq, "", " "); err == nil {
slog.DebugContext(ctx, "gemini_request_json", "request", string(reqJSON))
- if s.DumpLLM {
- // Construct the same URL that the Gemini client will use
- endpoint := cmp.Or(s.URL, "https://generativelanguage.googleapis.com/v1beta")
- url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", endpoint, cmp.Or(s.Model, DefaultModel), s.APIKey)
- if err := llm.DumpToFile("request", url, reqJSON); err != nil {
- slog.WarnContext(ctx, "failed to dump gemini request to file", "error", err)
- }
- }
}
// Create a Gemini model instance
@@ -555,11 +546,6 @@ func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error
// Log the structured Gemini response
if resJSON, err := json.MarshalIndent(gemRes, "", " "); err == nil {
slog.DebugContext(ctx, "gemini_response_json", "response", string(resJSON))
- if s.DumpLLM {
- if err := llm.DumpToFile("response", "", resJSON); err != nil {
- slog.WarnContext(ctx, "failed to dump gemini response to file", "error", err)
- }
- }
}
break
}
@@ -0,0 +1,149 @@
+// Package llmhttp provides HTTP utilities for LLM requests including
+// custom headers and database recording.
+package llmhttp
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "time"
+
+ "shelley.exe.dev/version"
+)
+
+// contextKey is the type for context keys in this package.
+type contextKey int
+
+const (
+ conversationIDKey contextKey = iota
+ modelIDKey
+ providerKey
+)
+
+// WithConversationID returns a context with the conversation ID attached.
+func WithConversationID(ctx context.Context, conversationID string) context.Context {
+ return context.WithValue(ctx, conversationIDKey, conversationID)
+}
+
+// ConversationIDFromContext returns the conversation ID from the context, if any.
+func ConversationIDFromContext(ctx context.Context) string {
+ if v := ctx.Value(conversationIDKey); v != nil {
+ return v.(string)
+ }
+ return ""
+}
+
+// WithModelID returns a context with the model ID attached.
+func WithModelID(ctx context.Context, modelID string) context.Context {
+ return context.WithValue(ctx, modelIDKey, modelID)
+}
+
+// ModelIDFromContext returns the model ID from the context, if any.
+func ModelIDFromContext(ctx context.Context) string {
+ if v := ctx.Value(modelIDKey); v != nil {
+ return v.(string)
+ }
+ return ""
+}
+
+// WithProvider returns a context with the provider name attached.
+func WithProvider(ctx context.Context, provider string) context.Context {
+ return context.WithValue(ctx, providerKey, provider)
+}
+
+// ProviderFromContext returns the provider name from the context, if any.
+func ProviderFromContext(ctx context.Context) string {
+ if v := ctx.Value(providerKey); v != nil {
+ return v.(string)
+ }
+ return ""
+}
+
+// Recorder is called after each LLM HTTP request with the request/response details.
+type Recorder func(ctx context.Context, url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration)
+
+// Transport wraps an http.RoundTripper to add Shelley-specific headers
+// and optionally record requests to a database.
+type Transport struct {
+ Base http.RoundTripper
+ Recorder Recorder
+}
+
+// RoundTrip implements http.RoundTripper.
+func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
+ start := time.Now()
+
+ // Clone the request to avoid modifying the original
+ req = req.Clone(req.Context())
+
+ // Add User-Agent with Shelley version
+ info := version.GetInfo()
+ userAgent := "Shelley"
+ if info.Commit != "" {
+ userAgent += "/" + info.Commit[:min(8, len(info.Commit))]
+ }
+ req.Header.Set("User-Agent", userAgent)
+
+ // Add conversation ID header if present
+ if conversationID := ConversationIDFromContext(req.Context()); conversationID != "" {
+ req.Header.Set("Shelley-Conversation-Id", conversationID)
+ }
+
+ // Read and store the request body for recording
+ var requestBody []byte
+ if t.Recorder != nil && req.Body != nil {
+ var err error
+ requestBody, err = io.ReadAll(req.Body)
+ if err != nil {
+ return nil, err
+ }
+ req.Body = io.NopCloser(bytes.NewReader(requestBody))
+ }
+
+ // Perform the actual request
+ base := t.Base
+ if base == nil {
+ base = http.DefaultTransport
+ }
+
+ resp, err := base.RoundTrip(req)
+
+ // Record the request if we have a recorder
+ if t.Recorder != nil {
+ var responseBody []byte
+ var statusCode int
+
+ if resp != nil {
+ statusCode = resp.StatusCode
+ // Read and restore the response body
+ responseBody, _ = io.ReadAll(resp.Body)
+ resp.Body.Close()
+ resp.Body = io.NopCloser(bytes.NewReader(responseBody))
+ }
+
+ t.Recorder(req.Context(), req.URL.String(), requestBody, responseBody, statusCode, err, time.Since(start))
+ }
+
+ return resp, err
+}
+
+// NewClient creates an http.Client with Shelley headers and optional recording.
+func NewClient(base *http.Client, recorder Recorder) *http.Client {
+ if base == nil {
+ base = http.DefaultClient
+ }
+
+ transport := base.Transport
+ if transport == nil {
+ transport = http.DefaultTransport
+ }
+
+ return &http.Client{
+ Transport: &Transport{
+ Base: transport,
+ Recorder: recorder,
+ },
+ Timeout: base.Timeout,
+ }
+}
@@ -0,0 +1,175 @@
+package llmhttp
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestContextFunctions(t *testing.T) {
+ ctx := context.Background()
+
+ // Test ConversationID
+ ctx = WithConversationID(ctx, "conv-123")
+ if got := ConversationIDFromContext(ctx); got != "conv-123" {
+ t.Errorf("ConversationIDFromContext() = %q, want %q", got, "conv-123")
+ }
+
+ // Test ModelID
+ ctx = WithModelID(ctx, "model-456")
+ if got := ModelIDFromContext(ctx); got != "model-456" {
+ t.Errorf("ModelIDFromContext() = %q, want %q", got, "model-456")
+ }
+
+ // Test Provider
+ ctx = WithProvider(ctx, "anthropic")
+ if got := ProviderFromContext(ctx); got != "anthropic" {
+ t.Errorf("ProviderFromContext() = %q, want %q", got, "anthropic")
+ }
+
+ // Test empty context
+ emptyCtx := context.Background()
+ if got := ConversationIDFromContext(emptyCtx); got != "" {
+ t.Errorf("ConversationIDFromContext(empty) = %q, want empty", got)
+ }
+ if got := ModelIDFromContext(emptyCtx); got != "" {
+ t.Errorf("ModelIDFromContext(empty) = %q, want empty", got)
+ }
+ if got := ProviderFromContext(emptyCtx); got != "" {
+ t.Errorf("ProviderFromContext(empty) = %q, want empty", got)
+ }
+}
+
+func TestTransportAddsHeaders(t *testing.T) {
+ // Create a test server that echoes request headers
+ var receivedHeaders http.Header
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ receivedHeaders = r.Header.Clone()
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("ok"))
+ }))
+ defer server.Close()
+
+ // Create client with our transport
+ client := NewClient(nil, nil)
+
+ // Make a request with conversation ID in context
+ ctx := WithConversationID(context.Background(), "test-conv-id")
+ req, _ := http.NewRequestWithContext(ctx, "GET", server.URL, nil)
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ resp.Body.Close()
+
+ // Verify User-Agent header was added
+ if !strings.HasPrefix(receivedHeaders.Get("User-Agent"), "Shelley") {
+ t.Errorf("User-Agent = %q, want prefix 'Shelley'", receivedHeaders.Get("User-Agent"))
+ }
+
+ // Verify Shelley-Conversation-Id header was added
+ if got := receivedHeaders.Get("Shelley-Conversation-Id"); got != "test-conv-id" {
+ t.Errorf("Shelley-Conversation-Id = %q, want %q", got, "test-conv-id")
+ }
+}
+
+func TestTransportRecordsRequest(t *testing.T) {
+ // Create a test server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("response body: " + string(body)))
+ }))
+ defer server.Close()
+
+ // Track recorded values
+ var (
+ recordedURL string
+ recordedRequestBody []byte
+ recordedRespBody []byte
+ recordedStatusCode int
+ recordedDuration time.Duration
+ recorderCalled bool
+ )
+
+ recorder := func(ctx context.Context, url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration) {
+ recorderCalled = true
+ recordedURL = url
+ recordedRequestBody = requestBody
+ recordedRespBody = responseBody
+ recordedStatusCode = statusCode
+ recordedDuration = duration
+ }
+
+ // Create client with recorder
+ client := NewClient(nil, recorder)
+
+ // Make a request with body
+ req, _ := http.NewRequest("POST", server.URL, strings.NewReader("test body"))
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+
+ // Read response body to ensure it's still accessible
+ respBody, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+
+ if string(respBody) != "response body: test body" {
+ t.Errorf("Response body = %q, want %q", string(respBody), "response body: test body")
+ }
+
+ // Verify recorder was called with correct values
+ if !recorderCalled {
+ t.Fatal("Recorder was not called")
+ }
+
+ if recordedURL != server.URL {
+ t.Errorf("Recorded URL = %q, want %q", recordedURL, server.URL)
+ }
+
+ if string(recordedRequestBody) != "test body" {
+ t.Errorf("Recorded request body = %q, want %q", string(recordedRequestBody), "test body")
+ }
+
+ if string(recordedRespBody) != "response body: test body" {
+ t.Errorf("Recorded response body = %q, want %q", string(recordedRespBody), "response body: test body")
+ }
+
+ if recordedStatusCode != http.StatusOK {
+ t.Errorf("Recorded status code = %d, want %d", recordedStatusCode, http.StatusOK)
+ }
+
+ if recordedDuration <= 0 {
+ t.Error("Recorded duration should be positive")
+ }
+}
+
+func TestTransportWithoutRecorder(t *testing.T) {
+ // Create a test server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("ok"))
+ }))
+ defer server.Close()
+
+ // Create client without recorder
+ client := NewClient(nil, nil)
+
+ // Make a request
+ req, _ := http.NewRequest("GET", server.URL, nil)
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Status code = %d, want %d", resp.StatusCode, http.StatusOK)
+ }
+}
@@ -314,7 +314,6 @@ type Service struct {
ModelURL string // optional, overrides Model.URL
MaxTokens int // defaults to DefaultMaxTokens if zero
Org string // optional - organization ID
- DumpLLM bool // whether to dump request/response text to files for debugging; defaults to false
}
var _ llm.Service = (*Service)(nil)
@@ -826,15 +825,6 @@ func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error
// Construct the full URL for logging and debugging
fullURL := baseURL + "/chat/completions"
- // Dump request if enabled
- if s.DumpLLM {
- if reqJSON, err := json.MarshalIndent(req, "", " "); err == nil {
- if err := llm.DumpToFile("request", fullURL, reqJSON); err != nil {
- slog.WarnContext(ctx, "failed to dump openai request to file", "error", err)
- }
- }
- }
-
// Retry mechanism
backoff := []time.Duration{1 * time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 15 * time.Second}
@@ -854,14 +844,6 @@ func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error
// Handle successful response
if err == nil {
- // Dump response if enabled
- if s.DumpLLM {
- if respJSON, jsonErr := json.MarshalIndent(resp, "", " "); jsonErr == nil {
- if dumpErr := llm.DumpToFile("response", "", respJSON); dumpErr != nil {
- slog.WarnContext(ctx, "failed to dump openai response to file", "error", dumpErr)
- }
- }
- }
return s.toLLMResponse(&resp), nil
}
@@ -4,11 +4,15 @@ import (
"context"
"fmt"
"log/slog"
- "sync"
+ "net/http"
"time"
+ "shelley.exe.dev/db"
+ "shelley.exe.dev/db/generated"
"shelley.exe.dev/llm"
"shelley.exe.dev/llm/ant"
+ "shelley.exe.dev/llm/gem"
+ "shelley.exe.dev/llm/llmhttp"
"shelley.exe.dev/llm/oai"
"shelley.exe.dev/loop"
)
@@ -17,11 +21,11 @@ import (
type Provider string
const (
- ProviderOpenAI Provider = "OpenAI"
- ProviderAnthropic Provider = "Anthropic"
- ProviderFireworks Provider = "Fireworks"
- ProviderGemini Provider = "Gemini"
- ProviderBuiltIn Provider = "Built-in"
+ ProviderOpenAI Provider = "openai"
+ ProviderAnthropic Provider = "anthropic"
+ ProviderFireworks Provider = "fireworks"
+ ProviderGemini Provider = "gemini"
+ ProviderBuiltIn Provider = "builtin"
)
// Model represents a configured LLM model in Shelley
@@ -39,7 +43,7 @@ type Model struct {
RequiredEnvVars []string
// Factory creates an llm.Service instance for this model
- Factory func(config *Config) (llm.Service, error)
+ Factory func(config *Config, httpc *http.Client) (llm.Service, error)
}
// Config holds the configuration needed to create LLM services
@@ -55,6 +59,9 @@ type Config struct {
Gateway string
Logger *slog.Logger
+
+ // Database for recording LLM requests (optional)
+ DB *db.DB
}
// getAnthropicURL returns the Anthropic API URL, with gateway suffix if gateway is set
@@ -97,11 +104,11 @@ func All() []Model {
Provider: ProviderAnthropic,
Description: "Claude Opus 4.5 (default)",
RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
- Factory: func(config *Config) (llm.Service, error) {
+ Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
if config.AnthropicAPIKey == "" {
return nil, fmt.Errorf("claude-opus-4.5 requires ANTHROPIC_API_KEY")
}
- svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Opus}
+ svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Opus, HTTPC: httpc}
if url := config.getAnthropicURL(); url != "" {
svc.URL = url
}
@@ -113,11 +120,11 @@ func All() []Model {
Provider: ProviderFireworks,
Description: "Qwen3 Coder 480B on Fireworks",
RequiredEnvVars: []string{"FIREWORKS_API_KEY"},
- Factory: func(config *Config) (llm.Service, error) {
+ Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
if config.FireworksAPIKey == "" {
return nil, fmt.Errorf("qwen3-coder-fireworks requires FIREWORKS_API_KEY")
}
- svc := &oai.Service{Model: oai.Qwen3CoderFireworks, APIKey: config.FireworksAPIKey}
+ svc := &oai.Service{Model: oai.Qwen3CoderFireworks, APIKey: config.FireworksAPIKey, HTTPC: httpc}
if url := config.getFireworksURL(); url != "" {
svc.ModelURL = url
}
@@ -129,11 +136,11 @@ func All() []Model {
Provider: ProviderFireworks,
Description: "GLM-4P6 on Fireworks",
RequiredEnvVars: []string{"FIREWORKS_API_KEY"},
- Factory: func(config *Config) (llm.Service, error) {
+ Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
if config.FireworksAPIKey == "" {
return nil, fmt.Errorf("glm-4p6-fireworks requires FIREWORKS_API_KEY")
}
- svc := &oai.Service{Model: oai.GLM4P6Fireworks, APIKey: config.FireworksAPIKey}
+ svc := &oai.Service{Model: oai.GLM4P6Fireworks, APIKey: config.FireworksAPIKey, HTTPC: httpc}
if url := config.getFireworksURL(); url != "" {
svc.ModelURL = url
}
@@ -145,11 +152,11 @@ func All() []Model {
Provider: ProviderOpenAI,
Description: "GPT-5",
RequiredEnvVars: []string{"OPENAI_API_KEY"},
- Factory: func(config *Config) (llm.Service, error) {
+ Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
if config.OpenAIAPIKey == "" {
return nil, fmt.Errorf("gpt-5 requires OPENAI_API_KEY")
}
- svc := &oai.Service{Model: oai.GPT5, APIKey: config.OpenAIAPIKey}
+ svc := &oai.Service{Model: oai.GPT5, APIKey: config.OpenAIAPIKey, HTTPC: httpc}
if url := config.getOpenAIURL(); url != "" {
svc.ModelURL = url
}
@@ -161,11 +168,11 @@ func All() []Model {
Provider: ProviderOpenAI,
Description: "GPT-5 Nano",
RequiredEnvVars: []string{"OPENAI_API_KEY"},
- Factory: func(config *Config) (llm.Service, error) {
+ Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
if config.OpenAIAPIKey == "" {
return nil, fmt.Errorf("gpt-5-nano requires OPENAI_API_KEY")
}
- svc := &oai.Service{Model: oai.GPT5Nano, APIKey: config.OpenAIAPIKey}
+ svc := &oai.Service{Model: oai.GPT5Nano, APIKey: config.OpenAIAPIKey, HTTPC: httpc}
if url := config.getOpenAIURL(); url != "" {
svc.ModelURL = url
}
@@ -177,11 +184,11 @@ func All() []Model {
Provider: ProviderOpenAI,
Description: "GPT-5.1 Codex (uses Responses API)",
RequiredEnvVars: []string{"OPENAI_API_KEY"},
- Factory: func(config *Config) (llm.Service, error) {
+ Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
if config.OpenAIAPIKey == "" {
return nil, fmt.Errorf("gpt-5.1-codex requires OPENAI_API_KEY")
}
- svc := &oai.ResponsesService{Model: oai.GPT5Codex, APIKey: config.OpenAIAPIKey}
+ svc := &oai.ResponsesService{Model: oai.GPT5Codex, APIKey: config.OpenAIAPIKey, HTTPC: httpc}
if url := config.getOpenAIURL(); url != "" {
svc.ModelURL = url
}
@@ -193,11 +200,11 @@ func All() []Model {
Provider: ProviderAnthropic,
Description: "Claude Sonnet 4.5",
RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
- Factory: func(config *Config) (llm.Service, error) {
+ Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
if config.AnthropicAPIKey == "" {
return nil, fmt.Errorf("claude-sonnet-4.5 requires ANTHROPIC_API_KEY")
}
- svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Sonnet}
+ svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Sonnet, HTTPC: httpc}
if url := config.getAnthropicURL(); url != "" {
svc.URL = url
}
@@ -209,23 +216,39 @@ func All() []Model {
Provider: ProviderAnthropic,
Description: "Claude Haiku 4.5",
RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
- Factory: func(config *Config) (llm.Service, error) {
+ Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
if config.AnthropicAPIKey == "" {
return nil, fmt.Errorf("claude-haiku-4.5 requires ANTHROPIC_API_KEY")
}
- svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Haiku}
+ svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Haiku, HTTPC: httpc}
if url := config.getAnthropicURL(); url != "" {
svc.URL = url
}
return svc, nil
},
},
+ {
+ ID: "gemini-2.5-pro",
+ Provider: ProviderGemini,
+ Description: "Gemini 2.5 Pro",
+ RequiredEnvVars: []string{"GEMINI_API_KEY"},
+ Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
+ if config.GeminiAPIKey == "" {
+ return nil, fmt.Errorf("gemini-2.5-pro requires GEMINI_API_KEY")
+ }
+ svc := &gem.Service{APIKey: config.GeminiAPIKey, Model: gem.DefaultModel, HTTPC: httpc}
+ if url := config.getGeminiURL(); url != "" {
+ svc.URL = url
+ }
+ return svc, nil
+ },
+ },
{
ID: "predictable",
Provider: ProviderBuiltIn,
Description: "Deterministic test model (no API key)",
RequiredEnvVars: []string{},
- Factory: func(config *Config) (llm.Service, error) {
+ Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
return loop.NewPredictableService(), nil
},
},
@@ -259,58 +282,15 @@ func Default() Model {
// Manager manages LLM services for all configured models
type Manager struct {
- services map[string]llm.Service
+ services map[string]serviceEntry
logger *slog.Logger
- history *LLMRequestHistory
-}
-
-// LLMRequestRecord stores a request/response pair for debugging
-type LLMRequestRecord struct {
- Timestamp time.Time `json:"timestamp"`
- ModelID string `json:"model_id"`
- URL string `json:"url"`
- HTTPRequest []byte `json:"http_request,omitempty"`
- HTTPResponse []byte `json:"http_response,omitempty"`
- HTTPStatusCode int `json:"http_status_code,omitempty"`
- Error string `json:"error,omitempty"`
- Duration float64 `json:"duration_seconds"`
-}
-
-// LLMRequestHistory maintains a circular buffer of recent LLM requests
-type LLMRequestHistory struct {
- mu sync.RWMutex
- records []LLMRequestRecord
- maxSize int
-}
-
-// NewLLMRequestHistory creates a new request history with the given max size
-func NewLLMRequestHistory(maxSize int) *LLMRequestHistory {
- return &LLMRequestHistory{
- records: make([]LLMRequestRecord, 0, maxSize),
- maxSize: maxSize,
- }
+ db *db.DB
}
-// Add adds a new record to the history
-func (h *LLMRequestHistory) Add(record LLMRequestRecord) {
- h.mu.Lock()
- defer h.mu.Unlock()
-
- if len(h.records) >= h.maxSize {
- // Remove oldest record
- h.records = h.records[1:]
- }
- h.records = append(h.records, record)
-}
-
-// GetRecords returns a copy of all records
-func (h *LLMRequestHistory) GetRecords() []LLMRequestRecord {
- h.mu.RLock()
- defer h.mu.RUnlock()
-
- result := make([]LLMRequestRecord, len(h.records))
- copy(result, h.records)
- return result
+type serviceEntry struct {
+ service llm.Service
+ provider Provider
+ modelID string
}
// ConfigInfo is an optional interface that services can implement to provide configuration details for logging
@@ -321,25 +301,27 @@ type ConfigInfo interface {
// loggingService wraps an llm.Service to log request completion with usage information
type loggingService struct {
- service llm.Service
- logger *slog.Logger
- modelID string
- history *LLMRequestHistory
+ service llm.Service
+ logger *slog.Logger
+ modelID string
+ provider Provider
+ db *db.DB
}
-// Do wraps the underlying service's Do method with logging
+// Do wraps the underlying service's Do method with logging and database recording
func (l *loggingService) Do(ctx context.Context, request *llm.Request) (*llm.Response, error) {
start := time.Now()
+ // Add model ID and provider to context for the HTTP transport
+ ctx = llmhttp.WithModelID(ctx, l.modelID)
+ ctx = llmhttp.WithProvider(ctx, string(l.provider))
+
// Call the underlying service
response, err := l.service.Do(ctx, request)
duration := time.Since(start)
durationSeconds := duration.Seconds()
- // History recording now happens in the provider (e.g., ant.Service)
- // to capture raw HTTP requests/responses
-
// Log the completion with usage information
if err != nil {
logAttrs := []any{
@@ -403,20 +385,86 @@ func (l *loggingService) UseSimplifiedPatch() bool {
}
// NewManager creates a new Manager with all models configured
-func NewManager(cfg *Config, history *LLMRequestHistory) (*Manager, error) {
+func NewManager(cfg *Config) (*Manager, error) {
manager := &Manager{
- services: make(map[string]llm.Service),
+ services: make(map[string]serviceEntry),
logger: cfg.Logger,
- history: history,
+ db: cfg.DB,
+ }
+
+ // Create HTTP client with recording if database is available
+ var httpc *http.Client
+ if cfg.DB != nil {
+ recorder := func(ctx context.Context, url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration) {
+ modelID := llmhttp.ModelIDFromContext(ctx)
+ provider := llmhttp.ProviderFromContext(ctx)
+ conversationID := llmhttp.ConversationIDFromContext(ctx)
+
+ var convIDPtr *string
+ if conversationID != "" {
+ convIDPtr = &conversationID
+ }
+
+ var reqBodyPtr, respBodyPtr *string
+ if len(requestBody) > 0 {
+ s := string(requestBody)
+ reqBodyPtr = &s
+ }
+ if len(responseBody) > 0 {
+ s := string(responseBody)
+ respBodyPtr = &s
+ }
+
+ var statusCodePtr *int64
+ if statusCode != 0 {
+ sc := int64(statusCode)
+ statusCodePtr = &sc
+ }
+
+ var errPtr *string
+ if err != nil {
+ s := err.Error()
+ errPtr = &s
+ }
+
+ durationMs := duration.Milliseconds()
+ durationMsPtr := &durationMs
+
+ // Insert into database (fire and forget, don't block the request)
+ go func() {
+ _, insertErr := cfg.DB.InsertLLMRequest(context.Background(), generated.InsertLLMRequestParams{
+ ConversationID: convIDPtr,
+ Model: modelID,
+ Provider: provider,
+ Url: url,
+ RequestBody: reqBodyPtr,
+ ResponseBody: respBodyPtr,
+ StatusCode: statusCodePtr,
+ Error: errPtr,
+ DurationMs: durationMsPtr,
+ })
+ if insertErr != nil && cfg.Logger != nil {
+ cfg.Logger.Warn("Failed to record LLM request", "error", insertErr)
+ }
+ }()
+ }
+ httpc = llmhttp.NewClient(nil, recorder)
+ } else {
+ // Still use the custom transport for headers, just without recording
+ httpc = llmhttp.NewClient(nil, nil)
}
for _, model := range All() {
- svc, err := model.Factory(cfg)
+ svc, err := model.Factory(cfg, httpc)
if err != nil {
// Model not available (e.g., missing API key) - skip it
continue
}
- manager.services[model.ID] = svc
+ manager.services[model.ID] = serviceEntry{
+ service: svc,
+ provider: model.Provider,
+ modelID: model.ID,
+ }
}
return manager, nil
@@ -424,44 +472,22 @@ func NewManager(cfg *Config, history *LLMRequestHistory) (*Manager, error) {
// GetService returns the LLM service for the given model ID, wrapped with logging
func (m *Manager) GetService(modelID string) (llm.Service, error) {
- if svc, ok := m.services[modelID]; ok {
- // Set HTTP recorder on ant.Service if we have history
- if antSvc, ok := svc.(*ant.Service); ok && m.history != nil {
- antSvc.HTTPRecorder = func(url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration) {
- record := LLMRequestRecord{
- Timestamp: time.Now().Add(-duration),
- ModelID: modelID,
- URL: url,
- HTTPRequest: requestBody,
- HTTPResponse: responseBody,
- HTTPStatusCode: statusCode,
- Duration: duration.Seconds(),
- }
- if err != nil {
- record.Error = err.Error()
- }
- m.history.Add(record)
- }
- }
+ if entry, ok := m.services[modelID]; ok {
// Wrap with logging if we have a logger
if m.logger != nil {
return &loggingService{
- service: svc,
- logger: m.logger,
- modelID: modelID,
- history: m.history,
+ service: entry.service,
+ logger: m.logger,
+ modelID: entry.modelID,
+ provider: entry.provider,
+ db: m.db,
}, nil
}
- return svc, nil
+ return entry.service, nil
}
return nil, fmt.Errorf("unsupported model: %s", modelID)
}
-// GetHistory returns the LLM request history
-func (m *Manager) GetHistory() *LLMRequestHistory {
- return m.history
-}
-
// GetAvailableModels returns a list of available model IDs in the same order as All()
func (m *Manager) GetAvailableModels() []string {
// Return IDs in the same order as All() for consistency
@@ -3,8 +3,8 @@ package models
import (
"context"
"log/slog"
+ "net/http"
"testing"
- "time"
"shelley.exe.dev/llm"
)
@@ -95,7 +95,7 @@ func TestFactory(t *testing.T) {
t.Fatal("predictable model not found")
}
- svc, err := m.Factory(cfg)
+ svc, err := m.Factory(cfg, nil)
if err != nil {
t.Fatalf("predictable Factory() failed: %v", err)
}
@@ -109,7 +109,7 @@ func TestManagerGetAvailableModelsOrder(t *testing.T) {
cfg := &Config{}
// Create manager - should only have predictable model since no API keys
- manager, err := NewManager(cfg, nil)
+ manager, err := NewManager(cfg)
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
@@ -148,7 +148,7 @@ func TestManagerGetAvailableModelsMatchesAllOrder(t *testing.T) {
FireworksAPIKey: "test-key",
}
- manager, err := NewManager(cfg, nil)
+ manager, err := NewManager(cfg)
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
@@ -176,80 +176,16 @@ func TestManagerGetAvailableModelsMatchesAllOrder(t *testing.T) {
}
}
-func TestLLMRequestHistory(t *testing.T) {
- // Test NewLLMRequestHistory
- history := NewLLMRequestHistory(3)
- if history == nil {
- t.Fatal("NewLLMRequestHistory returned nil")
- }
-
- // Test Add and GetRecords
- record1 := LLMRequestRecord{
- Timestamp: time.Now(),
- ModelID: "test-model-1",
- URL: "http://test.com/1",
- }
-
- record2 := LLMRequestRecord{
- Timestamp: time.Now(),
- ModelID: "test-model-2",
- URL: "http://test.com/2",
- }
-
- history.Add(record1)
- history.Add(record2)
-
- records := history.GetRecords()
- if len(records) != 2 {
- t.Errorf("Expected 2 records, got %d", len(records))
- }
-
- if records[0].ModelID != "test-model-1" {
- t.Errorf("Expected first record model ID 'test-model-1', got %s", records[0].ModelID)
- }
-
- if records[1].ModelID != "test-model-2" {
- t.Errorf("Expected second record model ID 'test-model-2', got %s", records[1].ModelID)
- }
-
- // Test circular buffer behavior
- record3 := LLMRequestRecord{
- Timestamp: time.Now(),
- ModelID: "test-model-3",
- URL: "http://test.com/3",
- }
-
- record4 := LLMRequestRecord{
- Timestamp: time.Now(),
- ModelID: "test-model-4",
- URL: "http://test.com/4",
- }
-
- history.Add(record3)
- history.Add(record4) // This should remove record1
-
- records = history.GetRecords()
- if len(records) != 3 {
- t.Errorf("Expected 3 records (circular buffer), got %d", len(records))
- }
-
- // First record should now be record2 (record1 was removed)
- if records[0].ModelID != "test-model-2" {
- t.Errorf("Expected first record model ID 'test-model-2', got %s", records[0].ModelID)
- }
-}
-
-func TestHistoryRecordingService(t *testing.T) {
+func TestLoggingService(t *testing.T) {
// Create a mock service for testing
mockService := &mockLLMService{}
- history := NewLLMRequestHistory(10)
logger := slog.Default()
loggingSvc := &loggingService{
- service: mockService,
- logger: logger,
- modelID: "test-model",
- history: history,
+ service: mockService,
+ logger: logger,
+ modelID: "test-model",
+ provider: ProviderBuiltIn,
}
// Test Do method
@@ -327,9 +263,8 @@ func (m *mockLLMService) UseSimplifiedPatch() bool {
func TestManagerGetService(t *testing.T) {
// Test with predictable model (no API keys needed)
cfg := &Config{}
- history := NewLLMRequestHistory(10)
- manager, err := NewManager(cfg, history)
+ manager, err := NewManager(cfg)
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
@@ -350,25 +285,10 @@ func TestManagerGetService(t *testing.T) {
}
}
-func TestManagerGetHistory(t *testing.T) {
- cfg := &Config{}
- history := NewLLMRequestHistory(5)
-
- manager, err := NewManager(cfg, history)
- if err != nil {
- t.Fatalf("NewManager failed: %v", err)
- }
-
- retrievedHistory := manager.GetHistory()
- if retrievedHistory != history {
- t.Error("GetHistory did not return the expected history instance")
- }
-}
-
func TestManagerHasModel(t *testing.T) {
cfg := &Config{}
- manager, err := NewManager(cfg, nil)
+ manager, err := NewManager(cfg)
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
@@ -420,14 +340,13 @@ func TestConfigGetURLMethods(t *testing.T) {
func TestUseSimplifiedPatch(t *testing.T) {
// Test with a service that doesn't implement SimplifiedPatcher
mockService := &mockLLMService{}
- history := NewLLMRequestHistory(10)
logger := slog.Default()
loggingSvc := &loggingService{
- service: mockService,
- logger: logger,
- modelID: "test-model",
- history: history,
+ service: mockService,
+ logger: logger,
+ modelID: "test-model",
+ provider: ProviderBuiltIn,
}
// Should return false since mockService doesn't implement SimplifiedPatcher
@@ -439,10 +358,10 @@ func TestUseSimplifiedPatch(t *testing.T) {
// Test with a service that implements SimplifiedPatcher
mockSimplifiedService := &mockSimplifiedLLMService{useSimplified: true}
loggingSvc2 := &loggingService{
- service: mockSimplifiedService,
- logger: logger,
- modelID: "test-model-2",
- history: history,
+ service: mockSimplifiedService,
+ logger: logger,
+ modelID: "test-model-2",
+ provider: ProviderBuiltIn,
}
// Should return true since mockSimplifiedService implements SimplifiedPatcher and returns true
@@ -461,3 +380,27 @@ type mockSimplifiedLLMService struct {
func (m *mockSimplifiedLLMService) UseSimplifiedPatch() bool {
return m.useSimplified
}
+
+func TestHTTPClientPassedToFactory(t *testing.T) {
+ // Test that HTTP client is passed to factory and used by services
+ cfg := &Config{
+ AnthropicAPIKey: "test-key",
+ }
+
+ // Create a custom HTTP client
+ customClient := &http.Client{}
+
+ // Test that claude factory accepts HTTP client
+ m := ByID("claude-opus-4.5")
+ if m == nil {
+ t.Fatal("claude-opus-4.5 model not found")
+ }
+
+ svc, err := m.Factory(cfg, customClient)
+ if err != nil {
+ t.Fatalf("Factory with custom HTTP client failed: %v", err)
+ }
+ if svc == nil {
+ t.Fatal("Factory returned nil service")
+ }
+}
@@ -1,8 +1,10 @@
package server
import (
+ "bytes"
"context"
"encoding/json"
+ "io"
"log/slog"
"net/http"
"net/http/httptest"
@@ -53,10 +55,18 @@ func NewClaudeTestHarness(t *testing.T) *ClaudeTestHarness {
requestTokens: make([]uint64, 0),
}
+ // Create HTTP client with custom transport for token tracking
+ httpc := &http.Client{
+ Transport: &tokenTrackingTransport{
+ base: http.DefaultTransport,
+ recordToken: h.recordHTTPResponse,
+ },
+ }
+
service := &ant.Service{
- APIKey: apiKey,
- Model: ant.Claude45Haiku, // Use cheaper model for testing
- HTTPRecorder: h.recordHTTPRequest,
+ APIKey: apiKey,
+ Model: ant.Claude45Haiku, // Use cheaper model for testing
+ HTTPC: httpc,
}
h.llmService = service
@@ -75,9 +85,30 @@ func NewClaudeTestHarness(t *testing.T) *ClaudeTestHarness {
return h
}
-// recordHTTPRequest is a callback to record HTTP requests for token tracking
-func (h *ClaudeTestHarness) recordHTTPRequest(url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration) {
- h.t.Logf("HTTP callback: status=%d, err=%v, responseLen=%d", statusCode, err, len(responseBody))
+// tokenTrackingTransport wraps an HTTP transport to track token usage from responses
+type tokenTrackingTransport struct {
+ base http.RoundTripper
+ recordToken func(responseBody []byte, statusCode int)
+}
+
+func (t *tokenTrackingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ resp, err := t.base.RoundTrip(req)
+ if err != nil {
+ return resp, err
+ }
+
+ // Read and restore the response body
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ resp.Body = io.NopCloser(bytes.NewReader(body))
+
+ t.recordToken(body, resp.StatusCode)
+ return resp, nil
+}
+
+// recordHTTPResponse is a callback to record HTTP responses for token tracking
+func (h *ClaudeTestHarness) recordHTTPResponse(responseBody []byte, statusCode int) {
+ h.t.Logf("HTTP callback: status=%d, responseLen=%d", statusCode, len(responseBody))
if statusCode != http.StatusOK || responseBody == nil {
return
@@ -902,153 +902,6 @@ func (s *Server) handleStreamConversation(w http.ResponseWriter, r *http.Request
}
}
-// handleDebugLLM serves recent LLM requests and responses for debugging
-func (s *Server) handleDebugLLM(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- // Check if requesting a specific record JSON
- if idx := r.URL.Query().Get("index"); idx != "" {
- var i int
- if _, err := fmt.Sscanf(idx, "%d", &i); err != nil {
- http.Error(w, "Invalid index", http.StatusBadRequest)
- return
- }
-
- type historyProvider interface {
- GetHistory() *models.LLMRequestHistory
- }
-
- var records []models.LLMRequestRecord
- if hp, ok := s.llmManager.(historyProvider); ok && hp.GetHistory() != nil {
- records = hp.GetHistory().GetRecords()
- }
-
- if i < 0 || i >= len(records) {
- http.Error(w, "Index out of range", http.StatusNotFound)
- return
- }
-
- record := records[i]
- recordType := r.URL.Query().Get("type")
-
- switch recordType {
- case "request":
- w.Header().Set("Content-Type", "application/json")
- w.Write(record.HTTPRequest)
- case "response":
- w.Header().Set("Content-Type", "application/json")
- w.Write(record.HTTPResponse)
- default:
- // Return the full record
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(record)
- }
- return
- }
-
- // Get history from the LLM manager if it's a models.Manager
- type historyProvider interface {
- GetHistory() *models.LLMRequestHistory
- }
-
- var records []models.LLMRequestRecord
- if hp, ok := s.llmManager.(historyProvider); ok && hp.GetHistory() != nil {
- records = hp.GetHistory().GetRecords()
- }
-
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.WriteHeader(http.StatusOK)
-
- // Write simple HTML with links to JSON
- fmt.Fprint(w, `<!DOCTYPE html>
-<html>
-<head>
-<meta charset="utf-8">
-<title>LLM Debug - Recent Requests</title>
-<style>
-body {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- margin: 20px;
- background: #ffffff;
- color: #000000;
-}
-h1 {
- margin-bottom: 20px;
-}
-table {
- border-collapse: collapse;
- width: 100%;
-}
-th, td {
- padding: 8px 12px;
- text-align: left;
- border-bottom: 1px solid #ddd;
-}
-th {
- background: #f5f5f5;
- font-weight: 600;
-}
-tr:hover {
- background: #f9f9f9;
-}
-.error {
- color: #d32f2f;
-}
-.success {
- color: #388e3c;
-}
-a {
- color: #1976d2;
- text-decoration: none;
-}
-a:hover {
- text-decoration: underline;
-}
-</style>
-</head>
-<body>
-<h1>LLM Debug - Recent Requests</h1>
-`)
-
- if len(records) == 0 {
- fmt.Fprint(w, "<p>No requests recorded yet.</p>")
- } else {
- fmt.Fprint(w, "<table>")
- fmt.Fprint(w, "<tr><th>#</th><th>Time</th><th>Model</th><th>URL</th><th>Status</th><th>Duration</th><th>Request</th><th>Response</th></tr>")
- for i := len(records) - 1; i >= 0; i-- {
- record := records[i]
- num := len(records) - i
- statusClass := "success"
- statusText := fmt.Sprintf("%d", record.HTTPStatusCode)
- if record.Error != "" {
- statusClass = "error"
- statusText = record.Error
- } else if record.HTTPStatusCode >= 400 {
- statusClass = "error"
- }
- fmt.Fprintf(w, "<tr>")
- fmt.Fprintf(w, "<td>%d</td>", num)
- fmt.Fprintf(w, "<td>%s</td>", record.Timestamp.Format("15:04:05"))
- fmt.Fprintf(w, "<td>%s</td>", record.ModelID)
- fmt.Fprintf(w, "<td>%s</td>", record.URL)
- fmt.Fprintf(w, "<td class=\"%s\">%s</td>", statusClass, statusText)
- fmt.Fprintf(w, "<td>%.2fs</td>", record.Duration)
- fmt.Fprintf(w, "<td><a href=\"/debug/llm?index=%d&type=request\" target=\"_blank\">json</a></td>", i)
- fmt.Fprintf(w, "<td><a href=\"/debug/llm?index=%d&type=response\" target=\"_blank\">json</a></td>", i)
- fmt.Fprintf(w, "</tr>")
- }
- fmt.Fprint(w, "</table>")
- }
-
- fmt.Fprint(w, `
-</body>
-</html>
-`)
-}
-
// handleVersion returns version information as JSON
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -1,6 +1,10 @@
package server
-import "log/slog"
+import (
+ "log/slog"
+
+ "shelley.exe.dev/db"
+)
// Link represents a custom link to be displayed in the UI
type Link struct {
@@ -29,5 +33,8 @@ type LLMConfig struct {
// Links are custom links to be displayed in the UI (optional)
Links []Link
+ // DB is the database for recording LLM requests (optional)
+ DB *db.DB
+
Logger *slog.Logger
}
@@ -72,7 +72,7 @@ type LLMProvider interface {
}
// NewLLMServiceManager creates a new LLM service manager from config
-func NewLLMServiceManager(cfg *LLMConfig, history *models.LLMRequestHistory) LLMProvider {
+func NewLLMServiceManager(cfg *LLMConfig) LLMProvider {
// Convert LLMConfig to models.Config
modelConfig := &models.Config{
AnthropicAPIKey: cfg.AnthropicAPIKey,
@@ -81,9 +81,10 @@ func NewLLMServiceManager(cfg *LLMConfig, history *models.LLMRequestHistory) LLM
FireworksAPIKey: cfg.FireworksAPIKey,
Gateway: cfg.Gateway,
Logger: cfg.Logger,
+ DB: cfg.DB,
}
- manager, err := models.NewManager(modelConfig, history)
+ manager, err := models.NewManager(modelConfig)
if err != nil {
// This shouldn't happen in practice, but handle it gracefully
cfg.Logger.Error("Failed to create models manager", "error", err)
@@ -262,7 +263,6 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.Handle("/version", http.HandlerFunc(s.handleVersion)) // Small response
// Debug routes
- mux.Handle("/debug/llm", gzipHandler(http.HandlerFunc(s.handleDebugLLM)))
// Serve embedded UI assets
mux.Handle("/", s.staticHandler(ui.Assets()))
@@ -50,7 +50,7 @@ func TestWithAnthropicAPI(t *testing.T) {
FireworksAPIKey: os.Getenv("FIREWORKS_API_KEY"),
Logger: logger,
}
- llmManager := server.NewLLMServiceManager(llmConfig, nil)
+ llmManager := server.NewLLMServiceManager(llmConfig)
// Set up tools config
toolSetConfig := claudetool.ToolSetConfig{
@@ -47,7 +47,7 @@ func TestServerEndToEnd(t *testing.T) {
}))
// Create LLM service manager with predictable service
- llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger})
predictableService := loop.NewPredictableService()
// For testing, we'll override the manager's service selection
_ = predictableService // will need to mock this properly
@@ -384,7 +384,7 @@ func TestConversationCleanup(t *testing.T) {
// Create server with predictable service
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
- llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger})
svr := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
// Create a conversation
@@ -420,7 +420,7 @@ func TestSlugGeneration(t *testing.T) {
// Create server
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
- llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger})
_ = server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
// Test slug generation directly to avoid timing issues
@@ -501,7 +501,7 @@ func TestSanitizeSlug(t *testing.T) {
func TestSlugGenerationWithPredictableService(t *testing.T) {
// Create server with predictable service only
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
- llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger})
// Create a temporary database
tempDB := t.TempDir() + "/test.db"
@@ -608,7 +608,7 @@ func TestSSEIncrementalUpdates(t *testing.T) {
// Create logger and LLM manager
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
- llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger})
// Create server
serviceInstance := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
@@ -921,7 +921,7 @@ func TestVersionEndpoint(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
- llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger})
svr := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "", "", nil)
mux := http.NewServeMux()
@@ -972,7 +972,7 @@ func TestScreenshotRouteServesImage(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
- llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger})
svr := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "", "", nil)
mux := http.NewServeMux()
@@ -1153,7 +1153,7 @@ func TestSubagentEndToEnd(t *testing.T) {
}))
// Create LLM service manager with predictable service
- llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger})
// Set up tools config
toolSetConfig := claudetool.ToolSetConfig{