diff --git a/cmd/shelley/main.go b/cmd/shelley/main.go index e2699ecf10f162be3101fd3279a88ced48679fbf..f0e450a4a344c352d442b30cbdfb7a0cf2a04baf 100644 --- a/cmd/shelley/main.go +++ b/cmd/shelley/main.go @@ -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, } diff --git a/db/db.go b/db/db.go index 7f4b4ad55c48d59e886ea34ece4d7afd265bfca4..0e4c653c6432ac37dad2dde866574577b2d8fdf2 100644 --- a/db/db.go +++ b/db/db.go @@ -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 +} diff --git a/db/generated/llm_requests.sql.go b/db/generated/llm_requests.sql.go new file mode 100644 index 0000000000000000000000000000000000000000..0b6b88f0004e42d856be98e770eaeb099c051530 --- /dev/null +++ b/db/generated/llm_requests.sql.go @@ -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 +} diff --git a/db/generated/models.go b/db/generated/models.go index 62fa24d19fbf1c35f86574a688b14469690262b6..8b43efda0cb45058a5955f4e1d238c7b1a42a99f 100644 --- a/db/generated/models.go +++ b/db/generated/models.go @@ -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"` diff --git a/db/query/llm_requests.sql b/db/query/llm_requests.sql new file mode 100644 index 0000000000000000000000000000000000000000..90aeab858de6a87378673a9d786370304ef2f295 --- /dev/null +++ b/db/query/llm_requests.sql @@ -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 *; diff --git a/db/schema/010-add-llm-requests.sql b/db/schema/010-add-llm-requests.sql new file mode 100644 index 0000000000000000000000000000000000000000..6023b9c396c663f9a72cc04223e3d9437e448ac6 --- /dev/null +++ b/db/schema/010-add-llm-requests.sql @@ -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); diff --git a/llm/ant/ant.go b/llm/ant/ant.go index 7d0a2119c840d74e0e5c3449172c4df2aebe321a..3b2c2579bfe38fc7107e809dcf19dc53a8ad6d8c 100644 --- a/llm/ant/ant.go +++ b/llm/ant/ant.go @@ -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 } } diff --git a/llm/ant/ant_test.go b/llm/ant/ant_test.go index ad516b1db13205867288bef8b702b2d067aa22be..876813b19670da8b8c7cbed744be912904ed0538 100644 --- a/llm/ant/ant_test.go +++ b/llm/ant/ant_test.go @@ -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 diff --git a/llm/conversation/convo_test.go b/llm/conversation/convo_test.go index 30db46990a55fc40f2a088bf118b41dbba7ee0d8..520b7c1b086eb8138e42511c8c549a7f9df8531e 100644 --- a/llm/conversation/convo_test.go +++ b/llm/conversation/convo_test.go @@ -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 }) diff --git a/llm/conversation/testdata/basic_convo.httprr b/llm/conversation/testdata/basic_convo.httprr index 7f9886d59fb1f4a199fb1bc3d0b53899e8e695e6..4a3d3aa868791ff94e8cec8402de4e4208329e67 100644 --- a/llm/conversation/testdata/basic_convo.httprr +++ b/llm/conversation/testdata/basic_convo.httprr @@ -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"}} \ No newline at end of file +{"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"}} \ No newline at end of file diff --git a/llm/gem/gem.go b/llm/gem/gem.go index 7b41614e2303dc70ead57d883677972cb9119a60..91469882adad070f77690e69ddc7425727dedd27 100644 --- a/llm/gem/gem.go +++ b/llm/gem/gem.go @@ -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 } diff --git a/llm/llmhttp/llmhttp.go b/llm/llmhttp/llmhttp.go new file mode 100644 index 0000000000000000000000000000000000000000..26d8fcf6c2f2cd2800403d80af3f2bd8b144d82b --- /dev/null +++ b/llm/llmhttp/llmhttp.go @@ -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, + } +} diff --git a/llm/llmhttp/llmhttp_test.go b/llm/llmhttp/llmhttp_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ccad6d3cba92f8d7e29af8b60aa3726a13f0b59d --- /dev/null +++ b/llm/llmhttp/llmhttp_test.go @@ -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) + } +} diff --git a/llm/oai/oai.go b/llm/oai/oai.go index 2cf2f0d87983b5997ecd32d72002c32b62b42204..fa0504d2f16b7e6a18ac2426cf1769ebcb75c442 100644 --- a/llm/oai/oai.go +++ b/llm/oai/oai.go @@ -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 } diff --git a/models/models.go b/models/models.go index ecfe214583acacbf609fc0a3f81df10a90a3ecf3..8c8a1263b52d4eba9ed42a331e763167e9494ae1 100644 --- a/models/models.go +++ b/models/models.go @@ -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 diff --git a/models/models_test.go b/models/models_test.go index d82f0703c571aa27c93a61b642aad5e70d8dc1bc..fd0c792c69107ecd6536fc6edabcd3867fe3664a 100644 --- a/models/models_test.go +++ b/models/models_test.go @@ -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") + } +} diff --git a/server/cancel_claude_test.go b/server/cancel_claude_test.go index 3f5499f754b48190d285ec757dfc623288583faf..c6d9344388c0fdd0679cc16f5ef1115508e68e7c 100644 --- a/server/cancel_claude_test.go +++ b/server/cancel_claude_test.go @@ -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 diff --git a/server/handlers.go b/server/handlers.go index 3e4b3b164db2935dc1a8642824bfda3bbc72d787..b07c7436bc2697ab4f0980c1de8059c5f66b4c26 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -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, ` - - - -LLM Debug - Recent Requests - - - -

LLM Debug - Recent Requests

-`) - - if len(records) == 0 { - fmt.Fprint(w, "

No requests recorded yet.

") - } else { - fmt.Fprint(w, "") - fmt.Fprint(w, "") - 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, "") - fmt.Fprintf(w, "", num) - fmt.Fprintf(w, "", record.Timestamp.Format("15:04:05")) - fmt.Fprintf(w, "", record.ModelID) - fmt.Fprintf(w, "", record.URL) - fmt.Fprintf(w, "", statusClass, statusText) - fmt.Fprintf(w, "", record.Duration) - fmt.Fprintf(w, "", i) - fmt.Fprintf(w, "", i) - fmt.Fprintf(w, "") - } - fmt.Fprint(w, "
#TimeModelURLStatusDurationRequestResponse
%d%s%s%s%s%.2fsjsonjson
") - } - - fmt.Fprint(w, ` - - -`) -} - // handleVersion returns version information as JSON func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/server/llmconfig.go b/server/llmconfig.go index 0def1ecdb0dc9431cc39d8f82b4a0998b96366a9..59f946ec639df2a46b742c01582c3ec939ffd601 100644 --- a/server/llmconfig.go +++ b/server/llmconfig.go @@ -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 } diff --git a/server/server.go b/server/server.go index 0705cedeb37f1c796d70d230636caf3ab4e7e125..9a83f022a308cd14733c9feb84618a4d1cc82a82 100644 --- a/server/server.go +++ b/server/server.go @@ -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())) diff --git a/test/anthropic_test.go b/test/anthropic_test.go index c23c8476cb44ffbcc0159a89f03de8fb6531d35e..ccf2e22f2a4ec23e589c60ec8d8b16d747aecef0 100644 --- a/test/anthropic_test.go +++ b/test/anthropic_test.go @@ -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{ diff --git a/test/server_test.go b/test/server_test.go index 19c101a13ea379510d62f2722863a73549f347b3..da9de5678f4371e2f2fceb7a8e1bf057944f6d81 100644 --- a/test/server_test.go +++ b/test/server_test.go @@ -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{