DefaultMatcher creates a matcher that normalizes dynamic fields for consistent matching. The onError callback is called if reading the request body fails (nil logs and returns false).
(onError func(err error))
| 277 | // DefaultMatcher creates a matcher that normalizes dynamic fields for consistent matching. |
| 278 | // The onError callback is called if reading the request body fails (nil logs and returns false). |
| 279 | func DefaultMatcher(onError func(err error)) recorder.MatcherFunc { |
| 280 | // Normalize tool call IDs (they change between requests) |
| 281 | callIDRegex := regexp.MustCompile(`call_[a-z0-9\-]+`) |
| 282 | // Normalize max_tokens/max_output_tokens/maxOutputTokens field (varies based on models.dev |
| 283 | // cache state and provider cloning behavior). Handles both snake_case and camelCase variants. |
| 284 | maxTokensRegex := regexp.MustCompile(`"(?:max_(?:output_)?tokens|maxOutputTokens)":\d+,?`) |
| 285 | // Normalize Gemini thinkingConfig (varies based on provider defaults for thinking budget). |
| 286 | // This handles both camelCase (API) variants of the thinkingConfig field. |
| 287 | thinkingConfigRegex := regexp.MustCompile(`"thinkingConfig":\{[^}]*\},?`) |
| 288 | // Normalize OpenAI reasoning config (varies based on NoThinking flag and thinking budget). |
| 289 | reasoningRegex := regexp.MustCompile(`"reasoning":\{[^}]*\},?`) |
| 290 | // Normalize OpenAI tool_choice field. The string form ("auto", "none", |
| 291 | // "required") is now sent explicitly whenever tools are present so that |
| 292 | // strict gateways (LiteLLM) accept the request, but older cassettes were |
| 293 | // recorded without it. |
| 294 | toolChoiceRegex := regexp.MustCompile(`"tool_choice":"[^"]*",?`) |
| 295 | |
| 296 | return func(r *http.Request, i cassette.Request) bool { |
| 297 | if r.Body == nil || r.Body == http.NoBody { |
| 298 | return cassette.DefaultMatcher(r, i) |
| 299 | } |
| 300 | if r.Method != i.Method { |
| 301 | return false |
| 302 | } |
| 303 | if r.URL.String() != i.URL { |
| 304 | return false |
| 305 | } |
| 306 | |
| 307 | reqBody, err := io.ReadAll(r.Body) |
| 308 | if err != nil { |
| 309 | if onError != nil { |
| 310 | onError(err) |
| 311 | } else { |
| 312 | slog.Error("Failed to read request body for matching", "error", err) |
| 313 | } |
| 314 | return false |
| 315 | } |
| 316 | r.Body.Close() |
| 317 | r.Body = io.NopCloser(bytes.NewBuffer(reqBody)) |
| 318 | |
| 319 | // Normalize dynamic fields for matching |
| 320 | normalizedReq := callIDRegex.ReplaceAllString(string(reqBody), "call_ID") |
| 321 | normalizedReq = maxTokensRegex.ReplaceAllString(normalizedReq, "") |
| 322 | normalizedReq = thinkingConfigRegex.ReplaceAllString(normalizedReq, "") |
| 323 | normalizedReq = reasoningRegex.ReplaceAllString(normalizedReq, "") |
| 324 | normalizedReq = toolChoiceRegex.ReplaceAllString(normalizedReq, "") |
| 325 | normalizedCassette := callIDRegex.ReplaceAllString(i.Body, "call_ID") |
| 326 | normalizedCassette = maxTokensRegex.ReplaceAllString(normalizedCassette, "") |
| 327 | normalizedCassette = thinkingConfigRegex.ReplaceAllString(normalizedCassette, "") |
| 328 | normalizedCassette = reasoningRegex.ReplaceAllString(normalizedCassette, "") |
| 329 | normalizedCassette = toolChoiceRegex.ReplaceAllString(normalizedCassette, "") |
| 330 | |
| 331 | return normalizedReq == normalizedCassette |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | // TargetURLForHost returns the target URL builder for a given forwarding host. |
| 336 | // Returns nil if the host is not recognized. |