Detailed changes
@@ -24,24 +24,24 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
github.com/ardanlabs/kronk v1.20.8 // indirect
- github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
- github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
- github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 // indirect
- github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
@@ -93,7 +93,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nikolalohinski/gonja/v2 v2.7.0 // indirect
- github.com/openai/openai-go/v2 v2.7.1 // indirect
+ github.com/openai/openai-go/v3 v3.28.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
@@ -131,7 +131,7 @@ require (
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.269.0 // indirect
- google.golang.org/genai v1.49.0 // indirect
+ google.golang.org/genai v1.50.0 // indirect
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
@@ -32,42 +32,42 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/ardanlabs/kronk v1.20.8 h1:mwmi8extRNcDH4YbvEx+CaWuBGpedIaTtJn5J6M3JSM=
github.com/ardanlabs/kronk v1.20.8/go.mod h1:aQPdkfQzTi6eWpqXlpc0GCrRt3e96vOdl/f+7xiJAgM=
-github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
-github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
+github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
+github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
-github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
-github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
+github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
+github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd+je7Ug7TkvAzlmkU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 h1:fJvQ5mIBVfKtiyx0AHY6HeWcRX5LGANLpq8SVR+Uazs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 h1:M1A9AjcFwlxTLuf0Faj88L8Iqw0n/AJHjpZTQzMMsSc=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2/go.mod h1:KsdTV6Q9WKUZm2mNJnUFmIoXfZux91M3sr/a4REX8e0=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -209,8 +209,8 @@ github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
-github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
-github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zpUTZhz3GElQE=
+github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHPQ63KM=
+github.com/openai/openai-go/v3 v3.28.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -309,8 +309,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
-google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
-google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/genai v1.50.0 h1:yHKV/vjoeN9PJ3iF0ur4cBZco4N3Kl7j09rMq7XSoWk=
+google.golang.org/genai v1.50.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 h1:RxhCsti413yL0IjU9dVvuTbCISo8gs3RW1jPMStck+4=
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171/go.mod h1:uhvzakVEqAuXU3TC2JCsxIRe5f77l+JySE3EqPoMyqM=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
@@ -0,0 +1,78 @@
+package main
+
+// This example demonstrates provider-defined web search tools.
+// It auto-selects the provider based on which API key is set:
+//
+// ANTHROPIC_API_KEY → Anthropic (Claude)
+// OPENAI_API_KEY → OpenAI (GPT, Responses API)
+//
+// Provider tools are executed server-side by the model provider,
+// so there is no local tool implementation needed.
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "charm.land/fantasy"
+ "charm.land/fantasy/providers/anthropic"
+ "charm.land/fantasy/providers/openai"
+)
+
+func main() {
+ ctx := context.Background()
+
+ var (
+ model fantasy.LanguageModel
+ webSearch fantasy.ProviderDefinedTool
+ err error
+ )
+
+ switch {
+ case os.Getenv("ANTHROPIC_API_KEY") != "":
+ p, _ := anthropic.New(anthropic.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")))
+ model, err = p.LanguageModel(ctx, "claude-sonnet-4-20250514")
+ webSearch = anthropic.WebSearchTool(nil)
+
+ case os.Getenv("OPENAI_API_KEY") != "":
+ p, _ := openai.New(
+ openai.WithAPIKey(os.Getenv("OPENAI_API_KEY")),
+ openai.WithUseResponsesAPI(),
+ )
+ model, err = p.LanguageModel(ctx, "gpt-5.4")
+ webSearch = openai.WebSearchTool(nil)
+
+ default:
+ fmt.Fprintln(os.Stderr, "Set ANTHROPIC_API_KEY or OPENAI_API_KEY")
+ os.Exit(1)
+ }
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ agent := fantasy.NewAgent(model,
+ fantasy.WithProviderDefinedTools(webSearch),
+ )
+
+ result, err := agent.Generate(ctx, fantasy.AgentCall{
+ Prompt: "What is the population of Tokyo? Cite your source.",
+ })
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ var text strings.Builder
+ for _, c := range result.Response.Content {
+ if tc, ok := c.(fantasy.TextContent); ok {
+ text.WriteString(tc.Text)
+ }
+ }
+ fmt.Println(text.String())
+
+ for _, source := range result.Response.Content.Sources() {
+ fmt.Printf("Source: %s — %s\n", source.Title, source.URL)
+ }
+}
@@ -794,11 +794,12 @@ func TestGenerate_WebSearchResponse(t *testing.T) {
model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
require.NoError(t, err)
- resp, err := model.Generate(context.Background(), fantasy.Call{
- Prompt: testPrompt(),
- Tools: []fantasy.Tool{
- WebSearchTool(nil),
- }, })
+ resp, err := model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt(),
+ Tools: []fantasy.Tool{
+ WebSearchTool(nil),
+ },
+ })
require.NoError(t, err)
call := awaitAnthropicCall(t, calls)
@@ -1264,11 +1265,12 @@ func TestStream_WebSearchResponse(t *testing.T) {
model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514")
require.NoError(t, err)
- stream, err := model.Stream(context.Background(), fantasy.Call{
- Prompt: testPrompt(),
- Tools: []fantasy.Tool{
- WebSearchTool(nil),
- }, })
+ stream, err := model.Stream(context.Background(), fantasy.Call{
+ Prompt: testPrompt(),
+ Tools: []fantasy.Tool{
+ WebSearchTool(nil),
+ },
+ })
require.NoError(t, err)
var parts []fantasy.StreamPart
@@ -3447,3 +3447,361 @@ func TestUserAgent(t *testing.T) {
assert.Equal(t, "provider-ua", server.calls[0].headers["User-Agent"])
})
}
+
+// --- OpenAI Responses API Web Search Tests ---
+
+// mockResponsesWebSearchResponse returns a Responses API response
+// containing a web_search_call output item followed by a message
+// with url_citation annotations.
+func mockResponsesWebSearchResponse() map[string]any {
+ return map[string]any{
+ "id": "resp_01WebSearch",
+ "object": "response",
+ "model": "gpt-4.1",
+ "output": []any{
+ map[string]any{
+ "type": "web_search_call",
+ "id": "ws_01",
+ "status": "completed",
+ "action": map[string]any{
+ "type": "search",
+ "query": "latest AI news",
+ },
+ },
+ map[string]any{
+ "type": "message",
+ "id": "msg_01",
+ "role": "assistant",
+ "status": "completed",
+ "content": []any{
+ map[string]any{
+ "type": "output_text",
+ "text": "Based on recent search results, here is the latest AI news.",
+ "annotations": []any{
+ map[string]any{
+ "type": "url_citation",
+ "url": "https://example.com/ai-news",
+ "title": "Latest AI News",
+ "start_index": 0,
+ "end_index": 50,
+ },
+ map[string]any{
+ "type": "url_citation",
+ "url": "https://example.com/ml-update",
+ "title": "ML Update",
+ "start_index": 51,
+ "end_index": 60,
+ },
+ },
+ },
+ },
+ },
+ },
+ "status": "completed",
+ "usage": map[string]any{
+ "input_tokens": 100,
+ "output_tokens": 50,
+ "total_tokens": 150,
+ },
+ }
+}
+
+func newResponsesProvider(t *testing.T, serverURL string) fantasy.LanguageModel {
+ t.Helper()
+ provider, err := New(
+ WithAPIKey("test-api-key"),
+ WithBaseURL(serverURL),
+ WithUseResponsesAPI(),
+ )
+ require.NoError(t, err)
+ model, err := provider.LanguageModel(context.Background(), "gpt-4.1")
+ require.NoError(t, err)
+ return model
+}
+
+func TestResponsesGenerate_WebSearchResponse(t *testing.T) {
+ t.Parallel()
+
+ server := newMockServer()
+ defer server.close()
+ server.response = mockResponsesWebSearchResponse()
+
+ model := newResponsesProvider(t, server.server.URL)
+
+ resp, err := model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt,
+ Tools: []fantasy.Tool{WebSearchTool(nil)},
+ })
+ require.NoError(t, err)
+
+ require.Equal(t, "POST", server.calls[0].method)
+ require.Equal(t, "/responses", server.calls[0].path)
+
+ var (
+ toolCalls []fantasy.ToolCallContent
+ sources []fantasy.SourceContent
+ toolResults []fantasy.ToolResultContent
+ texts []fantasy.TextContent
+ )
+ for _, c := range resp.Content {
+ switch v := c.(type) {
+ case fantasy.ToolCallContent:
+ toolCalls = append(toolCalls, v)
+ case fantasy.SourceContent:
+ sources = append(sources, v)
+ case fantasy.ToolResultContent:
+ toolResults = append(toolResults, v)
+ case fantasy.TextContent:
+ texts = append(texts, v)
+ }
+ }
+
+ // ToolCallContent for the provider-executed web_search.
+ require.Len(t, toolCalls, 1)
+ require.True(t, toolCalls[0].ProviderExecuted)
+ require.Equal(t, "web_search", toolCalls[0].ToolName)
+ require.Equal(t, "ws_01", toolCalls[0].ToolCallID)
+
+ // SourceContent entries from url_citation annotations.
+ require.Len(t, sources, 2)
+ require.Equal(t, "https://example.com/ai-news", sources[0].URL)
+ require.Equal(t, "Latest AI News", sources[0].Title)
+ require.Equal(t, fantasy.SourceTypeURL, sources[0].SourceType)
+ require.Equal(t, "https://example.com/ml-update", sources[1].URL)
+ require.Equal(t, "ML Update", sources[1].Title)
+
+ // ToolResultContent with provider metadata.
+ require.Len(t, toolResults, 1)
+ require.True(t, toolResults[0].ProviderExecuted)
+ require.Equal(t, "web_search", toolResults[0].ToolName)
+ require.Equal(t, "ws_01", toolResults[0].ToolCallID)
+
+ metaVal, ok := toolResults[0].ProviderMetadata[Name]
+ require.True(t, ok, "providerMetadata should contain openai key")
+ wsMeta, ok := metaVal.(*WebSearchCallMetadata)
+ require.True(t, ok, "metadata should be *WebSearchCallMetadata")
+ require.Equal(t, "ws_01", wsMeta.ItemID)
+ require.NotNil(t, wsMeta.Action)
+ require.Equal(t, "search", wsMeta.Action.Type)
+ require.Equal(t, "latest AI news", wsMeta.Action.Query)
+
+ // TextContent with the final answer.
+ require.Len(t, texts, 1)
+ require.Equal(t,
+ "Based on recent search results, here is the latest AI news.",
+ texts[0].Text,
+ )
+}
+
+func TestResponsesGenerate_WebSearchToolInRequest(t *testing.T) {
+ t.Parallel()
+
+ t.Run("basic web_search tool", func(t *testing.T) {
+ t.Parallel()
+
+ server := newMockServer()
+ defer server.close()
+ server.response = mockResponsesWebSearchResponse()
+
+ model := newResponsesProvider(t, server.server.URL)
+
+ _, err := model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt,
+ Tools: []fantasy.Tool{WebSearchTool(nil)},
+ })
+ require.NoError(t, err)
+
+ tools, ok := server.calls[0].body["tools"].([]any)
+ require.True(t, ok, "request body should have tools array")
+ require.Len(t, tools, 1)
+
+ tool, ok := tools[0].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "web_search", tool["type"])
+ })
+
+ t.Run("with search_context_size and allowed_domains", func(t *testing.T) {
+ t.Parallel()
+
+ server := newMockServer()
+ defer server.close()
+ server.response = mockResponsesWebSearchResponse()
+
+ model := newResponsesProvider(t, server.server.URL)
+
+ _, err := model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt,
+ Tools: []fantasy.Tool{
+ WebSearchTool(&WebSearchToolOptions{
+ SearchContextSize: SearchContextSizeHigh,
+ AllowedDomains: []string{"example.com", "test.com"},
+ }),
+ },
+ })
+ require.NoError(t, err)
+
+ tools, ok := server.calls[0].body["tools"].([]any)
+ require.True(t, ok)
+ require.Len(t, tools, 1)
+
+ tool, ok := tools[0].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "web_search", tool["type"])
+ require.Equal(t, "high", tool["search_context_size"])
+
+ filters, ok := tool["filters"].(map[string]any)
+ require.True(t, ok, "tool should have filters")
+ domains, ok := filters["allowed_domains"].([]any)
+ require.True(t, ok, "filters should have allowed_domains")
+ require.Len(t, domains, 2)
+ require.Equal(t, "example.com", domains[0])
+ require.Equal(t, "test.com", domains[1])
+ })
+
+ t.Run("with user_location", func(t *testing.T) {
+ t.Parallel()
+
+ server := newMockServer()
+ defer server.close()
+ server.response = mockResponsesWebSearchResponse()
+
+ model := newResponsesProvider(t, server.server.URL)
+
+ _, err := model.Generate(context.Background(), fantasy.Call{
+ Prompt: testPrompt,
+ Tools: []fantasy.Tool{
+ WebSearchTool(&WebSearchToolOptions{
+ UserLocation: &WebSearchUserLocation{
+ City: "San Francisco",
+ Country: "US",
+ },
+ }),
+ },
+ })
+ require.NoError(t, err)
+
+ tools, ok := server.calls[0].body["tools"].([]any)
+ require.True(t, ok)
+ require.Len(t, tools, 1)
+
+ tool, ok := tools[0].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "web_search", tool["type"])
+
+ userLoc, ok := tool["user_location"].(map[string]any)
+ require.True(t, ok, "tool should have user_location")
+ require.Equal(t, "San Francisco", userLoc["city"])
+ require.Equal(t, "US", userLoc["country"])
+ })
+}
+
+func TestResponsesToPrompt_WebSearchProviderExecutedToolResults(t *testing.T) {
+ t.Parallel()
+
+ prompt := fantasy.Prompt{
+ {
+ Role: fantasy.MessageRoleUser,
+ Content: []fantasy.MessagePart{
+ fantasy.TextPart{Text: "Search for the latest AI news"},
+ },
+ },
+ {
+ Role: fantasy.MessageRoleAssistant,
+ Content: []fantasy.MessagePart{
+ fantasy.ToolCallPart{
+ ToolCallID: "ws_01",
+ ToolName: "web_search",
+ ProviderExecuted: true,
+ },
+ fantasy.ToolResultPart{
+ ToolCallID: "ws_01",
+ ProviderExecuted: true,
+ },
+ fantasy.TextPart{Text: "Here is what I found."},
+ },
+ },
+ }
+
+ input, warnings := toResponsesPrompt(prompt, "system instructions")
+
+ require.Empty(t, warnings)
+
+ // Expected input items: user message, item_reference (for
+ // provider-executed tool call; the ToolResultPart is skipped),
+ // and assistant text message. System instructions are passed
+ // via params.Instructions, not as an input item.
+ require.Len(t, input, 3,
+ "expected user + item_reference + assistant text")
+}
+
+func TestResponsesStream_WebSearchResponse(t *testing.T) {
+ t.Parallel()
+
+ chunks := []string{
+ "event: response.output_item.added\n" +
+ `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_01","status":"in_progress"}}` + "\n\n",
+ "event: response.output_item.done\n" +
+ `data: {"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"ws_01","status":"completed","action":{"type":"search","query":"latest AI news"}}}` + "\n\n",
+ "event: response.output_item.added\n" +
+ `data: {"type":"response.output_item.added","output_index":1,"item":{"type":"message","id":"msg_01","role":"assistant","status":"in_progress","content":[]}}` + "\n\n",
+ "event: response.output_text.delta\n" +
+ `data: {"type":"response.output_text.delta","output_index":1,"content_index":0,"delta":"Here are the results."}` + "\n\n",
+ "event: response.output_item.done\n" +
+ `data: {"type":"response.output_item.done","output_index":1,"item":{"type":"message","id":"msg_01","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Here are the results.","annotations":[{"type":"url_citation","url":"https://example.com/ai-news","title":"Latest AI News","start_index":0,"end_index":21}]}]}}` + "\n\n",
+ "event: response.completed\n" +
+ `data: {"type":"response.completed","response":{"id":"resp_01","status":"completed","output":[],"usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150}}}` + "\n\n",
+ }
+
+ sms := newStreamingMockServer()
+ defer sms.close()
+ sms.chunks = chunks
+
+ model := newResponsesProvider(t, sms.server.URL)
+
+ stream, err := model.Stream(context.Background(), fantasy.Call{
+ Prompt: testPrompt,
+ Tools: []fantasy.Tool{WebSearchTool(nil)},
+ })
+ require.NoError(t, err)
+
+ var parts []fantasy.StreamPart
+ stream(func(part fantasy.StreamPart) bool {
+ parts = append(parts, part)
+ return true
+ })
+
+ var (
+ toolInputStarts []fantasy.StreamPart
+ toolCalls []fantasy.StreamPart
+ toolResults []fantasy.StreamPart
+ textDeltas []fantasy.StreamPart
+ )
+ for _, p := range parts {
+ switch p.Type {
+ case fantasy.StreamPartTypeToolInputStart:
+ toolInputStarts = append(toolInputStarts, p)
+ case fantasy.StreamPartTypeToolCall:
+ toolCalls = append(toolCalls, p)
+ case fantasy.StreamPartTypeToolResult:
+ toolResults = append(toolResults, p)
+ case fantasy.StreamPartTypeTextDelta:
+ textDeltas = append(textDeltas, p)
+ }
+ }
+
+ require.NotEmpty(t, toolInputStarts, "should have a tool input start")
+ require.True(t, toolInputStarts[0].ProviderExecuted)
+ require.Equal(t, "web_search", toolInputStarts[0].ToolCallName)
+
+ require.NotEmpty(t, toolCalls, "should have a tool call")
+ require.True(t, toolCalls[0].ProviderExecuted)
+ require.Equal(t, "web_search", toolCalls[0].ToolCallName)
+
+ require.NotEmpty(t, toolResults, "should have a tool result")
+ require.True(t, toolResults[0].ProviderExecuted)
+ require.Equal(t, "web_search", toolResults[0].ToolCallName)
+ require.Equal(t, "ws_01", toolResults[0].ID)
+
+ require.NotEmpty(t, textDeltas, "should have text deltas")
+ require.Equal(t, "Here are the results.", textDeltas[0].Delta)
+}
@@ -478,6 +478,10 @@ func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (respons
}
if toolCallPart.ProviderExecuted {
+ // Round-trip provider-executed tools via
+ // item_reference, letting the API resolve
+ // the stored output item by ID.
+ input = append(input, responses.ResponseInputItemParamOfItemReference(toolCallPart.ToolCallID))
continue
}
@@ -491,6 +495,10 @@ func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (respons
}
input = append(input, responses.ResponseInputItemParamOfFunctionCall(string(inputJSON), toolCallPart.ToolCallID, toolCallPart.ToolName))
+ case fantasy.ContentTypeSource:
+ // Source citations from web search are not a
+ // recognised Responses API input type; skip.
+ continue
case fantasy.ContentTypeReasoning:
reasoningMetadata := GetReasoningMetadata(c.Options())
if reasoningMetadata == nil || reasoningMetadata.ItemID == "" {
@@ -553,7 +561,14 @@ func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (respons
continue
}
+ // Provider-executed tool results (e.g. web search)
+ // are already round-tripped via the tool call; skip.
+ if toolResultPart.ProviderExecuted {
+ continue
+ }
+
var outputStr string
+
switch toolResultPart.Output.GetType() {
case fantasy.ToolResultContentTypeText:
output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output)
@@ -629,6 +644,17 @@ func toResponsesTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, opti
})
continue
}
+ if tool.GetType() == fantasy.ToolTypeProviderDefined {
+ pt, ok := tool.(fantasy.ProviderDefinedTool)
+ if !ok {
+ continue
+ }
+ switch pt.ID {
+ case "web_search":
+ openaiTools = append(openaiTools, toWebSearchToolParam(pt))
+ continue
+ }
+ }
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedTool,
@@ -733,6 +759,28 @@ func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call)
Input: outputItem.Arguments.OfString,
})
+ case "web_search_call":
+ // Provider-executed web search tool call. Emit both
+ // a ToolCallContent and ToolResultContent as a pair,
+ // matching the vercel/ai pattern for provider tools.
+ //
+ // Note: source citations come from url_citation annotations
+ // on the message text (handled in the "message" case above),
+ // not from the web_search_call action.
+ wsMeta := webSearchCallToMetadata(outputItem.ID, outputItem.Action)
+ content = append(content, fantasy.ToolCallContent{
+ ProviderExecuted: true,
+ ToolCallID: outputItem.ID,
+ ToolName: "web_search",
+ })
+ content = append(content, fantasy.ToolResultContent{
+ ProviderExecuted: true,
+ ToolCallID: outputItem.ID,
+ ToolName: "web_search",
+ ProviderMetadata: fantasy.ProviderMetadata{
+ Name: wsMeta,
+ },
+ })
case "reasoning":
metadata := &ResponsesReasoningMetadata{
ItemID: outputItem.ID,
@@ -849,6 +897,17 @@ func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (
return
}
+ case "web_search_call":
+ // Provider-executed web search; emit start.
+ if !yield(fantasy.StreamPart{
+ Type: fantasy.StreamPartTypeToolInputStart,
+ ID: added.Item.ID,
+ ToolCallName: "web_search",
+ ProviderExecuted: true,
+ }) {
+ return
+ }
+
case "message":
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeTextStart,
@@ -905,6 +964,37 @@ func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (
}
}
+ case "web_search_call":
+ // Provider-executed web search completed.
+ // Source citations come from url_citation annotations
+ // on the streamed message text, not from the action.
+ if !yield(fantasy.StreamPart{
+ Type: fantasy.StreamPartTypeToolInputEnd,
+ ID: done.Item.ID,
+ }) {
+ return
+ }
+ if !yield(fantasy.StreamPart{
+ Type: fantasy.StreamPartTypeToolCall,
+ ID: done.Item.ID,
+ ToolCallName: "web_search",
+ ProviderExecuted: true,
+ }) {
+ return
+ }
+ // Emit a ToolResult so the agent framework
+ // includes it in round-trip messages.
+ if !yield(fantasy.StreamPart{
+ Type: fantasy.StreamPartTypeToolResult,
+ ID: done.Item.ID,
+ ToolCallName: "web_search",
+ ProviderExecuted: true,
+ ProviderMetadata: fantasy.ProviderMetadata{
+ Name: webSearchCallToMetadata(done.Item.ID, done.Item.Action),
+ },
+ }) {
+ return
+ }
case "message":
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeTextEnd,
@@ -1034,6 +1124,63 @@ func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (
}, nil
}
+// toWebSearchToolParam converts a ProviderDefinedTool with ID
+// "web_search" into the OpenAI SDK's WebSearchToolParam.
+func toWebSearchToolParam(pt fantasy.ProviderDefinedTool) responses.ToolUnionParam {
+ wst := responses.WebSearchToolParam{
+ Type: responses.WebSearchToolTypeWebSearch,
+ }
+ if pt.Args != nil {
+ if size, ok := pt.Args["search_context_size"].(SearchContextSize); ok && size != "" {
+ wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
+ }
+ // Also accept plain string for search_context_size.
+ if size, ok := pt.Args["search_context_size"].(string); ok && size != "" {
+ wst.SearchContextSize = responses.WebSearchToolSearchContextSize(size)
+ }
+ if domains, ok := pt.Args["allowed_domains"].([]string); ok && len(domains) > 0 {
+ wst.Filters.AllowedDomains = domains
+ }
+ if loc, ok := pt.Args["user_location"].(*WebSearchUserLocation); ok && loc != nil {
+ if loc.City != "" {
+ wst.UserLocation.City = param.NewOpt(loc.City)
+ }
+ if loc.Region != "" {
+ wst.UserLocation.Region = param.NewOpt(loc.Region)
+ }
+ if loc.Country != "" {
+ wst.UserLocation.Country = param.NewOpt(loc.Country)
+ }
+ if loc.Timezone != "" {
+ wst.UserLocation.Timezone = param.NewOpt(loc.Timezone)
+ }
+ }
+ }
+ return responses.ToolUnionParam{
+ OfWebSearch: &wst,
+ }
+}
+
+// webSearchCallToMetadata converts an OpenAI web search call output
+// into our structured metadata for round-tripping.
+func webSearchCallToMetadata(itemID string, action responses.ResponseOutputItemUnionAction) *WebSearchCallMetadata {
+ meta := &WebSearchCallMetadata{ItemID: itemID}
+ if action.Type != "" {
+ a := &WebSearchAction{
+ Type: action.Type,
+ Query: action.Query,
+ }
+ for _, src := range action.Sources {
+ a.Sources = append(a.Sources, WebSearchSource{
+ Type: string(src.Type),
+ URL: src.URL,
+ })
+ }
+ meta.Action = a
+ }
+ return meta
+}
+
// GetReasoningMetadata extracts reasoning metadata from provider options for responses models.
func GetReasoningMetadata(providerOptions fantasy.ProviderOptions) *ResponsesReasoningMetadata {
if openaiResponsesOptions, ok := providerOptions[Name]; ok {
@@ -12,6 +12,7 @@ import (
const (
TypeResponsesProviderOptions = Name + ".responses.options"
TypeResponsesReasoningMetadata = Name + ".responses.reasoning_metadata"
+ TypeWebSearchCallMetadata = Name + ".responses.web_search_call_metadata"
)
// Register OpenAI Responses API-specific types with the global registry.
@@ -30,6 +31,13 @@ func init() {
}
return &v, nil
})
+ fantasy.RegisterProviderType(TypeWebSearchCallMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
+ var v WebSearchCallMetadata
+ if err := json.Unmarshal(data, &v); err != nil {
+ return nil, err
+ }
+ return &v, nil
+ })
}
// ResponsesReasoningMetadata represents reasoning metadata for OpenAI Responses API.
@@ -223,3 +231,112 @@ func IsResponsesModel(modelID string) bool {
func IsResponsesReasoningModel(modelID string) bool {
return slices.Contains(responsesReasoningModelIDs, modelID)
}
+
+// SearchContextSize controls how much context window space the
+// web search tool uses. Maps to the OpenAI API's
+// search_context_size parameter.
+type SearchContextSize string
+
+const (
+ // SearchContextSizeLow uses minimal context for search results.
+ SearchContextSizeLow SearchContextSize = "low"
+ // SearchContextSizeMedium is the default context size.
+ SearchContextSizeMedium SearchContextSize = "medium"
+ // SearchContextSizeHigh uses maximal context for search results.
+ SearchContextSizeHigh SearchContextSize = "high"
+)
+
+// WebSearchUserLocation provides geographic context for more
+// relevant web search results.
+type WebSearchUserLocation struct {
+ City string `json:"city,omitempty"`
+ Region string `json:"region,omitempty"`
+ Country string `json:"country,omitempty"`
+ Timezone string `json:"timezone,omitempty"`
+}
+
+// WebSearchToolOptions configures the OpenAI web search tool.
+type WebSearchToolOptions struct {
+ // SearchContextSize controls the amount of context window
+ // space used for search results. Defaults to medium.
+ SearchContextSize SearchContextSize
+ // AllowedDomains restricts search results to these domains.
+ // Subdomains are included automatically.
+ AllowedDomains []string
+ // UserLocation provides geographic context for more
+ // relevant search results.
+ UserLocation *WebSearchUserLocation
+}
+
+// WebSearchTool creates a provider-defined web search tool for
+// OpenAI models. Pass nil for default options.
+func WebSearchTool(opts *WebSearchToolOptions) fantasy.ProviderDefinedTool {
+ tool := fantasy.ProviderDefinedTool{
+ ID: "web_search",
+ Name: "web_search",
+ }
+ if opts == nil {
+ return tool
+ }
+ args := map[string]any{}
+ if opts.SearchContextSize != "" {
+ args["search_context_size"] = opts.SearchContextSize
+ }
+ if len(opts.AllowedDomains) > 0 {
+ args["allowed_domains"] = opts.AllowedDomains
+ }
+ if opts.UserLocation != nil {
+ args["user_location"] = opts.UserLocation
+ }
+ if len(args) > 0 {
+ tool.Args = args
+ }
+ return tool
+}
+
+// WebSearchSource represents a single source from a web search action.
+type WebSearchSource struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+}
+
+// WebSearchAction represents the action taken during a web search call.
+type WebSearchAction struct {
+ // Type is the kind of action: "search", "open_page", or "find".
+ Type string `json:"type"`
+ // Query is the search query (present when Type is "search").
+ Query string `json:"query,omitempty"`
+ // Sources are the results returned by the search.
+ Sources []WebSearchSource `json:"sources,omitempty"`
+}
+
+// WebSearchCallMetadata stores structured data from a web_search_call
+// output item for round-tripping through multi-turn conversations.
+// The ItemID is used with item_reference for efficient round-tripping
+// when response storage is enabled.
+type WebSearchCallMetadata struct {
+ // ItemID is the server-side ID of the web_search_call output item.
+ ItemID string `json:"item_id"`
+ // Action contains the structured action data from the search.
+ Action *WebSearchAction `json:"action,omitempty"`
+}
+
+// Options implements the ProviderOptionsData interface.
+func (*WebSearchCallMetadata) Options() {}
+
+// MarshalJSON implements custom JSON marshaling with type info.
+func (m WebSearchCallMetadata) MarshalJSON() ([]byte, error) {
+ type plain WebSearchCallMetadata
+ return fantasy.MarshalProviderType(TypeWebSearchCallMetadata, plain(m))
+}
+
+// UnmarshalJSON implements custom JSON unmarshaling with type info.
+func (m *WebSearchCallMetadata) UnmarshalJSON(data []byte) error {
+ type plain WebSearchCallMetadata
+ var p plain
+ if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
+ return err
+ }
+ *m = WebSearchCallMetadata(p)
+ return nil
+}
@@ -0,0 +1,128 @@
+package providertests
+
+import (
+ "cmp"
+ "net/http"
+ "os"
+ "testing"
+
+ "charm.land/fantasy"
+ "charm.land/fantasy/providers/openai"
+ "charm.land/x/vcr"
+ "github.com/stretchr/testify/require"
+)
+
+func openAIWebSearchBuilder(model string) builderFunc {
+ return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
+ opts := []openai.Option{
+ openai.WithAPIKey(cmp.Or(os.Getenv("FANTASY_OPENAI_API_KEY"), os.Getenv("OPENAI_API_KEY"), "(missing)")),
+ openai.WithHTTPClient(&http.Client{Transport: r}),
+ openai.WithUseResponsesAPI(),
+ }
+ provider, err := openai.New(opts...)
+ if err != nil {
+ return nil, err
+ }
+ return provider.LanguageModel(t.Context(), model)
+ }
+}
+
+// TestOpenAIWebSearch tests web search tool support via the agent
+// using WithProviderDefinedTools on the OpenAI Responses API.
+func TestOpenAIWebSearch(t *testing.T) {
+ model := "gpt-4.1"
+ webSearchTool := openai.WebSearchTool(nil)
+
+ t.Run("generate", func(t *testing.T) {
+ r := vcr.NewRecorder(t)
+
+ lm, err := openAIWebSearchBuilder(model)(t, r)
+ require.NoError(t, err)
+
+ agent := fantasy.NewAgent(
+ lm,
+ fantasy.WithSystemPrompt("You are a helpful assistant"),
+ fantasy.WithProviderDefinedTools(webSearchTool),
+ )
+
+ result, err := agent.Generate(t.Context(), fantasy.AgentCall{
+ Prompt: "What is the current population of Tokyo? Cite your source.",
+ MaxOutputTokens: fantasy.Opt(int64(4000)),
+ })
+ require.NoError(t, err)
+
+ got := result.Response.Content.Text()
+ require.NotEmpty(t, got, "should have a text response")
+ require.Contains(t, got, "Tokyo", "response should mention Tokyo")
+
+ // Walk the steps and verify web search content was produced.
+ var sources []fantasy.SourceContent
+ var providerToolCalls []fantasy.ToolCallContent
+ for _, step := range result.Steps {
+ for _, c := range step.Content {
+ switch v := c.(type) {
+ case fantasy.ToolCallContent:
+ if v.ProviderExecuted {
+ providerToolCalls = append(providerToolCalls, v)
+ }
+ case fantasy.SourceContent:
+ sources = append(sources, v)
+ }
+ }
+ }
+
+ require.NotEmpty(t, providerToolCalls, "should have provider-executed tool calls")
+ require.Equal(t, "web_search", providerToolCalls[0].ToolName)
+ // Sources come from url_citation annotations; the model
+ // may or may not include inline citations so we don't
+ // require them, but if present they should have URLs.
+ for _, src := range sources {
+ require.NotEmpty(t, src.URL, "source should have a URL")
+ }
+ })
+
+ t.Run("stream", func(t *testing.T) {
+ r := vcr.NewRecorder(t)
+
+ lm, err := openAIWebSearchBuilder(model)(t, r)
+ require.NoError(t, err)
+
+ agent := fantasy.NewAgent(
+ lm,
+ fantasy.WithSystemPrompt("You are a helpful assistant"),
+ fantasy.WithProviderDefinedTools(webSearchTool),
+ )
+
+ // Turn 1: initial query triggers web search.
+ result, err := agent.Stream(t.Context(), fantasy.AgentStreamCall{
+ Prompt: "What is the current population of Tokyo? Cite your source.",
+ MaxOutputTokens: fantasy.Opt(int64(4000)),
+ })
+ require.NoError(t, err)
+
+ got := result.Response.Content.Text()
+ require.NotEmpty(t, got, "should have a text response")
+ require.Contains(t, got, "Tokyo", "response should mention Tokyo")
+
+ // Verify provider-executed tool calls and results in steps.
+ var providerToolCalls []fantasy.ToolCallContent
+ var providerToolResults []fantasy.ToolResultContent
+ for _, step := range result.Steps {
+ for _, c := range step.Content {
+ switch v := c.(type) {
+ case fantasy.ToolCallContent:
+ if v.ProviderExecuted {
+ providerToolCalls = append(providerToolCalls, v)
+ }
+ case fantasy.ToolResultContent:
+ if v.ProviderExecuted {
+ providerToolResults = append(providerToolResults, v)
+ }
+ }
+ }
+ }
+ require.NotEmpty(t, providerToolCalls, "should have provider-executed tool calls")
+ require.Equal(t, "web_search", providerToolCalls[0].ToolName)
+ require.NotEmpty(t, providerToolResults, "should have provider-executed tool results")
+ })
+}
@@ -390,6 +390,7 @@ func TestProviderRegistry_AllTypesRegistered(t *testing.T) {
data fantasy.ProviderOptionsData
}{
{"OpenAI Responses Reasoning Metadata", openai.Name, &openai.ResponsesReasoningMetadata{}},
+ {"OpenAI Web Search Call Metadata", openai.Name, &openai.WebSearchCallMetadata{}},
{"Anthropic Reasoning Metadata", anthropic.Name, &anthropic.ReasoningOptionMetadata{}},
{"Anthropic Web Search Result Metadata", anthropic.Name, &anthropic.WebSearchResultMetadata{}},
{"Google Reasoning Metadata", google.Name, &google.ReasoningMetadata{}},
@@ -0,0 +1,150 @@
+---
+version: 2
+interactions:
+- id: 0
+ request:
+ proto: HTTP/1.1
+ proto_major: 1
+ proto_minor: 1
+ content_length: 297
+ host: ""
+ body: '{"max_output_tokens":4000,"store":false,"input":[{"content":"You are a helpful assistant","role":"system"},{"content":[{"text":"What is the current population of Tokyo? Cite your source.","type":"input_text"}],"role":"user"}],"model":"gpt-4.1","tool_choice":"auto","tools":[{"type":"web_search"}]}'
+ headers:
+ Accept:
+ - application/json
+ Content-Type:
+ - application/json
+ User-Agent:
+ - Charm-Fantasy/0.12.3 (https://charm.land/fantasy)
+ url: https://api.openai.com/v1/responses
+ method: POST
+ response:
+ proto: HTTP/2.0
+ proto_major: 2
+ proto_minor: 0
+ content_length: -1
+ uncompressed: true
+ body: |-
+ {
+ "id": "resp_07bce1382dabd6c50169b960ccf2d481969a4addb2d83e8781",
+ "object": "response",
+ "created_at": 1773756620,
+ "status": "completed",
+ "background": false,
+ "billing": {
+ "payer": "developer"
+ },
+ "completed_at": 1773756625,
+ "error": null,
+ "frequency_penalty": 0.0,
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": 4000,
+ "max_tool_calls": null,
+ "model": "gpt-4.1-2025-04-14",
+ "output": [
+ {
+ "id": "ws_07bce1382dabd6c50169b960cdd08c8196820826087ad7361b",
+ "type": "web_search_call",
+ "status": "completed",
+ "action": {
+ "type": "search",
+ "queries": [
+ "current population of Tokyo 2024"
+ ],
+ "query": "current population of Tokyo 2024"
+ }
+ },
+ {
+ "id": "msg_07bce1382dabd6c50169b960cf63b881969452b2b9ab51a3a7",
+ "type": "message",
+ "status": "completed",
+ "content": [
+ {
+ "type": "output_text",
+ "annotations": [
+ {
+ "type": "url_citation",
+ "end_index": 566,
+ "start_index": 488,
+ "title": "Tokyo Third in UN Ranking of Global Megacities at 33.4 Million | Nippon.com",
+ "url": "https://www.nippon.com/en/japan-data/h02639/?utm_source=openai"
+ },
+ {
+ "type": "url_citation",
+ "end_index": 898,
+ "start_index": 807,
+ "title": "Demographics of Tokyo",
+ "url": "https://en.wikipedia.org/wiki/Demographics_of_Tokyo?utm_source=openai"
+ },
+ {
+ "type": "url_citation",
+ "end_index": 1240,
+ "start_index": 1152,
+ "title": "Greater Tokyo Area",
+ "url": "https://en.wikipedia.org/wiki/Greater_Tokyo_Area?utm_source=openai"
+ }
+ ],
+ "logprobs": [],
@@ -0,0 +1,161 @@
+---
+version: 2
+interactions:
+- id: 0
+ request:
+ proto: HTTP/1.1
+ proto_major: 1
+ proto_minor: 1
+ content_length: 311
+ host: ""
+ body: '{"max_output_tokens":4000,"store":false,"input":[{"content":"You are a helpful assistant","role":"system"},{"content":[{"text":"What is the current population of Tokyo? Cite your source.","type":"input_text"}],"role":"user"}],"model":"gpt-4.1","tool_choice":"auto","tools":[{"type":"web_search"}],"stream":true}'
+ headers:
+ Accept:
+ - application/json
+ Content-Type:
+ - application/json
+ User-Agent:
+ - Charm-Fantasy/0.12.3 (https://charm.land/fantasy)
+ url: https://api.openai.com/v1/responses
+ method: POST
+ response:
+ proto: HTTP/2.0
+ proto_major: 2
+ proto_minor: 0
+ content_length: -1
+ body: |+
+ event: response.created
+ data: {"type":"response.created","response":{"id":"resp_0dfea6230b48ad890169b960d9319881969c27da66ed1c1378","object":"response","created_at":1773756633,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4000,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"web_search","search_context_size":"medium","user_location":{"type":"approximate","city":null,"country":"US","region":null,"timezone":null}}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0}
+
+ event: response.in_progress
+ data: {"type":"response.in_progress","response":{"id":"resp_0dfea6230b48ad890169b960d9319881969c27da66ed1c1378","object":"response","created_at":1773756633,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":4000,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"web_search","search_context_size":"medium","user_location":{"type":"approximate","city":null,"country":"US","region":null,"timezone":null}}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1}
+
+ event: response.output_item.added
+ data: {"type":"response.output_item.added","item":{"id":"ws_0dfea6230b48ad890169b960da1f848196860f801e11b24193","type":"web_search_call","status":"in_progress","action":{"type":"search"}},"output_index":0,"sequence_number":2}
+
+ event: response.web_search_call.in_progress
+ data: {"type":"response.web_search_call.in_progress","item_id":"ws_0dfea6230b48ad890169b960da1f848196860f801e11b24193","output_index":0,"sequence_number":3}
+
+ event: response.web_search_call.searching
+ data: {"type":"response.web_search_call.searching","item_id":"ws_0dfea6230b48ad890169b960da1f848196860f801e11b24193","output_index":0,"sequence_number":4}
+
+ event: response.web_search_call.completed
+ data: {"type":"response.web_search_call.completed","item_id":"ws_0dfea6230b48ad890169b960da1f848196860f801e11b24193","output_index":0,"sequence_number":5}
+
+ event: response.output_item.done
+ data: {"type":"response.output_item.done","item":{"id":"ws_0dfea6230b48ad890169b960da1f848196860f801e11b24193","type":"web_search_call","status":"completed","action":{"type":"search","queries":["current population of Tokyo 2024"],"query":"current population of Tokyo 2024"}},"output_index":0,"sequence_number":6}
+
+ event: response.output_item.added
+ data: {"type":"response.output_item.added","item":{"id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":1,"sequence_number":7}
+
+ event: response.content_part.added
+ data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","output_index":1,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":8}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":"Here’s the most current and accurate information regarding the population","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"w0I9gLz","output_index":1,"sequence_number":9}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" of Tokyo as of March 17, ","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"OFMsBb","output_index":1,"sequence_number":10}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":"2026:\n\n- For the **Tokyo metropolitan area**","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"wGWX","output_index":1,"sequence_number":11}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":"—also referred to as Greater Tokyo, which includes the city","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"qBRGY","output_index":1,"sequence_number":12}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" proper along with its surrounding prefectures—the United Nations estimates","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"2HV4J","output_index":1,"sequence_number":13}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" the 2025 population at approximately **33.4","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"J17k","output_index":1,"sequence_number":14}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" million**. This makes it the **third-largest urban","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"wFYK9go4C5VzD","output_index":1,"sequence_number":15}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" agglomeration in the world**, following Jakarta and Dhaka","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"Xq5n8f","output_index":1,"sequence_number":16}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":". Estimates near **33 million** are commonly cited across reputable","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"FNtzTsTaOnDlq","output_index":1,"sequence_number":17}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" sources. ","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"2IKKku","output_index":1,"sequence_number":18}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":"([nippon.com](https://www.nippon.com/en/japan-data/h02639/tokyo-third-in-un-ranking-of-global-megacities-at-33-4-million.html?utm_source=openai)","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"","output_index":1,"sequence_number":19}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":")\n\n- However,","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"r2q","output_index":1,"sequence_number":20}
+
+ event: response.output_text.annotation.added
+ data: {"type":"response.output_text.annotation.added","annotation":{"type":"url_citation","end_index":652,"start_index":507,"title":"Tokyo Third in UN Ranking of Global Megacities at 33.4 Million | Nippon.com","url":"https://www.nippon.com/en/japan-data/h02639/tokyo-third-in-un-ranking-of-global-megacities-at-33-4-million.html?utm_source=openai"},"annotation_index":0,"content_index":0,"item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","output_index":1,"sequence_number":21}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" for the **Tokyo prefecture or “city proper”**","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"GW","output_index":1,"sequence_number":22}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":"—the 23 special wards and other administrative areas—the estimated","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"IuWuYpBPoq4jE6","output_index":1,"sequence_number":23}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" population for 2025 is **14,195","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"","output_index":1,"sequence_number":24}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":",730**. ","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"68Mp8WuT","output_index":1,"sequence_number":25}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":"([en.wikipedia.org](https://en.wikipedia.org/wiki/Demographics_of_Tokyo?utm_source=openai))\n\nTo clarify:\n\n- When","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"","output_index":1,"sequence_number":26}
+
+ event: response.output_text.annotation.added
+ data: {"type":"response.output_text.annotation.added","annotation":{"type":"url_citation","end_index":907,"start_index":816,"title":"Demographics of Tokyo","url":"https://en.wikipedia.org/wiki/Demographics_of_Tokyo?utm_source=openai"},"annotation_index":1,"content_index":0,"item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","output_index":1,"sequence_number":27}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" someone refers to **“Tokyo’s population,”** they may","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"LKfvzRMroEL","output_index":1,"sequence_number":28}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" mean one of two things:\n ","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"eN1MP","output_index":1,"sequence_number":29}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":"1. The **metropolitan area**, which is the expansive","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"F1esowqlu7GO","output_index":1,"sequence_number":30}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" urban zone including suburban regions—around **33 million**","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"13js","output_index":1,"sequence_number":31}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" people.\n 2. The **administrative city/p","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"uRvlwpX","output_index":1,"sequence_number":32}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":"refecture itself**—about **14.2 million**","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"orK5bC1","output_index":1,"sequence_number":33}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" people.\n\nSo depending on your intended definition, both figures","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"","output_index":1,"sequence_number":34}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":" are accurate for 2025—just measuring different scales","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"n6NvYi494h","output_index":1,"sequence_number":35}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":".\n\nLet me know if you’d like data on historical trends","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"a0fxr1kC00","output_index":1,"sequence_number":36}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":", future projections, or comparisons with other global megac","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"2Ba6","output_index":1,"sequence_number":37}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","content_index":0,"delta":"ities!","item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"obfuscation":"91BomkbK8p","output_index":1,"sequence_number":38}
+
+ event: response.output_text.done