Skip to content

Commit fc8c4ba

Browse files
authored
Merge pull request #1569 from ksylvan/0703-openai-web-search
OpenAI Plugin Now Supports Web Search Functionality
2 parents f927fdf + bd809a1 commit fc8c4ba

File tree

4 files changed

+167
-2
lines changed

4 files changed

+167
-2
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ Keep in mind that many of these were recorded when Fabric was Python-based, so r
114114

115115
> [!NOTE]
116116
>
117+
> July 4, 2025
118+
>
119+
> - Fabric now supports web search using the `--search` and `--search-location` flags
120+
> - Web search is available for both Anthropic and OpenAI providers
121+
> - Previous plugin-level search configurations have been removed in favor of the new flag-based approach.
122+
> - If you used the previous approach, consider cleaning up your `~/.config/fabric/.env` file, removing the unused `ANTHROPIC_WEB_SEARCH_TOOL_ENABLED` and `ANTHROPIC_WEB_SEARCH_TOOL_LOCATION` variables.
123+
>
124+
>
117125
>June 17, 2025
118126
>
119127
>- Fabric now supports Perplexity AI. Configure it by using `fabric -S` to add your Perplexity AI API Key,

cli/flags.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ type Flags struct {
7474
ListStrategies bool `long:"liststrategies" description:"List all strategies"`
7575
ListVendors bool `long:"listvendors" description:"List all vendors"`
7676
ShellCompleteOutput bool `long:"shell-complete-list" description:"Output raw list without headers/formatting (for shell completion)"`
77-
Search bool `long:"search" description:"Enable web search tool for supported models (Anthropic)"`
77+
Search bool `long:"search" description:"Enable web search tool for supported models (Anthropic, OpenAI)"`
7878
SearchLocation string `long:"search-location" description:"Set location for web search results (e.g., 'America/Los_Angeles')"`
7979
}
8080

plugins/ai/openai/openai.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package openai
22

33
import (
44
"context"
5+
"fmt"
56
"slices"
67
"strings"
78

@@ -182,6 +183,21 @@ func (o *Client) buildResponseParams(
182183
},
183184
}
184185

186+
// Add web search tool if enabled
187+
if opts.Search {
188+
webSearchTool := responses.ToolParamOfWebSearchPreview("web_search_preview")
189+
190+
// Add user location if provided
191+
if opts.SearchLocation != "" {
192+
webSearchTool.OfWebSearchPreview.UserLocation = responses.WebSearchToolUserLocationParam{
193+
Type: "approximate",
194+
Timezone: openai.String(opts.SearchLocation),
195+
}
196+
}
197+
198+
ret.Tools = []responses.ToolUnionParam{webSearchTool}
199+
}
200+
185201
if !opts.Raw {
186202
ret.Temperature = openai.Float(opts.Temperature)
187203
ret.TopP = openai.Float(opts.TopP)
@@ -232,15 +248,41 @@ func convertMessage(msg chat.ChatCompletionMessage) responses.ResponseInputItemU
232248
}
233249

234250
func (o *Client) extractText(resp *responses.Response) (ret string) {
251+
var textParts []string
252+
var citations []string
253+
citationMap := make(map[string]bool) // To avoid duplicate citations
254+
235255
for _, item := range resp.Output {
236256
if item.Type == "message" {
237257
for _, c := range item.Content {
238258
if c.Type == "output_text" {
239-
ret += c.AsOutputText().Text
259+
outputText := c.AsOutputText()
260+
textParts = append(textParts, outputText.Text)
261+
262+
// Extract citations from annotations
263+
for _, annotation := range outputText.Annotations {
264+
if annotation.Type == "url_citation" {
265+
urlCitation := annotation.AsURLCitation()
266+
citationKey := urlCitation.URL + "|" + urlCitation.Title
267+
if !citationMap[citationKey] {
268+
citationMap[citationKey] = true
269+
citationText := fmt.Sprintf("- [%s](%s)", urlCitation.Title, urlCitation.URL)
270+
citations = append(citations, citationText)
271+
}
272+
}
273+
}
240274
}
241275
}
242276
break
243277
}
244278
}
279+
280+
ret = strings.Join(textParts, "")
281+
282+
// Append citations if any were found
283+
if len(citations) > 0 {
284+
ret += "\n\n## Sources\n\n" + strings.Join(citations, "\n")
285+
}
286+
245287
return
246288
}

plugins/ai/openai/openai_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package openai
22

33
import (
4+
"strings"
45
"testing"
56

67
"github.com/danielmiessler/fabric/chat"
78
"github.com/danielmiessler/fabric/common"
89
openai "github.com/openai/openai-go"
10+
"github.com/openai/openai-go/responses"
911
"github.com/openai/openai-go/shared"
1012
"github.com/stretchr/testify/assert"
1113
)
@@ -60,3 +62,116 @@ func TestBuildResponseRequestNoMaxTokens(t *testing.T) {
6062
assert.Equal(t, openai.Float(opts.TopP), request.TopP)
6163
assert.False(t, request.MaxOutputTokens.Valid())
6264
}
65+
66+
func TestBuildResponseParams_WithoutSearch(t *testing.T) {
67+
client := NewClient()
68+
opts := &common.ChatOptions{
69+
Model: "gpt-4o",
70+
Temperature: 0.7,
71+
Search: false,
72+
}
73+
74+
msgs := []*chat.ChatCompletionMessage{
75+
{Role: "user", Content: "Hello"},
76+
}
77+
78+
params := client.buildResponseParams(msgs, opts)
79+
80+
assert.Nil(t, params.Tools, "Expected no tools when search is disabled")
81+
assert.Equal(t, shared.ResponsesModel(opts.Model), params.Model)
82+
assert.Equal(t, openai.Float(opts.Temperature), params.Temperature)
83+
}
84+
85+
func TestBuildResponseParams_WithSearch(t *testing.T) {
86+
client := NewClient()
87+
opts := &common.ChatOptions{
88+
Model: "gpt-4o",
89+
Temperature: 0.7,
90+
Search: true,
91+
}
92+
93+
msgs := []*chat.ChatCompletionMessage{
94+
{Role: "user", Content: "What's the weather today?"},
95+
}
96+
97+
params := client.buildResponseParams(msgs, opts)
98+
99+
assert.NotNil(t, params.Tools, "Expected tools when search is enabled")
100+
assert.Len(t, params.Tools, 1, "Expected exactly one tool")
101+
102+
tool := params.Tools[0]
103+
assert.NotNil(t, tool.OfWebSearchPreview, "Expected web search tool")
104+
assert.Equal(t, responses.WebSearchToolType("web_search_preview"), tool.OfWebSearchPreview.Type)
105+
}
106+
107+
func TestBuildResponseParams_WithSearchAndLocation(t *testing.T) {
108+
client := NewClient()
109+
opts := &common.ChatOptions{
110+
Model: "gpt-4o",
111+
Temperature: 0.7,
112+
Search: true,
113+
SearchLocation: "America/Los_Angeles",
114+
}
115+
116+
msgs := []*chat.ChatCompletionMessage{
117+
{Role: "user", Content: "What's the weather in San Francisco?"},
118+
}
119+
120+
params := client.buildResponseParams(msgs, opts)
121+
122+
assert.NotNil(t, params.Tools, "Expected tools when search is enabled")
123+
tool := params.Tools[0]
124+
assert.NotNil(t, tool.OfWebSearchPreview, "Expected web search tool")
125+
126+
userLocation := tool.OfWebSearchPreview.UserLocation
127+
assert.Equal(t, "approximate", string(userLocation.Type))
128+
assert.True(t, userLocation.Timezone.Valid(), "Expected timezone to be set")
129+
assert.Equal(t, opts.SearchLocation, userLocation.Timezone.Value)
130+
}
131+
132+
func TestCitationFormatting(t *testing.T) {
133+
// Test the citation formatting logic by simulating the citation extraction
134+
var textParts []string
135+
var citations []string
136+
citationMap := make(map[string]bool)
137+
138+
// Simulate text content
139+
textParts = append(textParts, "Based on recent research, artificial intelligence is advancing rapidly.")
140+
141+
// Simulate citations (as they would be extracted from OpenAI response)
142+
mockCitations := []struct {
143+
URL string
144+
Title string
145+
}{
146+
{"https://example.com/ai-research", "AI Research Advances 2025"},
147+
{"https://another-source.com/tech-news", "Technology News Today"},
148+
{"https://example.com/ai-research", "AI Research Advances 2025"}, // Duplicate to test deduplication
149+
}
150+
151+
for _, citation := range mockCitations {
152+
citationKey := citation.URL + "|" + citation.Title
153+
if !citationMap[citationKey] {
154+
citationMap[citationKey] = true
155+
citationText := "- [" + citation.Title + "](" + citation.URL + ")"
156+
citations = append(citations, citationText)
157+
}
158+
}
159+
160+
result := strings.Join(textParts, "")
161+
if len(citations) > 0 {
162+
result += "\n\n## Sources\n\n" + strings.Join(citations, "\n")
163+
}
164+
165+
// Verify the result contains the expected text
166+
expectedText := "Based on recent research, artificial intelligence is advancing rapidly."
167+
assert.Contains(t, result, expectedText, "Expected result to contain original text")
168+
169+
// Verify citations are included
170+
assert.Contains(t, result, "## Sources", "Expected result to contain Sources section")
171+
assert.Contains(t, result, "[AI Research Advances 2025](https://example.com/ai-research)", "Expected result to contain first citation")
172+
assert.Contains(t, result, "[Technology News Today](https://another-source.com/tech-news)", "Expected result to contain second citation")
173+
174+
// Verify deduplication - should only have 2 unique citations, not 3
175+
citationCount := strings.Count(result, "- [")
176+
assert.Equal(t, 2, citationCount, "Expected 2 unique citations")
177+
}

0 commit comments

Comments
 (0)