From b6ed899ad59184e91498ecf26bdc38edfa4b302f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 15 Jul 2026 15:08:59 +0100 Subject: [PATCH] Add branch-coverage tests for provider context and tool-choice helpers New table-driven tests raise branch coverage on ContextWindowTokens, extractModelName, isDigits, rateLimitInfo, normalizeOpenAICompatibleChatURL, stop-reason normalization and the OpenAI, Anthropic and Gemini tool-choice converters, covering date-suffix stripping, malformed URLs and default arms. Test-only, no source changes. --- .../context_limits_branchcov_test.go | 235 +++++++++++++++++ .../openai_helpers_branchcov_test.go | 238 ++++++++++++++++++ .../ai/providers/rate_limit_branchcov_test.go | 170 +++++++++++++ .../providers/tool_choice_branchcov_test.go | 166 ++++++++++++ 4 files changed, 809 insertions(+) create mode 100644 internal/ai/providers/context_limits_branchcov_test.go create mode 100644 internal/ai/providers/openai_helpers_branchcov_test.go create mode 100644 internal/ai/providers/rate_limit_branchcov_test.go create mode 100644 internal/ai/providers/tool_choice_branchcov_test.go diff --git a/internal/ai/providers/context_limits_branchcov_test.go b/internal/ai/providers/context_limits_branchcov_test.go new file mode 100644 index 000000000..546f862f5 --- /dev/null +++ b/internal/ai/providers/context_limits_branchcov_test.go @@ -0,0 +1,235 @@ +package providers + +import "testing" + +// TestContextWindowTokens_BranchCov_EmptyAndColonStripped exercises the +// early-return guard at context_limits.go:68 where extractModelName yields an +// empty string. This covers three ways to produce that empty model name: +// - whitespace-only input +// - a provider prefix whose right-hand side is empty after trimming +// - a bare trailing colon with nothing after it +func TestContextWindowTokens_BranchCov_EmptyAndColonStripped(t *testing.T) { + cases := []struct { + name string + model string + }{ + {"whitespace only", " "}, + {"empty string", ""}, + {"prefix colon empty after trim", "anthropic: "}, + {"prefix colon nothing after", "openai:"}, + {"bare colon", ":"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ContextWindowTokens(tc.model) + if got != DefaultContextWindow { + t.Fatalf("ContextWindowTokens(%q) = %d, want %d (DefaultContextWindow)", + tc.model, got, DefaultContextWindow) + } + }) + } +} + +// TestContextWindowTokens_BranchCov_DateSuffixVariants hits the +// stripDateSuffix+exact-map branch at context_limits.go:76-79 for both the +// -YYYY-MM-DD and -YYYYMMDD suffix shapes. +func TestContextWindowTokens_BranchCov_DateSuffixVariants(t *testing.T) { + cases := []struct { + name string + model string + want int + }{ + // -YYYY-MM-DD (11-char) suffix form. + {"gpt-4o dashed date", "gpt-4o-2024-08-06", 128_000}, + {"claude-3-opus dashed date", "claude-3-opus-2024-02-29", 200_000}, + // -YYYYMMDD (9-char) suffix form. + {"claude-opus-4 compact date", "claude-opus-4-20250514", 200_000}, + {"gemini-1.5-pro compact date", "gemini-1.5-pro-20240409", 2_097_152}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ContextWindowTokens(tc.model) + if got != tc.want { + t.Fatalf("ContextWindowTokens(%q) = %d, want %d", tc.model, got, tc.want) + } + }) + } +} + +// TestContextWindowTokens_BranchCov_EqualFoldMatch drives the EqualFold arm of +// caseInsensitiveMatch (context_limits.go:170). The exact map lookup and the +// case-sensitive longestPrefixMatch both miss because the only differing +// bytes are ASCII case, so resolution must come from strings.EqualFold. +func TestContextWindowTokens_BranchCov_EqualFoldMatch(t *testing.T) { + cases := []struct { + name string + model string + want int + }{ + {"upper GPT-4O", "GPT-4O", 128_000}, + {"mixed Claude-Opus-4", "Claude-Opus-4", 200_000}, + // Preserves the mixed-case registry key "MiniMax-Text-01" via lowercased input. + {"lower minimax", "minimax-text-01", 1_000_000}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ContextWindowTokens(tc.model) + if got != tc.want { + t.Fatalf("ContextWindowTokens(%q) = %d, want %d", tc.model, got, tc.want) + } + }) + } +} + +// TestContextWindowTokens_BranchCov_CaseInsensitivePrefixFallback exercises the +// fallback prefix phase of caseInsensitiveMatch (context_limits.go:175-184). +// The input differs from any known model in length and in case, so neither the +// exact map, the date strip, the case-sensitive longestPrefixMatch, nor +// EqualFold can resolve it; only the lowercased HasPrefix scan succeeds. +func TestContextWindowTokens_BranchCov_CaseInsensitivePrefixFallback(t *testing.T) { + cases := []struct { + name string + model string + want int + }{ + {"upper Claude-3-5-Sonnet-Latest", "Claude-3-5-Sonnet-Latest", 200_000}, + {"upper GROK-3-MINI-HI", "GROK-3-MINI-HI", 131_072}, + {"upper GPT-4O-MINI-PREVIEW", "GPT-4O-MINI-PREVIEW", 128_000}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ContextWindowTokens(tc.model) + if got != tc.want { + t.Fatalf("ContextWindowTokens(%q) = %d, want %d", tc.model, got, tc.want) + } + }) + } +} + +// TestContextWindowTokens_BranchCov_GenuinelyUnknown hits the final +// DefaultContextWindow return at context_limits.go:99 for a model that no +// fuzzy strategy can resolve (no colon, no date suffix, no prefix in any case). +func TestContextWindowTokens_BranchCov_GenuinelyUnknown(t *testing.T) { + cases := []struct { + name string + model string + }{ + {"nonsense", "zzz-not-a-real-model"}, + {"leading digits", "12345"}, + {"symbols only", "---"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ContextWindowTokens(tc.model) + if got != DefaultContextWindow { + t.Fatalf("ContextWindowTokens(%q) = %d, want %d (DefaultContextWindow)", + tc.model, got, DefaultContextWindow) + } + }) + } +} + +// TestExtractModelName_BranchCov covers every branch of extractModelName +// (context_limits.go:102-113): empty/whitespace guard, no-colon passthrough, +// and the colon-split + TrimSpace path including trailing-colon emptiness and +// only-the-first-colon-wins semantics. +func TestExtractModelName_BranchCov(t *testing.T) { + cases := []struct { + name string + model string + want string + }{ + {"empty", "", ""}, + {"whitespace only", " \t ", ""}, + {"no colon trimmed passthrough", " gpt-4o ", "gpt-4o"}, + {"simple prefix strip", "anthropic:claude-opus-4", "claude-opus-4"}, + {"prefix strip with inner whitespace", "anthropic: claude-opus-4 ", "claude-opus-4"}, + {"bare colon yields empty", "anthropic:", ""}, + {"colon only yields empty", ":", ""}, + {"only first colon splits", "openai:gpt-4o:alias", "gpt-4o:alias"}, + {"leading colon keeps remainder", ":gpt-4o", "gpt-4o"}, + {"trailing colon yields empty", "gpt-4o:", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := extractModelName(tc.model) + if got != tc.want { + t.Fatalf("extractModelName(%q) = %q, want %q", tc.model, got, tc.want) + } + }) + } +} + +// TestCaseInsensitiveMatch_BranchCov covers all three return sites of +// caseInsensitiveMatch (context_limits.go:168-189): the EqualFold exact match, +// the lowercased-prefix fallback, and the no-match (0,false) outcome. +func TestCaseInsensitiveMatch_BranchCov(t *testing.T) { + cases := []struct { + name string + model string + wantTok int + wantFound bool + }{ + // EqualFold arm: identical length, differing ASCII case. + {"equalfold upper GPT-4O", "GPT-4O", 128_000, true}, + {"equalfold lower minimax-text-01", "minimax-text-01", 1_000_000, true}, + // Lowercased prefix fallback: longer than any known model, case differs. + {"prefix Claude-3-5-Sonnet-Latest", "Claude-3-5-Sonnet-Latest", 200_000, true}, + {"prefix GROK-3-MINI-hi", "GROK-3-MINI-hi", 131_072, true}, + // No match at all. + {"no match nonsense", "zzz-not-a-real-model", 0, false}, + {"no match symbols", "---", 0, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotTok, gotFound := caseInsensitiveMatch(tc.model) + if gotFound != tc.wantFound { + t.Fatalf("caseInsensitiveMatch(%q) found = %v, want %v", + tc.model, gotFound, tc.wantFound) + } + if gotFound && gotTok != tc.wantTok { + t.Fatalf("caseInsensitiveMatch(%q) tokens = %d, want %d", + tc.model, gotTok, tc.wantTok) + } + if !gotFound && gotTok != 0 { + t.Fatalf("caseInsensitiveMatch(%q) tokens = %d, want 0 when not found", + tc.model, gotTok) + } + }) + } +} + +// TestIsDigits_BranchCov covers every branch of isDigits +// (context_limits.go:140-150): empty-string guard, the loop hit on a +// non-digit byte (both leading and trailing positions), the ASCII '-' case, +// and the all-digits success path. +func TestIsDigits_BranchCov(t *testing.T) { + cases := []struct { + s string + want bool + }{ + {"", false}, // empty guard + {"0", true}, // single digit success + {"123", true}, // multi-digit success + {"2024", true}, // year-like success + {"12a", false}, // trailing non-digit + {"a1", false}, // leading non-digit + {"-5", false}, // '-' is not a digit + {"1 2", false}, // embedded space is not a digit + {"12", false}, // full-width digits are non-ASCII, must be rejected + } + + for _, tc := range cases { + got := isDigits(tc.s) + if got != tc.want { + t.Fatalf("isDigits(%q) = %v, want %v", tc.s, got, tc.want) + } + } +} diff --git a/internal/ai/providers/openai_helpers_branchcov_test.go b/internal/ai/providers/openai_helpers_branchcov_test.go new file mode 100644 index 000000000..46d9b2b2d --- /dev/null +++ b/internal/ai/providers/openai_helpers_branchcov_test.go @@ -0,0 +1,238 @@ +package providers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestNormalizeOpenAICompatibleChatURL_BranchCov drives every branch of +// normalizeOpenAICompatibleChatURL directly. The sibling client-construction +// tests already exercise the empty, /chat/completions passthrough, /completions +// rewrite, empty-host-path, and non-empty-path append branches; this table adds +// the previously uncovered guard arm where url.Parse fails or the parsed URL has +// no scheme/host (relative or bare-token base URLs), and both of its sub-arms. +func TestNormalizeOpenAICompatibleChatURL_BranchCov(t *testing.T) { + tests := []struct { + name string + baseURL string + want string + }{ + // Branch: empty input falls back to the canonical OpenAI endpoint. + { + name: "empty returns default openai url", + baseURL: "", + want: openaiAPIURL, + }, + // Branch: whitespace-only trims to empty and falls back too. + { + name: "whitespace only returns default openai url", + baseURL: " ", + want: openaiAPIURL, + }, + // Branch: already targets /chat/completions -> returned verbatim (trailing + // slashes stripped). + { + name: "chat completions suffix passes through", + baseURL: "https://api.deepseek.com/chat/completions", + want: "https://api.deepseek.com/chat/completions", + }, + // Branch: legacy /completions endpoint rewritten to /chat/completions. + { + name: "completions suffix rewritten to chat completions", + baseURL: "https://api.mistral.ai/v1/completions", + want: "https://api.mistral.ai/v1/chat/completions", + }, + // Branch: invalid URL (url.Parse error from a malformed IPv6 literal) + // without scheme/host takes the relative-with-slash arm. + { + name: "malformed url with slash appends chat completions", + baseURL: "http://[::1", + want: "http://[::1/chat/completions", + }, + // Branch: url.Parse error (empty protocol scheme) with a slash. + { + name: "missing protocol scheme with slash appends chat completions", + baseURL: "://bad-scheme", + want: "://bad-scheme/chat/completions", + }, + // Branch: relative URL with a slash (no scheme/host) appends /chat/completions. + { + name: "relative path with slash appends chat completions", + baseURL: "foo/bar", + want: "foo/bar/chat/completions", + }, + // Branch: leading-slash relative URL still counts as having a slash. + { + name: "leading slash relative path appends chat completions", + baseURL: "/relative/path", + want: "/relative/path/chat/completions", + }, + // Branch: bare token with no slash and no scheme/host defaults to /v1. + { + name: "no slash token defaults to v1 chat completions", + baseURL: "localhost", + want: "localhost/v1/chat/completions", + }, + // Branch: host:port with no path parses without scheme/host (bare token), + // so it also takes the no-slash default arm. + { + name: "host port without scheme defaults to v1 chat completions", + baseURL: "localhost:8080", + want: "localhost:8080/v1/chat/completions", + }, + // Branch: parsed absolute URL with an empty path gets /v1/chat/completions. + { + name: "root host with empty path defaults to v1 chat completions", + baseURL: "https://my-local-llm:8080", + want: "https://my-local-llm:8080/v1/chat/completions", + }, + // Branch: parsed absolute URL with a non-empty path appends /chat/completions. + { + name: "host with versioned path appends chat completions", + baseURL: "https://api.openai.com/v1", + want: "https://api.openai.com/v1/chat/completions", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeOpenAICompatibleChatURL(tt.baseURL) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestNotableProviderForOpenRouterModel_BranchCov covers each branch of +// notableProviderForOpenRouterModel: the no-slash default, the empty-provider +// default, and the passthrough (with whitespace and case normalization). +func TestNotableProviderForOpenRouterModel_BranchCov(t *testing.T) { + tests := []struct { + name string + modelID string + want string + }{ + // Branch: no slash -> default to openai. + { + name: "bare model without provider defaults to openai", + modelID: "gpt-4", + want: "openai", + }, + // Branch: SplitN yields a single part for an identifier with no slash. + { + name: "deepseek bare model defaults to openai", + modelID: "deepseek-chat", + want: "openai", + }, + // Branch: leading slash yields an empty provider segment -> default. + { + name: "leading slash empty provider defaults to openai", + modelID: "/gpt-4", + want: "openai", + }, + // Branch: whitespace collapses to empty provider -> default. + { + name: "whitespace only defaults to openai", + modelID: " ", + want: "openai", + }, + // Branch: passthrough lowercases and trims the provider segment. + { + name: "provider segment lowercased and passed through", + modelID: "anthropic/claude-sonnet-4.5", + want: "anthropic", + }, + // Branch: passthrough preserves hyphenated multi-word providers. + { + name: "hyphenated provider passed through", + modelID: "meta-llama/llama-3.3-70b-instruct", + want: "meta-llama", + }, + // Branch: provider whitespace and mixed case normalized before passthrough. + { + name: "provider with surrounding whitespace and case normalized", + modelID: "Anthropic /claude-opus-4-5", + want: "anthropic", + }, + // Branch: openai provider passes through explicitly. + { + name: "openai provider passed through", + modelID: "openai/gpt-4o-mini", + want: "openai", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := notableProviderForOpenRouterModel(tt.modelID) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestNormalizeOpenAIStreamStopReason_BranchCov drives every branch of +// normalizeOpenAIStreamStopReason directly. The streaming integration tests +// reach the tool_use and end_turn arms indirectly, but no test exercises the +// passthrough arm for a non-empty, non-"stop" finish reason; this table covers +// all three arms with real return-value assertions. +func TestNormalizeOpenAIStreamStopReason_BranchCov(t *testing.T) { + tests := []struct { + name string + finishReason string + toolCalls []ToolCall + want string + }{ + // Branch: tool calls present -> tool_use regardless of finish reason. + { + name: "tool calls force tool_use over stop", + finishReason: "stop", + toolCalls: []ToolCall{ + {ID: "call_1", Name: "get_weather", Input: map[string]interface{}{"q": "NYC"}}, + }, + want: "tool_use", + }, + { + name: "tool calls force tool_use over tool_calls reason", + finishReason: "tool_calls", + toolCalls: []ToolCall{ + {ID: "call_2", Name: "search", Input: map[string]interface{}{}}, + }, + want: "tool_use", + }, + // Branch: empty finish reason with no tool calls -> end_turn. + { + name: "empty finish reason defaults to end_turn", + finishReason: "", + toolCalls: nil, + want: "end_turn", + }, + // Branch: literal "stop" finish reason -> end_turn. + { + name: "stop finish reason becomes end_turn", + finishReason: "stop", + toolCalls: nil, + want: "end_turn", + }, + // Branch: passthrough for a non-empty, non-"stop" reason (uncovered arm). + { + name: "length reason passes through", + finishReason: "length", + toolCalls: nil, + want: "length", + }, + // Branch: passthrough for an arbitrary provider-specific reason. + { + name: "content filter reason passes through", + finishReason: "content_filter", + toolCalls: nil, + want: "content_filter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeOpenAIStreamStopReason(tt.finishReason, tt.toolCalls) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/ai/providers/rate_limit_branchcov_test.go b/internal/ai/providers/rate_limit_branchcov_test.go new file mode 100644 index 000000000..7fcad5508 --- /dev/null +++ b/internal/ai/providers/rate_limit_branchcov_test.go @@ -0,0 +1,170 @@ +package providers + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRateLimitInfo exercises every branch of rateLimitInfo: the nil-response +// guard, the empty-value-slice skip, the non-rate-limit-header filter (all four +// substring arms), the empty-joined-value skip, the no-entries short circuit, +// the sort, and the maxEntries truncation boundary. +func TestRateLimitInfo(t *testing.T) { + tests := []struct { + name string + resp *http.Response + want string + }{ + { + name: "nil response returns empty string", + resp: nil, + want: "", + }, + { + name: "empty header map returns empty string", + resp: &http.Response{Header: http.Header{}}, + want: "", + }, + { + name: "non rate limit headers are skipped leaving no entries", + resp: &http.Response{Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Authorization": []string{"Bearer secret"}, + "X-Request-Id": []string{"abc"}, + }}, + want: "", + }, + { + name: "empty value slice is skipped but matching header survives", + resp: &http.Response{Header: http.Header{ + "X-RateLimit-Stale": []string{}, + "X-RateLimit-Remaining": []string{"100"}, + }}, + want: "rate_limit: x-ratelimit-remaining=100", + }, + { + name: "empty joined value is skipped but matching header survives", + resp: &http.Response{Header: http.Header{ + "X-RateLimit-Empty": []string{""}, + "X-RateLimit-Remaining": []string{"100"}, + }}, + want: "rate_limit: x-ratelimit-remaining=100", + }, + { + name: "all four substring filters match and entries are sorted", + resp: &http.Response{Header: http.Header{ + "X-RateLimit-Limit": []string{"60"}, + "X-Rate-Limit-Remaining": []string{"59"}, + "Retry-After": []string{"30"}, + "X-Quota-Remaining": []string{"1000"}, + }}, + want: "rate_limit: retry-after=30, x-quota-remaining=1000, x-rate-limit-remaining=59, x-ratelimit-limit=60", + }, + { + name: "multiple header values are joined with comma", + resp: &http.Response{Header: http.Header{ + "X-RateLimit-Reset": []string{"1", "2", "3"}, + }}, + want: "rate_limit: x-ratelimit-reset=1,2,3", + }, + { + name: "exactly maxEntries headers are not truncated", + resp: &http.Response{Header: http.Header{ + "X-RateLimit-A": []string{"1"}, + "X-RateLimit-B": []string{"2"}, + "X-RateLimit-C": []string{"3"}, + "X-RateLimit-D": []string{"4"}, + "X-RateLimit-E": []string{"5"}, + "X-RateLimit-F": []string{"6"}, + }}, + want: "rate_limit: x-ratelimit-a=1, x-ratelimit-b=2, x-ratelimit-c=3, x-ratelimit-d=4, x-ratelimit-e=5, x-ratelimit-f=6", + }, + { + name: "more than maxEntries headers are truncated to the sorted first six", + resp: &http.Response{Header: http.Header{ + "X-RateLimit-A": []string{"1"}, + "X-RateLimit-B": []string{"2"}, + "X-RateLimit-C": []string{"3"}, + "X-RateLimit-D": []string{"4"}, + "X-RateLimit-E": []string{"5"}, + "X-RateLimit-F": []string{"6"}, + "X-RateLimit-G": []string{"7"}, + "X-RateLimit-H": []string{"8"}, + }}, + want: "rate_limit: x-ratelimit-a=1, x-ratelimit-b=2, x-ratelimit-c=3, x-ratelimit-d=4, x-ratelimit-e=5, x-ratelimit-f=6", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rateLimitInfo(tt.resp) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestRateLimitInfo_TruncationDropsOverflow ensures the entries dropped by the +// maxEntries cap are genuinely removed from the output (the sorted tail), not +// just visually absent. +func TestRateLimitInfo_TruncationDropsOverflow(t *testing.T) { + resp := &http.Response{Header: http.Header{ + "X-RateLimit-A": []string{"1"}, + "X-RateLimit-B": []string{"2"}, + "X-RateLimit-C": []string{"3"}, + "X-RateLimit-D": []string{"4"}, + "X-RateLimit-E": []string{"5"}, + "X-RateLimit-F": []string{"6"}, + "X-RateLimit-G": []string{"7"}, + "X-RateLimit-H": []string{"8"}, + }} + got := rateLimitInfo(resp) + require.NotEmpty(t, got) + assert.NotContains(t, got, "x-ratelimit-g=7") + assert.NotContains(t, got, "x-ratelimit-h=8") +} + +// TestAppendRateLimitInfo covers the passthrough (empty info returns message +// untouched) and the append path (info is wrapped and concatenated). +func TestAppendRateLimitInfo(t *testing.T) { + tests := []struct { + name string + message string + resp *http.Response + want string + }{ + { + name: "nil response passes message through unchanged", + message: "upstream error", + resp: nil, + want: "upstream error", + }, + { + name: "response without rate limit headers passes message through", + message: "upstream error", + resp: &http.Response{Header: http.Header{"Content-Type": []string{"application/json"}}}, + want: "upstream error", + }, + { + name: "rate limit info is appended to message", + message: "429 too many requests", + resp: &http.Response{Header: http.Header{"Retry-After": []string{"30"}}}, + want: "429 too many requests (rate_limit: retry-after=30)", + }, + { + name: "empty message still receives appended info", + message: "", + resp: &http.Response{Header: http.Header{"X-RateLimit-Remaining": []string{"0"}}}, + want: " (rate_limit: x-ratelimit-remaining=0)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := appendRateLimitInfo(tt.message, tt.resp) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/ai/providers/tool_choice_branchcov_test.go b/internal/ai/providers/tool_choice_branchcov_test.go new file mode 100644 index 000000000..f69828ffa --- /dev/null +++ b/internal/ai/providers/tool_choice_branchcov_test.go @@ -0,0 +1,166 @@ +package providers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// toolChoiceAuto is a sentinel value that is neither ToolChoiceNone nor +// ToolChoiceRequired. It exercises the converters' default arm, which models +// the "let the model decide" / auto behavior Pulse leaves implicit. +const toolChoiceAuto ToolChoiceType = "auto" + +// TestConvertToolChoiceToOpenAI_Branches exercises every branch of +// convertToolChoiceToOpenAI: the nil guard, the ToolChoiceNone and +// ToolChoiceRequired switch arms, and the default/auto fallthrough. +func TestConvertToolChoiceToOpenAI_Branches(t *testing.T) { + tests := []struct { + name string + tc *ToolChoice + want interface{} + }{ + { + name: "nil tool choice omits the field", + tc: nil, + want: nil, + }, + { + name: "none serializes to the OpenAI literal string", + tc: &ToolChoice{Type: ToolChoiceNone}, + want: "none", + }, + { + name: "required serializes to the OpenAI literal string", + tc: &ToolChoice{Type: ToolChoiceRequired}, + want: "required", + }, + { + name: "auto falls through to nil so the request omits tool_choice", + tc: &ToolChoice{Type: toolChoiceAuto}, + want: nil, + }, + { + name: "empty type value is treated as the default arm", + tc: &ToolChoice{Type: ""}, + want: nil, + }, + { + name: "unknown type value is treated as the default arm", + tc: &ToolChoice{Type: ToolChoiceType("bogus")}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertToolChoiceToOpenAI(tt.tc) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestConvertToolChoiceToAnthropic_Branches exercises every branch of +// convertToolChoiceToAnthropic: the nil guard, the none/required switch arms, +// and the default/auto fallthrough. It asserts the concrete struct shape that +// gets serialized into the request body (Type "none" and Type "any"). +func TestConvertToolChoiceToAnthropic_Branches(t *testing.T) { + tests := []struct { + name string + tc *ToolChoice + want *anthropicToolChoice + }{ + { + name: "nil tool choice returns nil", + tc: nil, + want: nil, + }, + { + name: "none maps to Anthropic none mode", + tc: &ToolChoice{Type: ToolChoiceNone}, + want: &anthropicToolChoice{Type: "none"}, + }, + { + name: "required maps to Anthropic any mode", + tc: &ToolChoice{Type: ToolChoiceRequired}, + want: &anthropicToolChoice{Type: "any"}, + }, + { + name: "auto falls through to nil so tool_choice is omitted", + tc: &ToolChoice{Type: toolChoiceAuto}, + want: nil, + }, + { + name: "empty type value is treated as the default arm", + tc: &ToolChoice{Type: ""}, + want: nil, + }, + { + name: "unknown type value is treated as the default arm", + tc: &ToolChoice{Type: ToolChoiceType("bogus")}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertToolChoiceToAnthropic(tt.tc) + if tt.want == nil { + assert.Nil(t, got) + return + } + if assert.NotNil(t, got) { + assert.Equal(t, tt.want.Type, got.Type) + } + }) + } +} + +// TestConvertToolChoiceToGemini_Branches exercises every branch of +// convertToolChoiceToGemini: the nil guard, the NONE/ANY switch arms, and the +// default/auto fallthrough which returns an empty mode string. +func TestConvertToolChoiceToGemini_Branches(t *testing.T) { + tests := []struct { + name string + tc *ToolChoice + want string + }{ + { + name: "nil tool choice returns empty mode", + tc: nil, + want: "", + }, + { + name: "none maps to Gemini NONE mode", + tc: &ToolChoice{Type: ToolChoiceNone}, + want: "NONE", + }, + { + name: "required maps to Gemini ANY mode", + tc: &ToolChoice{Type: ToolChoiceRequired}, + want: "ANY", + }, + { + name: "auto falls through to empty mode so the config is omitted", + tc: &ToolChoice{Type: toolChoiceAuto}, + want: "", + }, + { + name: "empty type value is treated as the default arm", + tc: &ToolChoice{Type: ""}, + want: "", + }, + { + name: "unknown type value is treated as the default arm", + tc: &ToolChoice{Type: ToolChoiceType("bogus")}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertToolChoiceToGemini(tt.tc) + assert.Equal(t, tt.want, got) + }) + } +}