tests: add coverage for model-native search
This commit is contained in:
parent
89ddf8e28d
commit
a4d2dfb17c
3 changed files with 340 additions and 0 deletions
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue