mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user