ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Spansforhttp (#536)

* put in spans in http client rather than every package

* good by back compat

* are we getting better?

* make ai clients in consturctor, fail early

* lets go

* leave otel to higher level

* fall back to default

* default client unless given one

* dead code

* shut off cache

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
ba7f1484 9d81de26

+176 -107
+4
cmd/albertsonsquery/main.go
··· 10 10 11 11 "careme/internal/albertsons/query" 12 12 "careme/internal/brightdata" 13 + 14 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 13 15 ) 14 16 15 17 func main() { ··· 57 59 ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) 58 60 defer cancel() 59 61 } 62 + 60 63 httpClient, err := brightdata.NewProxyAwareHTTPClient(brightdata.LoadConfig()) 61 64 if err != nil { 62 65 return fmt.Errorf("create HTTP client: %w", err) 63 66 } 67 + httpClient.Transport = otelhttp.NewTransport(httpClient.Transport) 64 68 client, err := query.NewSearchClient(query.SearchClientConfig{ 65 69 BaseURL: baseURL, 66 70 SubscriptionKey: subscriptionKey,
+5 -3
cmd/careme/web.go
··· 29 29 "careme/internal/watchdog" 30 30 31 31 cachepkg "careme/internal/cache" 32 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 32 33 ) 33 34 34 35 func runServer(cfg *config.Config, addr string) error { ··· 58 59 userStorage := users.NewStorage(cache) 59 60 ro := &readyOnce{} 60 61 watchdogServer := watchdog.Server{} 62 + aiHTTPClient := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} 61 63 // TODO make the mock more transparent? 62 - grader := ingredientgrading.NewManager(cfg, cache) 64 + grader := ingredientgrading.NewManager(cfg, cache, aiHTTPClient) 63 65 64 66 var generator recipes.ExtGenerator 65 67 var waitFns []func() 66 68 if cfg.Mocks.Enable { 67 69 generator = recipes.NewMockGenerator() 68 70 } else { 69 - mc := critique.NewManager(cfg, cache) 71 + mc := critique.NewManager(cfg, cache, aiHTTPClient) 70 72 ro.add(mc) 71 73 72 - aiclient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL") 74 + aiclient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL", aiHTTPClient) 73 75 ro.add(aiclient) 74 76 staples, err := recipes.NewCachedStaplesService(cfg, cache, grader) 75 77 if err != nil {
+2 -1
cmd/ingredients/main.go
··· 5 5 "flag" 6 6 "fmt" 7 7 "log" 8 + "net/http" 8 9 "slices" 9 10 "strings" 10 11 ··· 60 61 if err != nil { 61 62 log.Fatalf("failed to create cache for ingredient grading: %s", err) 62 63 } 63 - grader := ingredientgrading.NewManager(cfg, cacheStore) 64 + grader := ingredientgrading.NewManager(cfg, cacheStore, http.DefaultClient) 64 65 graded, err := grader.GradeIngredients(ctx, ings) 65 66 if err != nil { 66 67 log.Fatalf("failed to grade ingredients: %s", err)
+2
go.mod
··· 20 20 github.com/sendgrid/sendgrid-go v3.16.1+incompatible 21 21 github.com/stretchr/testify v1.11.1 22 22 go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 23 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 23 24 go.opentelemetry.io/otel v1.43.0 24 25 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 25 26 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 ··· 48 49 github.com/cespare/xxhash/v2 v2.3.0 // indirect 49 50 github.com/davecgh/go-spew v1.1.1 // indirect 50 51 github.com/emicklei/go-restful/v3 v3.12.2 // indirect 52 + github.com/felixge/httpsnoop v1.0.4 // indirect 51 53 github.com/fxamacker/cbor/v2 v2.9.0 // indirect 52 54 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 53 55 github.com/go-logr/logr v1.4.3 // indirect
+4
go.sum
··· 61 61 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 62 62 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 63 63 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 64 + github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 65 + github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 64 66 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 65 67 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 66 68 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= ··· 280 282 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 281 283 go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM= 282 284 go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg= 285 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= 286 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 283 287 go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= 284 288 go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= 285 289 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
+22 -22
internal/ai/client.go
··· 8 8 "hash/fnv" 9 9 "io" 10 10 "log/slog" 11 + "net/http" 11 12 "strings" 12 13 "time" 13 14 ··· 20 21 21 22 "github.com/invopop/jsonschema" 22 23 ) 23 - 24 - type client struct { 25 - apiKey string 26 - schema map[string]any 27 - wineSchema map[string]any 28 - model string 29 - wineModel string 30 - } 31 24 32 25 type GeneratedImage struct { 33 26 Body io.Reader ··· 99 92 Commentary string `json:"commentary"` 100 93 } 101 94 95 + type client struct { 96 + schema map[string]any 97 + wineSchema map[string]any 98 + model string 99 + wineModel string 100 + oai openai.Client 101 + } 102 + 102 103 // ignoring model for now. 103 - func NewClient(apiKey, _ string) *client { 104 + func NewClient(apiKey, _ string, httpClient *http.Client) *client { 104 105 // ignor model for now. 105 106 r := jsonschema.Reflector{ 106 107 DoNotReference: true, // no $defs and no $ref ··· 114 115 _ = json.Unmarshal(recipesSchemaJSON, &m) 115 116 var wine map[string]any 116 117 _ = json.Unmarshal(wineSchemaJSON, &wine) 118 + opts := []option.RequestOption{option.WithAPIKey(apiKey)} 119 + if httpClient != nil { 120 + opts = append(opts, option.WithHTTPClient(httpClient)) 121 + } 122 + aiClient := openai.NewClient(opts...) 117 123 return &client{ 118 - apiKey: apiKey, 124 + oai: aiClient, 119 125 schema: m, 120 126 wineSchema: wine, 121 127 model: openai.ChatModelGPT5_4, ··· 223 229 if previousResponseID == "" { 224 230 return nil, fmt.Errorf("response ID is required for regeneration") 225 231 } 226 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 227 232 messages := cleanInstuctions(instructions) 228 233 229 234 params := responses.ResponseNewParams{ ··· 236 241 Store: openai.Bool(true), 237 242 Text: scheme(c.schema), 238 243 } 239 - resp, err := client.Responses.New(ctx, params) 244 + resp, err := c.oai.Responses.New(ctx, params) 240 245 if err != nil { 241 246 return nil, fmt.Errorf("failed to regenerate recipes: %w", err) 242 247 } ··· 249 254 if question == "" { 250 255 return nil, fmt.Errorf("question is required") 251 256 } 252 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 253 257 254 258 params := responses.ResponseNewParams{ 255 259 Model: c.model, ··· 262 266 if previousResponseID != "" { 263 267 params.PreviousResponseID = openai.String(previousResponseID) 264 268 } 265 - resp, err := client.Responses.New(ctx, params) 269 + resp, err := c.oai.Responses.New(ctx, params) 266 270 if err != nil { 267 271 return nil, fmt.Errorf("failed to answer question: %w", err) 268 272 } ··· 285 289 return nil, fmt.Errorf("failed to build recipe image prompt: %w", err) 286 290 } 287 291 288 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 289 - resp, err := client.Images.Generate(ctx, openai.ImageGenerateParams{ 292 + resp, err := c.oai.Images.Generate(ctx, openai.ImageGenerateParams{ 290 293 Prompt: prompt, 291 294 Model: recipeImageModel, 292 295 N: openai.Int(1), ··· 347 350 if err != nil { 348 351 return nil, fmt.Errorf("failed to build wine selection prompt: %w", err) 349 352 } 350 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 351 353 params := responses.ResponseNewParams{ 352 354 Model: c.wineModel, 353 355 Instructions: openai.String(winePrompt), ··· 356 358 }, 357 359 Text: scheme(c.wineSchema), 358 360 } 359 - resp, err := client.Responses.New(ctx, params) 361 + resp, err := c.oai.Responses.New(ctx, params) 360 362 if err != nil { 361 363 return nil, fmt.Errorf("failed to pick wine: %w", err) 362 364 } ··· 374 376 return nil, fmt.Errorf("failed to build recipe messages: %w", err) 375 377 } 376 378 377 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 378 379 params := responses.ResponseNewParams{ 379 380 Model: c.model, 380 381 Instructions: openai.String(systemMessage), ··· 387 388 } 388 389 // should we stream. Can we pass past generation. 389 390 390 - resp, err := client.Responses.New(ctx, params) 391 + resp, err := c.oai.Responses.New(ctx, params) 391 392 if err != nil { 392 393 return nil, fmt.Errorf("failed to generate recipes: %w", err) 393 394 } ··· 472 473 // more CORRECT to do a very simple response request with allowed tokens 1 but this seems cheaper 473 474 // https://chatgpt.com/share/6984da16-ff88-8009-8486-4e0479ac6a01 474 475 // could only do it once to ensure startup 475 - client := openai.NewClient(option.WithAPIKey(c.apiKey)) 476 - _, err := client.Models.List(ctx) 476 + _, err := c.oai.Models.List(ctx) 477 477 return err 478 478 } 479 479
+22 -21
internal/ai/critique.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "net/http" 8 9 "strings" 9 10 "time" 10 11 ··· 50 51 } 51 52 52 53 type critiquer struct { 53 - apiKey string 54 54 model string 55 55 schema map[string]any 56 + gem *genai.Client 56 57 } 57 58 58 - func NewCritiquer(apiKey, model string) *critiquer { 59 + func NewCritiquer(apiKey, model string, httpClient *http.Client) *critiquer { 59 60 model = strings.TrimSpace(model) 60 61 if model == "" { 61 62 model = defaultGeminiCritiqueModel 62 63 } 64 + 65 + if httpClient == nil { 66 + httpClient = http.DefaultClient 67 + } 68 + 69 + // pass in context and return error? seems like context only used in edge case 70 + client, err := genai.NewClient(context.TODO(), &genai.ClientConfig{ 71 + APIKey: apiKey, 72 + Backend: genai.BackendGeminiAPI, 73 + HTTPClient: httpClient, 74 + }) 75 + if err != nil { 76 + panic(err) 77 + } 78 + 63 79 return &critiquer{ 64 - apiKey: strings.TrimSpace(apiKey), 80 + gem: client, 65 81 model: model, 66 82 schema: recipeCritiqueJSONSchema(), 67 83 } 68 84 } 69 85 70 86 func (c *critiquer) Ready(ctx context.Context) error { 71 - client, err := c.newClient(ctx) 72 - if err != nil { 73 - return err 74 - } 75 - for _, err := range client.Models.All(ctx) { 87 + for _, err := range c.gem.Models.All(ctx) { 76 88 return err 77 89 } 78 90 return fmt.Errorf("model not found: %s", c.model) ··· 95 107 if err != nil { 96 108 return nil, fmt.Errorf("failed to build recipe critique prompt: %w", err) 97 109 } 98 - client, err := c.newClient(ctx) 110 + 99 111 if err != nil { 100 112 return nil, err 101 113 } 102 114 start := time.Now() 103 - resp, err := client.Models.GenerateContent(ctx, c.model, genai.Text(prompt), &genai.GenerateContentConfig{ 115 + resp, err := c.gem.Models.GenerateContent(ctx, c.model, genai.Text(prompt), &genai.GenerateContentConfig{ 104 116 SystemInstruction: genai.NewContentFromText(recipeCritiqueSystemInstruction, genai.RoleUser), 105 117 // Temperature: genai.Ptr[float32](0), 106 118 // MaxOutputTokens: 768, ··· 146 158 } 147 159 148 160 return slog.Group("usage", attrs...) 149 - } 150 - 151 - func (c *critiquer) newClient(ctx context.Context) (*genai.Client, error) { 152 - client, err := genai.NewClient(ctx, &genai.ClientConfig{ 153 - APIKey: c.apiKey, 154 - Backend: genai.BackendGeminiAPI, 155 - }) 156 - if err != nil { 157 - return nil, fmt.Errorf("create Gemini client: %w", err) 158 - } 159 - return client, nil 160 161 } 161 162 162 163 func parseRecipeCritique(body string) (*RecipeCritique, error) {
+11 -5
internal/ai/ingredient_grade.go
··· 8 8 "hash/fnv" 9 9 "io" 10 10 "log/slog" 11 + "net/http" 11 12 "strings" 12 13 13 14 "github.com/invopop/jsonschema" ··· 120 121 } 121 122 122 123 type ingredientGrader struct { 123 - apiKey string 124 124 model string 125 125 cacheVersion string 126 126 schema map[string]any 127 + oai openai.Client 127 128 } 128 129 129 130 func ingredientGradeCacheVersion(model, systemInstruction string) string { ··· 133 134 return base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 134 135 } 135 136 136 - func NewIngredientGrader(apiKey, model string) *ingredientGrader { 137 + func NewIngredientGrader(apiKey, model string, httpClient *http.Client) *ingredientGrader { 137 138 model = strings.TrimSpace(model) 138 139 if model == "" { 139 140 model = defaultIngredientGradeModel 140 141 } 142 + opts := []option.RequestOption{option.WithAPIKey(apiKey)} 143 + if httpClient != nil { 144 + opts = append(opts, option.WithHTTPClient(httpClient)) 145 + } 146 + aiClient := openai.NewClient(opts...) 147 + 141 148 return &ingredientGrader{ 142 - apiKey: strings.TrimSpace(apiKey), 149 + oai: aiClient, 143 150 model: model, 144 151 cacheVersion: ingredientGradeCacheVersion(model, ingredientGradeSystemInstruction), 145 152 schema: ingredientGradeJSONSchema(), ··· 169 176 return nil, fmt.Errorf("failed to build ingredient grading prompt: %w", err) 170 177 } 171 178 172 - client := openai.NewClient(option.WithAPIKey(g.apiKey)) 173 - resp, err := client.Responses.New(ctx, responses.ResponseNewParams{ 179 + resp, err := g.oai.Responses.New(ctx, responses.ResponseNewParams{ 174 180 Model: g.model, 175 181 Instructions: openai.String(ingredientGradeSystemInstruction), 176 182 Input: responses.ResponseNewParamsInputUnion{
+4 -1
internal/albertsons/query/client.go
··· 77 77 78 78 httpClient := cfg.HTTPClient 79 79 if httpClient == nil { 80 - httpClient = &http.Client{Timeout: 20 * time.Second} 80 + httpClient = http.DefaultClient 81 81 } 82 82 83 83 return &SearchClient{ ··· 89 89 } 90 90 91 91 func (c *SearchClient) Search(ctx context.Context, storeID, category string, opts SearchOptions) (*PathwaySearchPayload, error) { 92 + ctx, cancel := context.WithTimeout(ctx, time.Second*20) 93 + defer cancel() 94 + 92 95 storeID = strings.TrimSpace(storeID) 93 96 if storeID == "" { 94 97 return nil, errors.New("store id is required")
+18 -8
internal/brightdata/proxy.go
··· 47 47 } 48 48 49 49 func NewProxyAwareHTTPClient(cfg ProxyConfig) (*http.Client, error) { 50 - client := &http.Client{} 51 - if !cfg.Enabled() { 52 - return withRetries(client), nil 50 + transport := http.DefaultTransport 51 + if cfg.Enabled() { 52 + var err error 53 + transport, err = newProxyTransport(cfg) 54 + if err != nil { 55 + return nil, err 56 + } 53 57 } 54 58 59 + client := &http.Client{Transport: transport} 60 + 61 + return withRetries(client), nil 62 + } 63 + 64 + func newProxyTransport(cfg ProxyConfig) (*http.Transport, error) { 55 65 rootCAs, err := proxyRootCAs() 56 66 if err != nil { 57 67 return nil, err ··· 64 74 "username", cfg.Username, 65 75 ) 66 76 67 - transport := http.DefaultTransport.(*http.Transport).Clone() 68 - transport.Proxy = http.ProxyURL(cfg.proxyURL()) 69 - transport.TLSClientConfig = &tls.Config{RootCAs: rootCAs} 70 - client.Transport = transport 71 - return withRetries(client), nil 77 + // this feels funny 78 + proxyTransport := http.DefaultTransport.(*http.Transport).Clone() 79 + proxyTransport.Proxy = http.ProxyURL(cfg.proxyURL()) 80 + proxyTransport.TLSClientConfig = &tls.Config{RootCAs: rootCAs} 81 + return proxyTransport, nil 72 82 } 73 83 74 84 // this would be nice but it logs all retries as errors which sets off alerts.
+3 -3
internal/brightdata/proxy_test.go
··· 68 68 69 69 transport, ok := retryTransport.Client.HTTPClient.Transport.(*http.Transport) 70 70 if !ok { 71 - t.Fatalf("expected wrapped base *http.Transport, got %T", retryTransport.Client.HTTPClient.Transport) 71 + t.Fatalf("expected proxy *http.Transport, got %T", retryTransport.Client.HTTPClient.Transport) 72 72 } 73 73 74 74 req, err := http.NewRequest(http.MethodGet, "https://www.example.com/products", nil) ··· 104 104 if !ok { 105 105 t.Fatalf("expected *retryablehttp.RoundTripper when proxy disabled, got %T", client.Transport) 106 106 } 107 - if retryTransport.Client.HTTPClient.Transport != nil { 108 - t.Fatalf("expected default base transport via nil transport, got %T", retryTransport.Client.HTTPClient.Transport) 107 + if retryTransport.Client.HTTPClient.Transport != http.DefaultTransport { 108 + t.Fatalf("expected default base transport, got %T", retryTransport.Client.HTTPClient.Transport) 109 109 } 110 110 } 111 111
+14 -3
internal/cache/azure.go
··· 6 6 "io" 7 7 "log" 8 8 "log/slog" 9 + "net/http" 9 10 "os" 10 11 "strings" 11 12 12 13 "github.com/Azure/azure-sdk-for-go/sdk/azcore" 14 + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" 13 15 "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" 14 16 "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob" 15 17 "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror" ··· 22 24 23 25 var _ ListCache = (*BlobCache)(nil) 24 26 25 - func NewBlobCache(container string) (*BlobCache, error) { 27 + func NewBlobCache(container string, transport http.RoundTripper) (*BlobCache, error) { 26 28 // Should come from config 27 29 accountName, ok := os.LookupEnv("AZURE_STORAGE_ACCOUNT_NAME") 28 30 if !ok { ··· 37 39 cred, err := azblob.NewSharedKeyCredential(accountName, accountKey) 38 40 if err != nil { 39 41 return nil, fmt.Errorf("failed to create shared key credential: %w", err) 42 + } 43 + if transport == nil { 44 + transport = http.DefaultTransport 40 45 } 41 46 42 47 // The service URL for blob endpoints is usually in the form: http(s)://<account>.blob.core.windows.net/ 43 - client, err := azblob.NewClientWithSharedKeyCredential(fmt.Sprintf("https://%s.blob.core.windows.net/", accountName), cred, nil) 48 + client, err := azblob.NewClientWithSharedKeyCredential(fmt.Sprintf("https://%s.blob.core.windows.net/", accountName), cred, &azblob.ClientOptions{ 49 + ClientOptions: policy.ClientOptions{ 50 + Transport: &http.Client{Transport: transport}, 51 + }, 52 + }) 44 53 if err != nil { 45 54 return nil, fmt.Errorf("failed to create blob client: %w", err) 46 55 } ··· 142 151 return EnsureCache("recipes") 143 152 } 144 153 154 + // take transport here? 145 155 func EnsureCache(container string) (ListCache, error) { 146 156 _, ok := os.LookupEnv("AZURE_STORAGE_ACCOUNT_NAME") 147 157 if ok { 148 158 slog.Info("Using Azure Blob Storage for cache", "container", container) 149 - return NewBlobCache(container) 159 + // can pas in otelhttp.NewTransport(http.DefaultTransport) but it creates a lot of noise 160 + return NewBlobCache(container, http.DefaultTransport) 150 161 } 151 162 return NewFileCache(container), nil 152 163 }
+3 -2
internal/ingredients/grading/manager.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "net/http" 5 6 "strings" 6 7 7 8 "careme/internal/ai" ··· 42 43 return m.grader.CacheVersion() 43 44 } 44 45 45 - func NewManager(cfg *config.Config, c cache.ListCache) grader { 46 + func NewManager(cfg *config.Config, c cache.ListCache, httpClient *http.Client) grader { 46 47 if cfg == nil || !cfg.IngredientGrading.Enable || strings.TrimSpace(cfg.AI.APIKey) == "" { 47 48 return rubberstamp{} 48 49 } 49 - base := ai.NewIngredientGrader(cfg.AI.APIKey, cfg.IngredientGrading.Model) 50 + base := ai.NewIngredientGrader(cfg.AI.APIKey, cfg.IngredientGrading.Model, httpClient) 50 51 return newCachingGrader(&multiGrader{grader: base}, NewStore(c)) 51 52 } 52 53
+15 -13
internal/kroger/client.go
··· 32 32 expiresAt time.Time 33 33 clientID string 34 34 clientSecret string 35 + httpClient *http.Client 35 36 mu sync.Mutex 36 37 } 37 38 38 - func NewKrogerTokenManager(clientID, clientSecret string) *KrogerTokenManager { 39 + func NewKrogerTokenManager(clientID, clientSecret string, httpClient *http.Client) *KrogerTokenManager { 40 + if httpClient == nil { 41 + httpClient = http.DefaultClient 42 + } 39 43 return &KrogerTokenManager{ 40 44 clientID: clientID, 41 45 clientSecret: clientSecret, 46 + httpClient: httpClient, 42 47 } 43 48 } 44 49 ··· 61 66 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 62 67 req.SetBasicAuth(m.clientID, m.clientSecret) 63 68 64 - resp, err := http.DefaultClient.Do(req) 69 + resp, err := m.httpClient.Do(req) 65 70 if err != nil { 66 71 return "", err 67 72 } ··· 90 95 return m.token, nil 91 96 } 92 97 93 - // GetOAuth2Token fetches an access token using client credentials grant 94 - // Deprecated: use KrogerTokenManager instead 95 - func GetOAuth2Token(ctx context.Context, clientID, clientSecret string) (string, error) { 96 - tm := NewKrogerTokenManager(clientID, clientSecret) 97 - return tm.GetToken(ctx) 98 - } 99 - 100 - func newBearerTokenRequestEditor(cfg *config.Config) func(context.Context, *http.Request) error { 101 - tokenManager := NewKrogerTokenManager(cfg.Kroger.ClientID, cfg.Kroger.ClientSecret) 98 + func newBearerTokenRequestEditor(cfg *config.Config, httpClient *http.Client) func(context.Context, *http.Request) error { 99 + tokenManager := NewKrogerTokenManager(cfg.Kroger.ClientID, cfg.Kroger.ClientSecret, httpClient) 102 100 103 101 return func(editorCtx context.Context, req *http.Request) error { 104 102 token, err := tokenManager.GetToken(editorCtx) ··· 110 108 } 111 109 } 112 110 113 - func NewProductsClientFromConfig(cfg *config.Config) (*products.ClientWithResponses, error) { 114 - requestEditor := newBearerTokenRequestEditor(cfg) 111 + func NewProductsClientFromConfig(cfg *config.Config, httpClient *http.Client) (*products.ClientWithResponses, error) { 112 + if httpClient == nil { 113 + httpClient = http.DefaultClient 114 + } 115 + requestEditor := newBearerTokenRequestEditor(cfg, httpClient) 115 116 productsClient, err := products.NewClientWithResponses("https://api.kroger.com", 117 + products.WithHTTPClient(httpClient), 116 118 products.WithRequestEditorFn(products.RequestEditorFn(requestEditor)), 117 119 ) 118 120 if err != nil {
+9 -4
internal/kroger/locations.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "net/http" 6 7 7 8 "careme/internal/config" 8 9 krogerlocations "careme/internal/kroger/locations" ··· 15 16 client *krogerlocations.ClientWithResponses 16 17 } 17 18 18 - func NewLocationBackendFromConfig(cfg *config.Config) (*LocationBackend, error) { 19 - requestEditor := newBearerTokenRequestEditor(cfg) 20 - client, err := krogerlocations.NewClientWithResponses("https://api.kroger.com", 19 + func NewLocationBackendFromConfig(cfg *config.Config, httpClient *http.Client) (*LocationBackend, error) { 20 + if httpClient == nil { 21 + httpClient = http.DefaultClient 22 + } 23 + requestEditor := newBearerTokenRequestEditor(cfg, httpClient) 24 + locationsClient, err := krogerlocations.NewClientWithResponses("https://api.kroger.com", 25 + krogerlocations.WithHTTPClient(httpClient), 21 26 krogerlocations.WithRequestEditorFn(krogerlocations.RequestEditorFn(requestEditor)), 22 27 ) 23 28 if err != nil { 24 29 return nil, fmt.Errorf("create kroger locations client: %w", err) 25 30 } 26 - return &LocationBackend{client: client}, nil 31 + return &LocationBackend{client: locationsClient}, nil 27 32 } 28 33 29 34 func (b *LocationBackend) IsID(locationID string) bool {
+2 -2
internal/kroger/staples.go
··· 53 53 client *products.ClientWithResponses 54 54 } 55 55 56 - func NewStaplesProvider(cfg *config.Config) (*StaplesProvider, error) { 57 - client, err := NewProductsClientFromConfig(cfg) 56 + func NewStaplesProvider(cfg *config.Config, httpClient *http.Client) (*StaplesProvider, error) { 57 + client, err := NewProductsClientFromConfig(cfg, httpClient) 58 58 if err != nil { 59 59 return nil, err 60 60 }
+6 -1
internal/locations/storage.go
··· 6 6 "errors" 7 7 "fmt" 8 8 "log/slog" 9 + "net/http" 9 10 "sort" 10 11 "time" 11 12 ··· 26 27 locationtypes "careme/internal/locations/types" 27 28 28 29 "github.com/samber/lo" 30 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 29 31 "golang.org/x/sync/errgroup" 30 32 ) 31 33 ··· 79 81 } 80 82 81 83 ctx := context.Background() 84 + httpClient := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} 82 85 backendfactories := []locationBackendFactory{ 83 - func(context.Context) (locationBackend, error) { return kroger.NewLocationBackendFromConfig(cfg) }, 86 + func(context.Context) (locationBackend, error) { 87 + return kroger.NewLocationBackendFromConfig(cfg, httpClient) 88 + }, 84 89 func(context.Context) (locationBackend, error) { return walmart.NewClient(cfg.Walmart) }, 85 90 func(ctx context.Context) (locationBackend, error) { 86 91 return aldi.NewLocationBackendFromConfig(ctx, cfg, centroids)
+5 -3
internal/mail/mail.go
··· 30 30 "github.com/sendgrid/rest" 31 31 "github.com/sendgrid/sendgrid-go" 32 32 "github.com/sendgrid/sendgrid-go/helpers/mail" 33 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 33 34 "go.opentelemetry.io/otel" 34 35 "go.opentelemetry.io/otel/attribute" 35 36 ) ··· 73 74 } 74 75 75 76 userStorage := users.NewStorage(cache) 76 - mc := critique.NewManager(cfg, cache) 77 - ig := ingredientgrading.NewManager(cfg, cache) 77 + aiHTTPClient := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} 78 + mc := critique.NewManager(cfg, cache, aiHTTPClient) 79 + ig := ingredientgrading.NewManager(cfg, cache, aiHTTPClient) 78 80 staples, err := recipes.NewCachedStaplesService(cfg, cache, ig) 79 81 if err != nil { 80 82 return nil, fmt.Errorf("failed to create staples service: %w", err) 81 83 } 82 84 ss := recipes.StatusStore(cache) 83 - aiClient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL") 85 + aiClient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL", aiHTTPClient) 84 86 generator, err := recipes.NewGenerator(aiClient, mc, staples, ss) 85 87 if err != nil { 86 88 return nil, fmt.Errorf("failed to create recipe generator: %w", err)
+3 -2
internal/recipes/critique/manager.go
··· 4 4 "context" 5 5 "fmt" 6 6 "log/slog" 7 + "net/http" 7 8 "strings" 8 9 "sync" 9 10 ··· 60 61 wg sync.WaitGroup 61 62 } 62 63 63 - func NewManager(cfg *config.Config, c cache.ListCache) Manager { 64 + func NewManager(cfg *config.Config, c cache.ListCache, httpClient *http.Client) Manager { 64 65 if !cfg.Gemini.IsEnabled() { 65 66 return rubberstamp{} 66 67 } 67 - crit := ai.NewCritiquer(cfg.Gemini.APIKey, cfg.Gemini.CritiqueModel) 68 + crit := ai.NewCritiquer(cfg.Gemini.APIKey, cfg.Gemini.CritiqueModel, httpClient) 68 69 return &multiCritiquer{ 69 70 critiquer: newCachingCritiquer(crit, NewStore(c)), 70 71 }
+1 -1
internal/recipes/critique/multi_test.go
··· 45 45 func TestNewServiceReturnsRubberstampWithoutGemini(t *testing.T) { 46 46 t.Parallel() 47 47 48 - svc := NewManager(&config.Config{}, cache.NewFileCache(t.TempDir())) 48 + svc := NewManager(&config.Config{}, cache.NewFileCache(t.TempDir()), nil) 49 49 50 50 results := svc.CritiqueRecipes(t.Context(), []ai.Recipe{{Title: "Weeknight Pasta"}}) 51 51 result, ok := <-results
+7 -7
internal/recipes/generator_test.go
··· 423 423 } 424 424 critiquer := &captureCritiqueService{} 425 425 g := &generatorService{ 426 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 426 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 427 427 aiClient: aiStub, 428 428 critiquer: critiquer, 429 429 statusWriter: noopstatuswriter{}, ··· 525 525 } 526 526 527 527 g := &generatorService{ 528 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 528 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 529 529 aiClient: aiStub, 530 530 critiquer: critiquer, 531 531 statusWriter: noopstatuswriter{}, ··· 612 612 }, 613 613 } 614 614 g := &generatorService{ 615 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 615 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 616 616 aiClient: aiStub, 617 617 critiquer: critiquer, 618 618 statusWriter: noopstatuswriter{}, ··· 647 647 }}, 648 648 } 649 649 g := &generatorService{ 650 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 650 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 651 651 aiClient: aiStub, 652 652 critiquer: &captureCritiqueService{}, 653 653 statusWriter: noopstatuswriter{}, ··· 686 686 687 687 statuses := &statusCounter{} 688 688 g := &generatorService{ 689 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 689 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 690 690 aiClient: &sequenceAIClient{generateResponses: []*ai.ShoppingList{{ResponseID: "resp-stable", Recipes: []ai.Recipe{steady}}}}, 691 691 critiquer: &captureCritiqueService{}, 692 692 statusWriter: statuses, ··· 890 890 }, 891 891 } 892 892 g := &generatorService{ 893 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 893 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 894 894 aiClient: aiStub, 895 895 critiquer: critiquer, 896 896 statusWriter: noopstatuswriter{}, ··· 945 945 }, 946 946 } 947 947 g := &generatorService{ 948 - staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 948 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil, nil)}, 949 949 aiClient: aiStub, 950 950 critiquer: critiquer, 951 951 statusWriter: noopstatuswriter{},
+10 -4
internal/recipes/staples.go
··· 8 8 "hash/fnv" 9 9 "io" 10 10 "log/slog" 11 + "net/http" 11 12 "strings" 12 13 "testing" 13 14 "time" ··· 23 24 "careme/internal/wholefoods" 24 25 25 26 "github.com/samber/lo" 27 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 26 28 "go.opentelemetry.io/otel/attribute" 27 29 ) 28 30 ··· 250 252 return nil, fmt.Errorf("staples provider does not support location %q", locationID) 251 253 } 252 254 255 + // should we pass in a wrapper/roundtripper 253 256 func defaultStaplesBackends(cfg *config.Config) ([]backendStaplesProvider, error) { 254 257 // should we do this per request so we get new proxies per user? https://github.com/paulgmiller/careme/issues/443 255 - httpClient, err := brightdata.NewProxyAwareHTTPClient(cfg.BrightDataProxy) 258 + brightdataClient, err := brightdata.NewProxyAwareHTTPClient(cfg.BrightDataProxy) 256 259 if err != nil { 257 260 return nil, fmt.Errorf("create bright data proxy-aware client: %w", err) 258 261 } 262 + brightdataClient.Transport = otelhttp.NewTransport(brightdataClient.Transport) 259 263 260 264 // only returns an err because it ensures a cache for reese84 tokens. 261 - albertsonsProvider, err := albertsons.NewStaplesProvider(cfg.Albertsons, httpClient) 265 + albertsonsProvider, err := albertsons.NewStaplesProvider(cfg.Albertsons, brightdataClient) 262 266 if err != nil { 263 267 return nil, fmt.Errorf("create albertsons staples provider: %w", err) 264 268 } 265 269 266 - krogerBackend, err := kroger.NewStaplesProvider(cfg) 270 + // we should not use brightdata for koger 271 + httpClient := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} 272 + krogerBackend, err := kroger.NewStaplesProvider(cfg, httpClient) 267 273 if err != nil { 268 274 return nil, fmt.Errorf("create kroger staples provider: %w", err) 269 275 } ··· 273 279 krogerBackend, 274 280 // actowiz.NewStaplesProvider(), 275 281 walmart.NewStaplesProvider(), 276 - wholefoods.NewStaplesProvider(wholefoods.NewClient(httpClient)), 282 + wholefoods.NewStaplesProvider(wholefoods.NewClient(brightdataClient)), 277 283 }, nil 278 284 } 279 285
+4 -1
internal/wholefoods/client.go
··· 145 145 baseURL = DefaultBaseURL 146 146 } 147 147 if httpClient == nil { 148 - httpClient = &http.Client{Timeout: 20 * time.Second} 148 + httpClient = http.DefaultClient 149 149 } 150 150 151 151 return &client{ ··· 157 157 // Category fetches category products and follows limit/offset pagination until 158 158 // the API returns fewer items than the requested page size. 159 159 func (c *client) Category(ctx context.Context, queryterm, store string) ([]product, error) { 160 + ctx, cancel := context.WithTimeout(ctx, time.Second*20) 161 + defer cancel() 162 + 160 163 queryterm = strings.TrimSpace(queryterm) 161 164 if queryterm == "" { 162 165 return nil, errors.New("queryterm is required")