Skip to content

OpenAI Plugin Now Supports Web Search Functionality #1569

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 4, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ type Flags struct {
ListStrategies bool `long:"liststrategies" description:"List all strategies"`
ListVendors bool `long:"listvendors" description:"List all vendors"`
ShellCompleteOutput bool `long:"shell-complete-list" description:"Output raw list without headers/formatting (for shell completion)"`
Search bool `long:"search" description:"Enable web search tool for supported models (Anthropic)"`
Search bool `long:"search" description:"Enable web search tool for supported models (Anthropic, OpenAI)"`
SearchLocation string `long:"search-location" description:"Set location for web search results (e.g., 'America/Los_Angeles')"`
}

Expand Down
44 changes: 43 additions & 1 deletion plugins/ai/openai/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package openai

import (
"context"
"fmt"
"slices"
"strings"

Expand Down Expand Up @@ -182,6 +183,21 @@ func (o *Client) buildResponseParams(
},
}

// Add web search tool if enabled
if opts.Search {
webSearchTool := responses.ToolParamOfWebSearchPreview("web_search_preview")

// Add user location if provided
if opts.SearchLocation != "" {
webSearchTool.OfWebSearchPreview.UserLocation = responses.WebSearchToolUserLocationParam{
Type: "approximate",
Timezone: openai.String(opts.SearchLocation),
}
}

ret.Tools = []responses.ToolUnionParam{webSearchTool}
}

if !opts.Raw {
ret.Temperature = openai.Float(opts.Temperature)
ret.TopP = openai.Float(opts.TopP)
Expand Down Expand Up @@ -232,15 +248,41 @@ func convertMessage(msg chat.ChatCompletionMessage) responses.ResponseInputItemU
}

func (o *Client) extractText(resp *responses.Response) (ret string) {
var textParts []string
var citations []string
citationMap := make(map[string]bool) // To avoid duplicate citations

for _, item := range resp.Output {
if item.Type == "message" {
for _, c := range item.Content {
if c.Type == "output_text" {
ret += c.AsOutputText().Text
outputText := c.AsOutputText()
textParts = append(textParts, outputText.Text)

// Extract citations from annotations
for _, annotation := range outputText.Annotations {
if annotation.Type == "url_citation" {
urlCitation := annotation.AsURLCitation()
citationKey := urlCitation.URL + "|" + urlCitation.Title
if !citationMap[citationKey] {
citationMap[citationKey] = true
citationText := fmt.Sprintf("- [%s](%s)", urlCitation.Title, urlCitation.URL)
citations = append(citations, citationText)
}
}
}
}
}
break
}
}

ret = strings.Join(textParts, "")

// Append citations if any were found
if len(citations) > 0 {
ret += "\n\n## Sources\n\n" + strings.Join(citations, "\n")
}

return
}
115 changes: 115 additions & 0 deletions plugins/ai/openai/openai_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package openai

import (
"strings"
"testing"

"github.com/danielmiessler/fabric/chat"
"github.com/danielmiessler/fabric/common"
openai "github.com/openai/openai-go"
"github.com/openai/openai-go/responses"
"github.com/openai/openai-go/shared"
"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -60,3 +62,116 @@ func TestBuildResponseRequestNoMaxTokens(t *testing.T) {
assert.Equal(t, openai.Float(opts.TopP), request.TopP)
assert.False(t, request.MaxOutputTokens.Valid())
}

func TestBuildResponseParams_WithoutSearch(t *testing.T) {
client := NewClient()
opts := &common.ChatOptions{
Model: "gpt-4o",
Temperature: 0.7,
Search: false,
}

msgs := []*chat.ChatCompletionMessage{
{Role: "user", Content: "Hello"},
}

params := client.buildResponseParams(msgs, opts)

assert.Nil(t, params.Tools, "Expected no tools when search is disabled")
assert.Equal(t, shared.ResponsesModel(opts.Model), params.Model)
assert.Equal(t, openai.Float(opts.Temperature), params.Temperature)
}

func TestBuildResponseParams_WithSearch(t *testing.T) {
client := NewClient()
opts := &common.ChatOptions{
Model: "gpt-4o",
Temperature: 0.7,
Search: true,
}

msgs := []*chat.ChatCompletionMessage{
{Role: "user", Content: "What's the weather today?"},
}

params := client.buildResponseParams(msgs, opts)

assert.NotNil(t, params.Tools, "Expected tools when search is enabled")
assert.Len(t, params.Tools, 1, "Expected exactly one tool")

tool := params.Tools[0]
assert.NotNil(t, tool.OfWebSearchPreview, "Expected web search tool")
assert.Equal(t, responses.WebSearchToolType("web_search_preview"), tool.OfWebSearchPreview.Type)
}

func TestBuildResponseParams_WithSearchAndLocation(t *testing.T) {
client := NewClient()
opts := &common.ChatOptions{
Model: "gpt-4o",
Temperature: 0.7,
Search: true,
SearchLocation: "America/Los_Angeles",
}

msgs := []*chat.ChatCompletionMessage{
{Role: "user", Content: "What's the weather in San Francisco?"},
}

params := client.buildResponseParams(msgs, opts)

assert.NotNil(t, params.Tools, "Expected tools when search is enabled")
tool := params.Tools[0]
assert.NotNil(t, tool.OfWebSearchPreview, "Expected web search tool")

userLocation := tool.OfWebSearchPreview.UserLocation
assert.Equal(t, "approximate", string(userLocation.Type))
assert.True(t, userLocation.Timezone.Valid(), "Expected timezone to be set")
assert.Equal(t, opts.SearchLocation, userLocation.Timezone.Value)
}

func TestCitationFormatting(t *testing.T) {
// Test the citation formatting logic by simulating the citation extraction
var textParts []string
var citations []string
citationMap := make(map[string]bool)

// Simulate text content
textParts = append(textParts, "Based on recent research, artificial intelligence is advancing rapidly.")

// Simulate citations (as they would be extracted from OpenAI response)
mockCitations := []struct {
URL string
Title string
}{
{"https://example.com/ai-research", "AI Research Advances 2025"},
{"https://another-source.com/tech-news", "Technology News Today"},
{"https://example.com/ai-research", "AI Research Advances 2025"}, // Duplicate to test deduplication
}

for _, citation := range mockCitations {
citationKey := citation.URL + "|" + citation.Title
if !citationMap[citationKey] {
citationMap[citationKey] = true
citationText := "- [" + citation.Title + "](" + citation.URL + ")"
citations = append(citations, citationText)
}
}

result := strings.Join(textParts, "")
if len(citations) > 0 {
result += "\n\n## Sources\n\n" + strings.Join(citations, "\n")
}

// Verify the result contains the expected text
expectedText := "Based on recent research, artificial intelligence is advancing rapidly."
assert.Contains(t, result, expectedText, "Expected result to contain original text")

// Verify citations are included
assert.Contains(t, result, "## Sources", "Expected result to contain Sources section")
assert.Contains(t, result, "[AI Research Advances 2025](https://example.com/ai-research)", "Expected result to contain first citation")
assert.Contains(t, result, "[Technology News Today](https://another-source.com/tech-news)", "Expected result to contain second citation")

// Verify deduplication - should only have 2 unique citations, not 3
citationCount := strings.Count(result, "- [")
assert.Equal(t, 2, citationCount, "Expected 2 unique citations")
}
Loading