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.
This commit is contained in:
rcourtman
2026-07-15 15:08:59 +01:00
parent c775eed739
commit b6ed899ad5
4 changed files with 809 additions and 0 deletions
@@ -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)
}
}
}
@@ -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)
})
}
}
@@ -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)
})
}
}
@@ -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)
})
}
}