feat(openai): add web search tool support for Responses API (#173)

Kyle Carberry created

Add OpenAI web search as a provider-defined tool, mirroring the
Anthropic web search implementation pattern.

New files:
- providers/openai/web_search.go: WebSearchTool() helper with
  SearchContextSize, AllowedDomains, and UserLocation options.
- examples/openai-web-search/main.go: Example using the agent
  pattern with web search.

Changes to existing files:
- responses_options.go: WebSearchCallMetadata, WebSearchAction,
  and WebSearchSource types registered for JSON round-tripping.
- responses_language_model.go:
  - toResponsesTools(): Route ProviderDefinedTool web_search to
    the OpenAI SDK WebSearchToolParam.
  - Generate(): Emit ToolCallContent + ToolResultContent pair for
    web_search_call output items. Source citations come from
    url_citation annotations on message text.
  - Stream(): Emit ToolInputStart/ToolInputEnd/ToolCall/ToolResult
    lifecycle events for web_search_call items.
  - toResponsesPrompt(): Round-trip provider-executed tool calls
    via item_reference; skip SourceContent and provider-executed
    ToolResultPart (already handled by the reference).
- providertests/provider_registry_test.go: Add metadata test case.

Change summary

examples/go.mod                                          |  30 
examples/go.sum                                          |  60 
examples/web-search/main.go                              |  78 ++
providers/anthropic/anthropic_test.go                    |  22 
providers/openai/openai_test.go                          | 358 ++++++++++
providers/openai/responses_language_model.go             | 147 ++++
providers/openai/responses_options.go                    | 117 +++
providertests/openai_web_search_test.go                  | 128 +++
providertests/provider_registry_test.go                  |   1 
providertests/testdata/TestOpenAIWebSearch/generate.yaml |  88 ++
providertests/testdata/TestOpenAIWebSearch/stream.yaml   | 144 ++++
11 files changed, 1,118 insertions(+), 55 deletions(-)

Detailed changes

examples/go.mod đź”—

@@ -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

examples/go.sum đź”—

@@ -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=

examples/web-search/main.go đź”—

@@ -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)
+	}
+}

providers/anthropic/anthropic_test.go đź”—

@@ -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

providers/openai/openai_test.go đź”—

@@ -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)
+}

providers/openai/responses_language_model.go đź”—

@@ -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 {

providers/openai/responses_options.go đź”—

@@ -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
+}

providertests/openai_web_search_test.go đź”—

@@ -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")
+	})
+}

providertests/provider_registry_test.go đź”—

@@ -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{}},

providertests/testdata/TestOpenAIWebSearch/generate.yaml đź”—

@@ -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": [],

providertests/testdata/TestOpenAIWebSearch/stream.yaml đź”—

@@ -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