tests: add coverage for model-native search

This commit is contained in:
dataCenter430 2026-03-15 17:20:59 +01:00
parent 89ddf8e28d
commit a4d2dfb17c
3 changed files with 340 additions and 0 deletions

View file

@ -1318,3 +1318,78 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) {
t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content)
} }
} }
// --- Native search helper tests ---
type nativeSearchProvider struct {
supported bool
}
func (p *nativeSearchProvider) Chat(ctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any) (*providers.LLMResponse, error) {
return &providers.LLMResponse{Content: "ok"}, nil
}
func (p *nativeSearchProvider) GetDefaultModel() string { return "test-model" }
func (p *nativeSearchProvider) SupportsNativeSearch() bool { return p.supported }
type plainProvider struct{}
func (p *plainProvider) Chat(ctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any) (*providers.LLMResponse, error) {
return &providers.LLMResponse{Content: "ok"}, nil
}
func (p *plainProvider) GetDefaultModel() string { return "test-model" }
func TestIsNativeSearchProvider_Supported(t *testing.T) {
if !isNativeSearchProvider(&nativeSearchProvider{supported: true}) {
t.Fatal("expected true for provider that supports native search")
}
}
func TestIsNativeSearchProvider_NotSupported(t *testing.T) {
if isNativeSearchProvider(&nativeSearchProvider{supported: false}) {
t.Fatal("expected false for provider that does not support native search")
}
}
func TestIsNativeSearchProvider_NoInterface(t *testing.T) {
if isNativeSearchProvider(&plainProvider{}) {
t.Fatal("expected false for provider that does not implement NativeSearchCapable")
}
}
func TestFilterClientWebSearch_RemovesWebSearch(t *testing.T) {
defs := []providers.ToolDefinition{
{Type: "function", Function: providers.ToolFunctionDefinition{Name: "web_search"}},
{Type: "function", Function: providers.ToolFunctionDefinition{Name: "read_file"}},
{Type: "function", Function: providers.ToolFunctionDefinition{Name: "exec"}},
}
result := filterClientWebSearch(defs)
if len(result) != 2 {
t.Fatalf("len(result) = %d, want 2", len(result))
}
for _, td := range result {
if td.Function.Name == "web_search" {
t.Fatal("web_search should be filtered out")
}
}
}
func TestFilterClientWebSearch_NoWebSearch(t *testing.T) {
defs := []providers.ToolDefinition{
{Type: "function", Function: providers.ToolFunctionDefinition{Name: "read_file"}},
{Type: "function", Function: providers.ToolFunctionDefinition{Name: "exec"}},
}
result := filterClientWebSearch(defs)
if len(result) != 2 {
t.Fatalf("len(result) = %d, want 2", len(result))
}
}
func TestFilterClientWebSearch_EmptyInput(t *testing.T) {
result := filterClientWebSearch(nil)
if len(result) != 0 {
t.Fatalf("len(result) = %d, want 0", len(result))
}
}

View file

@ -384,6 +384,45 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
} }
} }
func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Tools.Web.PreferNative {
t.Fatal("DefaultConfig().Tools.Web.PreferNative should be true")
}
}
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"enabled":true}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if !cfg.Tools.Web.PreferNative {
t.Fatal("PreferNative should remain true when unset in config file")
}
}
func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"prefer_native":false}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Tools.Web.PreferNative {
t.Fatal("PreferNative should be false when disabled in config file")
}
}
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
if !cfg.Tools.Exec.AllowRemote { if !cfg.Tools.Exec.AllowRemote {

View file

@ -824,6 +824,232 @@ func TestSupportsPromptCacheKey(t *testing.T) {
} }
} }
func TestBuildToolsList_NativeSearchAddsWebSearchPreview(t *testing.T) {
tools := []ToolDefinition{
{Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}},
}
result := buildToolsList(tools, true)
if len(result) != 2 {
t.Fatalf("len(result) = %d, want 2", len(result))
}
wsEntry, ok := result[1].(map[string]any)
if !ok {
t.Fatalf("web search entry is %T, want map[string]any", result[1])
}
if wsEntry["type"] != "web_search_preview" {
t.Fatalf("type = %v, want web_search_preview", wsEntry["type"])
}
}
func TestBuildToolsList_NativeSearchFiltersClientWebSearch(t *testing.T) {
tools := []ToolDefinition{
{Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}},
{Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}},
}
result := buildToolsList(tools, true)
for _, entry := range result {
if td, ok := entry.(ToolDefinition); ok && strings.EqualFold(td.Function.Name, "web_search") {
t.Fatal("client-side web_search should be filtered out when native search is enabled")
}
}
if len(result) != 2 { // read_file + web_search_preview
t.Fatalf("len(result) = %d, want 2 (read_file + web_search_preview)", len(result))
}
}
func TestBuildToolsList_NoNativeSearchPassesThrough(t *testing.T) {
tools := []ToolDefinition{
{Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}},
{Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}},
}
result := buildToolsList(tools, false)
if len(result) != 2 {
t.Fatalf("len(result) = %d, want 2", len(result))
}
}
func TestIsNativeSearchHost(t *testing.T) {
tests := []struct {
apiBase string
want bool
}{
{"https://api.openai.com/v1", true},
{"https://myresource.openai.azure.com/openai/deployments/gpt-4", true},
{"https://api.mistral.ai/v1", false},
{"https://api.deepseek.com/v1", false},
{"https://api.groq.com/openai/v1", false},
{"http://localhost:11434/v1", false},
{"", false},
}
for _, tt := range tests {
if got := isNativeSearchHost(tt.apiBase); got != tt.want {
t.Errorf("isNativeSearchHost(%q) = %v, want %v", tt.apiBase, got, tt.want)
}
}
}
func TestSupportsNativeSearch_OpenAI(t *testing.T) {
p := NewProvider("key", "https://api.openai.com/v1", "")
if !p.SupportsNativeSearch() {
t.Fatal("OpenAI provider should support native search")
}
}
func TestSupportsNativeSearch_NonOpenAI(t *testing.T) {
p := NewProvider("key", "https://api.deepseek.com/v1", "")
if p.SupportsNativeSearch() {
t.Fatal("DeepSeek provider should not support native search")
}
}
func TestProviderChat_NativeSearchToolInjected(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
p.apiBase = "https://api.openai.com/v1"
p.httpClient = &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
r.URL, _ = url.Parse(server.URL + r.URL.Path)
return http.DefaultTransport.RoundTrip(r)
}),
}
tools := []ToolDefinition{
{Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}},
}
_, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
tools,
"gpt-5.4",
map[string]any{"native_search": true},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
toolsRaw, ok := requestBody["tools"].([]any)
if !ok {
t.Fatalf("tools is %T, want []any", requestBody["tools"])
}
if len(toolsRaw) != 2 {
t.Fatalf("len(tools) = %d, want 2 (read_file + web_search_preview)", len(toolsRaw))
}
lastTool, ok := toolsRaw[1].(map[string]any)
if !ok {
t.Fatalf("last tool is %T, want map[string]any", toolsRaw[1])
}
if lastTool["type"] != "web_search_preview" {
t.Fatalf("last tool type = %v, want web_search_preview", lastTool["type"])
}
}
func TestProviderChat_NativeSearchNotInjectedWithoutOption(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
tools := []ToolDefinition{
{Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}},
}
_, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
tools,
"gpt-5.4",
map[string]any{},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
toolsRaw, ok := requestBody["tools"].([]any)
if !ok {
t.Fatalf("tools is %T, want []any", requestBody["tools"])
}
if len(toolsRaw) != 1 {
t.Fatalf("len(tools) = %d, want 1 (web_search only)", len(toolsRaw))
}
}
// TestProviderChat_NativeSearchIgnoredOnNonOpenAI verifies that when native_search
// is true in options but the provider's apiBase is not OpenAI (e.g. fallback to DeepSeek),
// we do not inject web_search_preview to avoid API errors.
func TestProviderChat_NativeSearchIgnoredOnNonOpenAI(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
// Use server.URL so host is not api.openai.com — simulates DeepSeek/other provider
p := NewProvider("key", server.URL, "")
_, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
nil,
"deepseek-chat",
map[string]any{"native_search": true},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
// Should not have tools at all (no tools passed, and we must not add web_search_preview)
if toolsRaw, ok := requestBody["tools"]; ok {
t.Fatalf("tools should be omitted for non-OpenAI when only native_search was requested, got %v", toolsRaw)
}
}
func TestSerializeMessages_StripsSystemParts(t *testing.T) { func TestSerializeMessages_StripsSystemParts(t *testing.T) {
messages := []protocoltypes.Message{ messages := []protocoltypes.Message{
{ {