From 6462ab9c4287aa01d1ccba994c320efe64ada2fd Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 17 Mar 2026 13:26:10 -0400 Subject: [PATCH] feat(openai): add web search tool support for Responses API (#173) 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. --- 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 + .../TestOpenAIWebSearch/generate.yaml | 150 ++++++++ .../testdata/TestOpenAIWebSearch/stream.yaml | 161 ++++++++ 11 files changed, 1197 insertions(+), 55 deletions(-) create mode 100644 examples/web-search/main.go create mode 100644 providertests/openai_web_search_test.go create mode 100644 providertests/testdata/TestOpenAIWebSearch/generate.yaml create mode 100644 providertests/testdata/TestOpenAIWebSearch/stream.yaml diff --git a/examples/go.mod b/examples/go.mod index 6ae5432ed117004842d7de369a78efb6e1d83348..a08265370b96d45ae242b40070a4af1a4bf10e17 100644 --- a/examples/go.mod +++ b/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 diff --git a/examples/go.sum b/examples/go.sum index 8ba82659a72db5c7991c76db76ec016cee6c065b..309d7a0e1461c907650c6b215f2e89120a1e5fd4 100644 --- a/examples/go.sum +++ b/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= diff --git a/examples/web-search/main.go b/examples/web-search/main.go new file mode 100644 index 0000000000000000000000000000000000000000..203e46ad79de71905eea364ca7d43515fa65548c --- /dev/null +++ b/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) + } +} diff --git a/providers/anthropic/anthropic_test.go b/providers/anthropic/anthropic_test.go index c6c2704fa9134bf4fc667c7cd190b95a53a1845a..51adb7c333110ed1c3a490cce32312ef05df8be9 100644 --- a/providers/anthropic/anthropic_test.go +++ b/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 diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index c08d5dbcb4f59c494b0ef87bda2caf9b6fa85dca..3d57070afc0a8b23ecd6ba4a71adc1bfcca39dc2 100644 --- a/providers/openai/openai_test.go +++ b/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) +} diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index 4fade0d3e3d8138447bc4e6fe3d2f7ca9da5e03a..9b1d9e3be3d9c4ac95e1f1e491ef7dc47a634687 100644 --- a/providers/openai/responses_language_model.go +++ b/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 { diff --git a/providers/openai/responses_options.go b/providers/openai/responses_options.go index 41ca2f67e7ff943b52b69f7241bf44e04d811846..b42145d4052eb8d3d72db277a8f165a1ba31d7c5 100644 --- a/providers/openai/responses_options.go +++ b/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 +} diff --git a/providertests/openai_web_search_test.go b/providertests/openai_web_search_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1fe4418fa9d1400c30fcf2de510c77b4b88a1865 --- /dev/null +++ b/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") + }) +} diff --git a/providertests/provider_registry_test.go b/providertests/provider_registry_test.go index b2b5edc51a3c61dc303e49ad2b5b27d03f29f170..9340e50b6b3a1e9056a5fa229d0d578c26abd237 100644 --- a/providertests/provider_registry_test.go +++ b/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{}}, diff --git a/providertests/testdata/TestOpenAIWebSearch/generate.yaml b/providertests/testdata/TestOpenAIWebSearch/generate.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9c0ccf0908bad6ddf23507bff59e9caf5393edc2 --- /dev/null +++ b/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": [], + "text": "Here is the most current and reliable information on Tokyo\u2019s population as of early 2026, based on authoritative sources:\n\nFirst, if you're referring to Tokyo as a broader **urban agglomeration** (commonly defined by the United Nations as the metropolitan area including Tokyo and surrounding prefectures like Saitama, Chiba, and Kanagawa), the population is approximately **33.4 million** as of 2025. This makes Tokyo the **third-largest megacity** in the world behind Jakarta and Dhaka ([nippon.com](https://www.nippon.com/en/japan-data/h02639/?utm_source=openai)).\n\nIf your interest is in the **administrative population** of **Tokyo Metropolis** (which includes the 23 special wards, as well as the western Tama area and outlying islands), the estimated population is about **14.20 million** as of 2025 ([en.wikipedia.org](https://en.wikipedia.org/wiki/Demographics_of_Tokyo?utm_source=openai)).\n\nAdditionally, if someone refers to the **Greater Tokyo Area** (which includes a wider region encompassing additional prefectures beyond Tokyo Metropolis), the United Nations estimates its total population at approximately **36.95 million** as of 2026 ([en.wikipedia.org](https://en.wikipedia.org/wiki/Greater_Tokyo_Area?utm_source=openai)).\n\nTo summarize, depending on how \"Tokyo\" is defined:\n\n\u2022 Urban agglomeration (metro area including adjacent prefectures): **\u2248\u202f33.4 million** (2025) \n\u2022 Tokyo Metropolis (actual administrative area): **\u2248\u202f14.20 million** (2025) \n\u2022 Greater Tokyo Area (extensive metropolitan region): **\u2248\u202f36.95 million** (2026)\n\nLet me know which specific definition you're interested in if you'd like further details." + } + ], + "role": "assistant" + } + ], + "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": "default", + "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": { + "input_tokens": 17106, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 386, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 17492 + }, + "user": null, + "metadata": {} + } + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 5.104430812s diff --git a/providertests/testdata/TestOpenAIWebSearch/stream.yaml b/providertests/testdata/TestOpenAIWebSearch/stream.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e76a65e256ba9fdf02f4e2f6eb7bff121dcdd343 --- /dev/null +++ b/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 + data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","logprobs":[],"output_index":1,"sequence_number":39,"text":"Here’s the most current and accurate information regarding the population of Tokyo as of March 17, 2026:\n\n- For the **Tokyo metropolitan area**—also referred to as Greater Tokyo, which includes the city proper along with its surrounding prefectures—the United Nations estimates the 2025 population at approximately **33.4 million**. This makes it the **third-largest urban agglomeration in the world**, following Jakarta and Dhaka. Estimates near **33 million** are commonly cited across reputable sources. ([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))\n\n- However, for the **Tokyo prefecture or “city proper”**—the 23 special wards and other administrative areas—the estimated population for 2025 is **14,195,730**. ([en.wikipedia.org](https://en.wikipedia.org/wiki/Demographics_of_Tokyo?utm_source=openai))\n\nTo clarify:\n\n- When someone refers to **“Tokyo’s population,”** they may mean one of two things:\n 1. The **metropolitan area**, which is the expansive urban zone including suburban regions—around **33 million** people.\n 2. The **administrative city/prefecture itself**—about **14.2 million** people.\n\nSo depending on your intended definition, both figures are accurate for 2025—just measuring different scales.\n\nLet me know if you’d like data on historical trends, future projections, or comparisons with other global megacities!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","output_index":1,"part":{"type":"output_text","annotations":[{"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"},{"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"}],"logprobs":[],"text":"Here’s the most current and accurate information regarding the population of Tokyo as of March 17, 2026:\n\n- For the **Tokyo metropolitan area**—also referred to as Greater Tokyo, which includes the city proper along with its surrounding prefectures—the United Nations estimates the 2025 population at approximately **33.4 million**. This makes it the **third-largest urban agglomeration in the world**, following Jakarta and Dhaka. Estimates near **33 million** are commonly cited across reputable sources. ([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))\n\n- However, for the **Tokyo prefecture or “city proper”**—the 23 special wards and other administrative areas—the estimated population for 2025 is **14,195,730**. ([en.wikipedia.org](https://en.wikipedia.org/wiki/Demographics_of_Tokyo?utm_source=openai))\n\nTo clarify:\n\n- When someone refers to **“Tokyo’s population,”** they may mean one of two things:\n 1. The **metropolitan area**, which is the expansive urban zone including suburban regions—around **33 million** people.\n 2. The **administrative city/prefecture itself**—about **14.2 million** people.\n\nSo depending on your intended definition, both figures are accurate for 2025—just measuring different scales.\n\nLet me know if you’d like data on historical trends, future projections, or comparisons with other global megacities!"},"sequence_number":40} + + event: response.output_item.done + data: {"type":"response.output_item.done","item":{"id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","type":"message","status":"completed","content":[{"type":"output_text","annotations":[{"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"},{"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"}],"logprobs":[],"text":"Here’s the most current and accurate information regarding the population of Tokyo as of March 17, 2026:\n\n- For the **Tokyo metropolitan area**—also referred to as Greater Tokyo, which includes the city proper along with its surrounding prefectures—the United Nations estimates the 2025 population at approximately **33.4 million**. This makes it the **third-largest urban agglomeration in the world**, following Jakarta and Dhaka. Estimates near **33 million** are commonly cited across reputable sources. ([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))\n\n- However, for the **Tokyo prefecture or “city proper”**—the 23 special wards and other administrative areas—the estimated population for 2025 is **14,195,730**. ([en.wikipedia.org](https://en.wikipedia.org/wiki/Demographics_of_Tokyo?utm_source=openai))\n\nTo clarify:\n\n- When someone refers to **“Tokyo’s population,”** they may mean one of two things:\n 1. The **metropolitan area**, which is the expansive urban zone including suburban regions—around **33 million** people.\n 2. The **administrative city/prefecture itself**—about **14.2 million** people.\n\nSo depending on your intended definition, both figures are accurate for 2025—just measuring different scales.\n\nLet me know if you’d like data on historical trends, future projections, or comparisons with other global megacities!"}],"role":"assistant"},"output_index":1,"sequence_number":41} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_0dfea6230b48ad890169b960d9319881969c27da66ed1c1378","object":"response","created_at":1773756633,"status":"completed","background":false,"completed_at":1773756637,"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_0dfea6230b48ad890169b960da1f848196860f801e11b24193","type":"web_search_call","status":"completed","action":{"type":"search","queries":["current population of Tokyo 2024"],"query":"current population of Tokyo 2024"}},{"id":"msg_0dfea6230b48ad890169b960db86348196bfedf4dc41325e1e","type":"message","status":"completed","content":[{"type":"output_text","annotations":[{"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"},{"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"}],"logprobs":[],"text":"Here’s the most current and accurate information regarding the population of Tokyo as of March 17, 2026:\n\n- For the **Tokyo metropolitan area**—also referred to as Greater Tokyo, which includes the city proper along with its surrounding prefectures—the United Nations estimates the 2025 population at approximately **33.4 million**. This makes it the **third-largest urban agglomeration in the world**, following Jakarta and Dhaka. Estimates near **33 million** are commonly cited across reputable sources. ([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))\n\n- However, for the **Tokyo prefecture or “city proper”**—the 23 special wards and other administrative areas—the estimated population for 2025 is **14,195,730**. ([en.wikipedia.org](https://en.wikipedia.org/wiki/Demographics_of_Tokyo?utm_source=openai))\n\nTo clarify:\n\n- When someone refers to **“Tokyo’s population,”** they may mean one of two things:\n 1. The **metropolitan area**, which is the expansive urban zone including suburban regions—around **33 million** people.\n 2. The **administrative city/prefecture itself**—about **14.2 million** people.\n\nSo depending on your intended definition, both figures are accurate for 2025—just measuring different scales.\n\nLet me know if you’d like data on historical trends, future projections, or comparisons with other global megacities!"}],"role":"assistant"}],"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":"default","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":{"input_tokens":16828,"input_tokens_details":{"cached_tokens":0},"output_tokens":337,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":17165},"user":null,"metadata":{}},"sequence_number":42} + + headers: + Content-Type: + - text/event-stream; charset=utf-8 + status: 200 OK + code: 200 + duration: 768.756726ms