diff --git a/.release-please-manifest.json b/.release-please-manifest.json index caf148712..de0960aba 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.11.0" + ".": "1.12.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 64c5f0cbf..7b99ae61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## 1.12.0 (2026-10-07) + +Full Changelog: [v1.11.0...v1.12.0](https://github.com/anthropics/anthropic-sdk-python/compare/v1.11.0...v1.12.0) + +### Features + +* **api:** add claude-haiku-5-5 and typed computer and browser toolset tool calls ([f6346e6](https://github.com/anthropics/anthropic-sdk-python/commit/f6346e63b7507a596e6e8acc9425cae2dfc7bf24)) +* **api:** add disabled to thinking types in model capabilities ([78d815d](https://github.com/anthropics/anthropic-sdk-python/commit/78d815d78b33cfea5f4aa78c57a30b341fa8ff42)) +* **api:** add display_name to RBAC roles and deprecate name ([f63f99c](https://github.com/anthropics/anthropic-sdk-python/commit/f63f99c17583fbdc7e0ba7b92f8f259724403146)) +* **api:** add include_default parameter to list workspaces ([5d4b37b](https://github.com/anthropics/anthropic-sdk-python/commit/5d4b37b36b96e24fb605993216c39db378b14d4c)) +* **api:** add lifecycle stage fields and filter to /v1/models ([0c96c02](https://github.com/anthropics/anthropic-sdk-python/commit/0c96c0212f4bfb6f592639c59378583cd80ee352)) +* **api:** add line to model objects ([a85c649](https://github.com/anthropics/anthropic-sdk-python/commit/a85c649ff834f54c6809eaceb38e3317c3805fb4)) +* **api:** add url_sources to the Managed Agents web_fetch tool config ([78d815d](https://github.com/anthropics/anthropic-sdk-python/commit/78d815d78b33cfea5f4aa78c57a30b341fa8ff42)) +* **api:** add web search and code execution support to model capabilities ([78d815d](https://github.com/anthropics/anthropic-sdk-python/commit/78d815d78b33cfea5f4aa78c57a30b341fa8ff42)) + + +### Bug Fixes + +* **api:** send the beta header by default when listing spend limits ([e2690b4](https://github.com/anthropics/anthropic-sdk-python/commit/e2690b4e733b0453d05bb770f32743618cfc9c92)) +* **client:** send empty strings in query params and multipart forms ([80e69f1](https://github.com/anthropics/anthropic-sdk-python/commit/80e69f1a204bd5bfdd090e6d32ce36c846544c1a)) +* **tools:** keep a local edit made after a memory upload whose response was lost ([#990](https://github.com/anthropics/anthropic-sdk-python/issues/990)) ([78d815d](https://github.com/anthropics/anthropic-sdk-python/commit/78d815d78b33cfea5f4aa78c57a30b341fa8ff42)) + + +### Chores + +* **client:** track whether the client timeout was set explicitly ([36a223f](https://github.com/anthropics/anthropic-sdk-python/commit/36a223feb1cc309bb9e2fcba371b430455a5555a)) +* **docs:** correct example groups in rate limit list description ([71fcf2f](https://github.com/anthropics/anthropic-sdk-python/commit/71fcf2fbf2caa7b28b228c50b23b94acc78467a9)) +* **docs:** describe a federation rule's target by its type ([78d815d](https://github.com/anthropics/anthropic-sdk-python/commit/78d815d78b33cfea5f4aa78c57a30b341fa8ff42)) +* **docs:** fix example IDs in sessions, agents and vault credentials ([78d815d](https://github.com/anthropics/anthropic-sdk-python/commit/78d815d78b33cfea5f4aa78c57a30b341fa8ff42)) +* **docs:** update Managed Agents multiagent and thread descriptions ([78d815d](https://github.com/anthropics/anthropic-sdk-python/commit/78d815d78b33cfea5f4aa78c57a30b341fa8ff42)) +* **docs:** update the activity summaries endpoint description ([d9bd4bf](https://github.com/anthropics/anthropic-sdk-python/commit/d9bd4bf6f558822e16a2d6a65fd333ee4d3596ab)) +* **docs:** update the description of the Model line field ([4444463](https://github.com/anthropics/anthropic-sdk-python/commit/4444463ed4f434b248e92c6757b60b8b10a66285)) +* **internal:** add REVIEW.md with review instructions ([78d815d](https://github.com/anthropics/anthropic-sdk-python/commit/78d815d78b33cfea5f4aa78c57a30b341fa8ff42)) +* **internal:** format the code blocks of the docs with one run of ruff ([78d815d](https://github.com/anthropics/anthropic-sdk-python/commit/78d815d78b33cfea5f4aa78c57a30b341fa8ff42)) +* **internal:** refactor deprecated models ([7b0c7d5](https://github.com/anthropics/anthropic-sdk-python/commit/7b0c7d5f4febd73b102559cbf7abcf72ff6ea775)) +* **tests:** run tests skipped for a query param bug that is now fixed ([d606929](https://github.com/anthropics/anthropic-sdk-python/commit/d6069290e87ef9fba83d232d0fee94528bae1d3c)) +* **tests:** run tests that an older mock server could not serve ([bffd222](https://github.com/anthropics/anthropic-sdk-python/commit/bffd22228215ef677530b6cbea5756b7a24203d1)) +* **tests:** run the batch results raw and streaming response tests ([#978](https://github.com/anthropics/anthropic-sdk-python/issues/978)) ([78d815d](https://github.com/anthropics/anthropic-sdk-python/commit/78d815d78b33cfea5f4aa78c57a30b341fa8ff42)) +* **tests:** run the tests skipped for incomplete example responses ([06a75bc](https://github.com/anthropics/anthropic-sdk-python/commit/06a75bc59e17e80de9a7fe95f47faf810b48fa46)) + + +### Documentation + +* **api:** state the agent tools limit as 256 ([e4eff6b](https://github.com/anthropics/anthropic-sdk-python/commit/e4eff6b3aa651febf5413894a2e220a05aa32c00)) + ## 1.11.0 (2026-09-30) Full Changelog: [v1.10.0...v1.11.0](https://github.com/anthropics/anthropic-sdk-python/compare/v1.10.0...v1.11.0) diff --git a/README.md b/README.md index ef37f650f..36b1233f9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ The Claude SDK for Python provides access to the [Claude API](https://docs.anthr ## Documentation +Tool helpers (`@beta_tool`, the tool runner and `ToolError`) are documented in [tools.md](tools.md). The browser and computer toolset classes have their own guides, [browser-toolset.md](browser-toolset.md) and [computer-toolset.md](computer-toolset.md). + Full documentation is available at **[platform.claude.com/docs/en/api/sdks/python](https://platform.claude.com/docs/en/api/sdks/python)**. ## Installation diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 000000000..d5fc88a83 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,24 @@ +# Review instructions + +## Report these as Important, as well as functional bugs + +- A violation of this repository's `CLAUDE.md`. Quote the rule +- A new public name, parameter or option that the PR description does not list +- A behavior change that still applies with the PR's feature switched off +- A new runtime check of an API response, or a client-side copy of validation + that the API does +- Code that validates, reorders, defaults or drops part of a request the caller + built +- Code that fails on an enum member, union variant or field this version does + not know +- A fix made to one copy of logic that exists more than once +- A behavior change with no test that fails before it and passes after +- A hand-written list of fields, variants or names that generated code also + carries, with no tripwire test +- A comment that restates the code, or that runs past three lines on a symbol + that is not public + +## Don't report these + +- A missing runtime check of an API response, or missing client-side validation +- What an application can do to its own process, credentials or telemetry diff --git a/api.md b/api.md index 2e42adc7c..adf141aa1 100644 --- a/api.md +++ b/api.md @@ -35,32 +35,92 @@ from anthropic.types import ( BashCodeExecutionToolResultError, BashCodeExecutionToolResultErrorCode, BashCodeExecutionToolResultErrorParam, + BrowserClickTarget, BrowserCloseTabConfigParam, + BrowserCloseTabInput, + BrowserCloseTabToolUseBlock, + BrowserCoordinateTarget, BrowserDoubleClickConfigParam, + BrowserDoubleClickInput, + BrowserDoubleClickToolUseBlock, BrowserFileUploadConfigParam, + BrowserFileUploadInput, + BrowserFileUploadToolUseBlock, BrowserFindConfigParam, + BrowserFindInput, + BrowserFindToolUseBlock, BrowserFormInputConfigParam, + BrowserFormInputInput, + BrowserFormInputToolUseBlock, + BrowserFormInputValue, BrowserGetPageTextConfigParam, + BrowserGetPageTextInput, + BrowserGetPageTextToolUseBlock, BrowserHoldKeyConfigParam, + BrowserHoldKeyInput, + BrowserHoldKeyToolUseBlock, BrowserHoverConfigParam, + BrowserHoverInput, + BrowserHoverToolUseBlock, BrowserJavascriptExecConfigParam, + BrowserJavascriptExecInput, + BrowserJavascriptExecToolUseBlock, BrowserKeyConfigParam, + BrowserKeyInput, + BrowserKeyToolUseBlock, BrowserLeftClickConfigParam, BrowserLeftClickDragConfigParam, + BrowserLeftClickDragInput, + BrowserLeftClickDragToolUseBlock, + BrowserLeftClickInput, + BrowserLeftClickToolUseBlock, BrowserLeftMouseDownConfigParam, + BrowserLeftMouseDownInput, + BrowserLeftMouseDownToolUseBlock, BrowserLeftMouseUpConfigParam, + BrowserLeftMouseUpInput, + BrowserLeftMouseUpToolUseBlock, BrowserListTabsConfigParam, + BrowserListTabsInput, + BrowserListTabsToolUseBlock, + BrowserMemberInput, + BrowserMemberName, BrowserMiddleClickConfigParam, + BrowserMiddleClickInput, + BrowserMiddleClickToolUseBlock, BrowserMouseMoveConfigParam, + BrowserMouseMoveInput, + BrowserMouseMoveToolUseBlock, BrowserNavigateConfigParam, + BrowserNavigateInput, + BrowserNavigateToolUseBlock, BrowserNewTabConfigParam, + BrowserNewTabInput, + BrowserNewTabToolUseBlock, BrowserReadConsoleConfigParam, + BrowserReadConsoleInput, + BrowserReadConsoleToolUseBlock, BrowserReadNetworkConfigParam, + BrowserReadNetworkInput, + BrowserReadNetworkToolUseBlock, BrowserReadPageConfigParam, + BrowserReadPageFilter, + BrowserReadPageInput, + BrowserReadPageToolUseBlock, + BrowserRefTarget, BrowserRightClickConfigParam, + BrowserRightClickInput, + BrowserRightClickToolUseBlock, BrowserScreenshotConfigParam, + BrowserScreenshotInput, + BrowserScreenshotToolUseBlock, BrowserScrollConfigParam, + BrowserScrollDirection, + BrowserScrollInput, BrowserScrollToConfigParam, + BrowserScrollToInput, + BrowserScrollToToolUseBlock, + BrowserScrollToolUseBlock, BrowserStateBlockParam, BrowserStateChangeParam, BrowserStateChangeDownloadCompletedParam, @@ -69,12 +129,23 @@ from anthropic.types import ( BrowserStateChangeTabOpenedParam, BrowserStateTabEntryParam, BrowserSwitchTabConfigParam, + BrowserSwitchTabInput, + BrowserSwitchTabToolUseBlock, + BrowserToolUseBlock, BrowserToolset20260801Param, BrowserToolsetConfigsParam, BrowserTripleClickConfigParam, + BrowserTripleClickInput, + BrowserTripleClickToolUseBlock, BrowserTypeConfigParam, + BrowserTypeInput, + BrowserTypeToolUseBlock, BrowserWaitConfigParam, + BrowserWaitInput, + BrowserWaitToolUseBlock, BrowserZoomConfigParam, + BrowserZoomInput, + BrowserZoomToolUseBlock, CacheControlEphemeralParam, CacheCreation, CacheMissMessagesChanged, @@ -113,24 +184,62 @@ from anthropic.types import ( CodeExecutionToolResultErrorCode, CodeExecutionToolResultErrorParam, ComputerCursorPositionConfigParam, + ComputerCursorPositionInput, + ComputerCursorPositionToolUseBlock, ComputerDoubleClickConfigParam, + ComputerDoubleClickInput, + ComputerDoubleClickToolUseBlock, ComputerHoldKeyConfigParam, + ComputerHoldKeyInput, + ComputerHoldKeyToolUseBlock, ComputerKeyConfigParam, + ComputerKeyInput, + ComputerKeyToolUseBlock, ComputerLeftClickConfigParam, ComputerLeftClickDragConfigParam, + ComputerLeftClickDragInput, + ComputerLeftClickDragToolUseBlock, + ComputerLeftClickInput, + ComputerLeftClickToolUseBlock, ComputerLeftMouseDownConfigParam, + ComputerLeftMouseDownInput, + ComputerLeftMouseDownToolUseBlock, ComputerLeftMouseUpConfigParam, + ComputerLeftMouseUpInput, + ComputerLeftMouseUpToolUseBlock, + ComputerMemberInput, + ComputerMemberName, ComputerMiddleClickConfigParam, + ComputerMiddleClickInput, + ComputerMiddleClickToolUseBlock, ComputerMouseMoveConfigParam, + ComputerMouseMoveInput, + ComputerMouseMoveToolUseBlock, ComputerRightClickConfigParam, + ComputerRightClickInput, + ComputerRightClickToolUseBlock, ComputerScreenshotConfigParam, + ComputerScreenshotInput, + ComputerScreenshotToolUseBlock, ComputerScrollConfigParam, + ComputerScrollDirection, + ComputerScrollInput, + ComputerScrollToolUseBlock, + ComputerToolUseBlock, ComputerToolset20260801Param, ComputerToolsetConfigsParam, ComputerTripleClickConfigParam, + ComputerTripleClickInput, + ComputerTripleClickToolUseBlock, ComputerTypeConfigParam, + ComputerTypeInput, + ComputerTypeToolUseBlock, ComputerWaitConfigParam, + ComputerWaitInput, + ComputerWaitToolUseBlock, ComputerZoomConfigParam, + ComputerZoomInput, + ComputerZoomToolUseBlock, Container, ContainerParams, ContainerSkill, @@ -239,6 +348,8 @@ from anthropic.types import ( ToolUnionParam, ToolUseBlock, ToolUseBlockParam, + ToolUseCaller, + ToolsetToolUseBlock, URLImageSourceParam, URLPDFSourceParam, Usage, @@ -279,6 +390,57 @@ from anthropic.types import ( ContentBlockStartEvent, ContentBlockDeltaEvent, ContentBlockStopEvent, + ResponseToolUseBlockUnion, + ResponseBrowserToolUseBlock, + ResponseBrowserCloseTabToolUseBlock, + ResponseBrowserDoubleClickToolUseBlock, + ResponseBrowserFileUploadToolUseBlock, + ResponseBrowserFindToolUseBlock, + ResponseBrowserFormInputToolUseBlock, + ResponseBrowserGetPageTextToolUseBlock, + ResponseBrowserHoldKeyToolUseBlock, + ResponseBrowserHoverToolUseBlock, + ResponseBrowserJavascriptExecToolUseBlock, + ResponseBrowserKeyToolUseBlock, + ResponseBrowserLeftClickDragToolUseBlock, + ResponseBrowserLeftClickToolUseBlock, + ResponseBrowserLeftMouseDownToolUseBlock, + ResponseBrowserLeftMouseUpToolUseBlock, + ResponseBrowserListTabsToolUseBlock, + ResponseBrowserMiddleClickToolUseBlock, + ResponseBrowserMouseMoveToolUseBlock, + ResponseBrowserNavigateToolUseBlock, + ResponseBrowserNewTabToolUseBlock, + ResponseBrowserReadConsoleToolUseBlock, + ResponseBrowserReadNetworkToolUseBlock, + ResponseBrowserReadPageToolUseBlock, + ResponseBrowserRightClickToolUseBlock, + ResponseBrowserScreenshotToolUseBlock, + ResponseBrowserScrollToToolUseBlock, + ResponseBrowserScrollToolUseBlock, + ResponseBrowserSwitchTabToolUseBlock, + ResponseBrowserTripleClickToolUseBlock, + ResponseBrowserTypeToolUseBlock, + ResponseBrowserWaitToolUseBlock, + ResponseBrowserZoomToolUseBlock, + ResponseComputerToolUseBlock, + ResponseComputerCursorPositionToolUseBlock, + ResponseComputerDoubleClickToolUseBlock, + ResponseComputerHoldKeyToolUseBlock, + ResponseComputerKeyToolUseBlock, + ResponseComputerLeftClickDragToolUseBlock, + ResponseComputerLeftClickToolUseBlock, + ResponseComputerLeftMouseDownToolUseBlock, + ResponseComputerLeftMouseUpToolUseBlock, + ResponseComputerMiddleClickToolUseBlock, + ResponseComputerMouseMoveToolUseBlock, + ResponseComputerRightClickToolUseBlock, + ResponseComputerScreenshotToolUseBlock, + ResponseComputerScrollToolUseBlock, + ResponseComputerTripleClickToolUseBlock, + ResponseComputerTypeToolUseBlock, + ResponseComputerWaitToolUseBlock, + ResponseComputerZoomToolUseBlock, ) ``` @@ -326,6 +488,8 @@ from anthropic.types import ( EffortCapability, ModelCapabilities, ModelInfo, + ModelLine, + ServerToolsCapability, ThinkingCapability, ThinkingTypes, ) @@ -720,6 +884,8 @@ from anthropic.types.beta import ( BetaEffortCapability, BetaModelCapabilities, BetaModelInfo, + BetaModelLine, + BetaServerToolsCapability, BetaThinkingCapability, BetaThinkingTypes, ) @@ -759,58 +925,118 @@ from anthropic.types.beta import ( BetaBashCodeExecutionToolResultBlockParam, BetaBashCodeExecutionToolResultError, BetaBashCodeExecutionToolResultErrorParam, + BetaBrowserClickTarget, BetaBrowserCloseTabConfig, BetaBrowserCloseTabConfigParam, + BetaBrowserCloseTabInput, + BetaBrowserCloseTabToolUseBlock, + BetaBrowserCoordinateTarget, BetaBrowserDoubleClickConfig, BetaBrowserDoubleClickConfigParam, + BetaBrowserDoubleClickInput, + BetaBrowserDoubleClickToolUseBlock, BetaBrowserFileUploadConfig, BetaBrowserFileUploadConfigParam, + BetaBrowserFileUploadInput, + BetaBrowserFileUploadToolUseBlock, BetaBrowserFindConfig, BetaBrowserFindConfigParam, + BetaBrowserFindInput, + BetaBrowserFindToolUseBlock, BetaBrowserFormInputConfig, BetaBrowserFormInputConfigParam, + BetaBrowserFormInputInput, + BetaBrowserFormInputToolUseBlock, + BetaBrowserFormInputValue, BetaBrowserGetPageTextConfig, BetaBrowserGetPageTextConfigParam, + BetaBrowserGetPageTextInput, + BetaBrowserGetPageTextToolUseBlock, BetaBrowserHoldKeyConfig, BetaBrowserHoldKeyConfigParam, + BetaBrowserHoldKeyInput, + BetaBrowserHoldKeyToolUseBlock, BetaBrowserHoverConfig, BetaBrowserHoverConfigParam, + BetaBrowserHoverInput, + BetaBrowserHoverToolUseBlock, BetaBrowserJavascriptExecConfig, BetaBrowserJavascriptExecConfigParam, + BetaBrowserJavascriptExecInput, + BetaBrowserJavascriptExecToolUseBlock, BetaBrowserKeyConfig, BetaBrowserKeyConfigParam, + BetaBrowserKeyInput, + BetaBrowserKeyToolUseBlock, BetaBrowserLeftClickConfig, BetaBrowserLeftClickConfigParam, BetaBrowserLeftClickDragConfig, BetaBrowserLeftClickDragConfigParam, + BetaBrowserLeftClickDragInput, + BetaBrowserLeftClickDragToolUseBlock, + BetaBrowserLeftClickInput, + BetaBrowserLeftClickToolUseBlock, BetaBrowserLeftMouseDownConfig, BetaBrowserLeftMouseDownConfigParam, + BetaBrowserLeftMouseDownInput, + BetaBrowserLeftMouseDownToolUseBlock, BetaBrowserLeftMouseUpConfig, BetaBrowserLeftMouseUpConfigParam, + BetaBrowserLeftMouseUpInput, + BetaBrowserLeftMouseUpToolUseBlock, BetaBrowserListTabsConfig, BetaBrowserListTabsConfigParam, + BetaBrowserListTabsInput, + BetaBrowserListTabsToolUseBlock, + BetaBrowserMemberInput, + BetaBrowserMemberName, BetaBrowserMiddleClickConfig, BetaBrowserMiddleClickConfigParam, + BetaBrowserMiddleClickInput, + BetaBrowserMiddleClickToolUseBlock, BetaBrowserMouseMoveConfig, BetaBrowserMouseMoveConfigParam, + BetaBrowserMouseMoveInput, + BetaBrowserMouseMoveToolUseBlock, BetaBrowserNavigateConfig, BetaBrowserNavigateConfigParam, + BetaBrowserNavigateInput, + BetaBrowserNavigateToolUseBlock, BetaBrowserNewTabConfig, BetaBrowserNewTabConfigParam, + BetaBrowserNewTabInput, + BetaBrowserNewTabToolUseBlock, BetaBrowserReadConsoleConfig, BetaBrowserReadConsoleConfigParam, + BetaBrowserReadConsoleInput, + BetaBrowserReadConsoleToolUseBlock, BetaBrowserReadNetworkConfig, BetaBrowserReadNetworkConfigParam, + BetaBrowserReadNetworkInput, + BetaBrowserReadNetworkToolUseBlock, BetaBrowserReadPageConfig, BetaBrowserReadPageConfigParam, + BetaBrowserReadPageFilter, + BetaBrowserReadPageInput, + BetaBrowserReadPageToolUseBlock, + BetaBrowserRefTarget, BetaBrowserRightClickConfig, BetaBrowserRightClickConfigParam, + BetaBrowserRightClickInput, + BetaBrowserRightClickToolUseBlock, BetaBrowserScreenshotConfig, BetaBrowserScreenshotConfigParam, + BetaBrowserScreenshotInput, + BetaBrowserScreenshotToolUseBlock, BetaBrowserScrollConfig, BetaBrowserScrollConfigParam, + BetaBrowserScrollDirection, + BetaBrowserScrollInput, BetaBrowserScrollToConfig, BetaBrowserScrollToConfigParam, + BetaBrowserScrollToInput, + BetaBrowserScrollToToolUseBlock, + BetaBrowserScrollToolUseBlock, BetaBrowserStateBlockParam, BetaBrowserStateChangeParam, BetaBrowserStateChangeDownloadCompletedParam, @@ -820,18 +1046,29 @@ from anthropic.types.beta import ( BetaBrowserStateTabEntryParam, BetaBrowserSwitchTabConfig, BetaBrowserSwitchTabConfigParam, + BetaBrowserSwitchTabInput, + BetaBrowserSwitchTabToolUseBlock, + BetaBrowserToolUseBlock, BetaBrowserToolset20260801, BetaBrowserToolset20260801Param, BetaBrowserToolsetConfigs, BetaBrowserToolsetConfigsParam, BetaBrowserTripleClickConfig, BetaBrowserTripleClickConfigParam, + BetaBrowserTripleClickInput, + BetaBrowserTripleClickToolUseBlock, BetaBrowserTypeConfig, BetaBrowserTypeConfigParam, + BetaBrowserTypeInput, + BetaBrowserTypeToolUseBlock, BetaBrowserWaitConfig, BetaBrowserWaitConfigParam, + BetaBrowserWaitInput, + BetaBrowserWaitToolUseBlock, BetaBrowserZoomConfig, BetaBrowserZoomConfigParam, + BetaBrowserZoomInput, + BetaBrowserZoomToolUseBlock, BetaCacheControlEphemeral, BetaCacheControlEphemeralParam, BetaCacheCreation, @@ -887,42 +1124,80 @@ from anthropic.types.beta import ( BetaCompactionIterationUsage, BetaComputerCursorPositionConfig, BetaComputerCursorPositionConfigParam, + BetaComputerCursorPositionInput, + BetaComputerCursorPositionToolUseBlock, BetaComputerDoubleClickConfig, BetaComputerDoubleClickConfigParam, + BetaComputerDoubleClickInput, + BetaComputerDoubleClickToolUseBlock, BetaComputerHoldKeyConfig, BetaComputerHoldKeyConfigParam, + BetaComputerHoldKeyInput, + BetaComputerHoldKeyToolUseBlock, BetaComputerKeyConfig, BetaComputerKeyConfigParam, + BetaComputerKeyInput, + BetaComputerKeyToolUseBlock, BetaComputerLeftClickConfig, BetaComputerLeftClickConfigParam, BetaComputerLeftClickDragConfig, BetaComputerLeftClickDragConfigParam, + BetaComputerLeftClickDragInput, + BetaComputerLeftClickDragToolUseBlock, + BetaComputerLeftClickInput, + BetaComputerLeftClickToolUseBlock, BetaComputerLeftMouseDownConfig, BetaComputerLeftMouseDownConfigParam, + BetaComputerLeftMouseDownInput, + BetaComputerLeftMouseDownToolUseBlock, BetaComputerLeftMouseUpConfig, BetaComputerLeftMouseUpConfigParam, + BetaComputerLeftMouseUpInput, + BetaComputerLeftMouseUpToolUseBlock, + BetaComputerMemberInput, + BetaComputerMemberName, BetaComputerMiddleClickConfig, BetaComputerMiddleClickConfigParam, + BetaComputerMiddleClickInput, + BetaComputerMiddleClickToolUseBlock, BetaComputerMouseMoveConfig, BetaComputerMouseMoveConfigParam, + BetaComputerMouseMoveInput, + BetaComputerMouseMoveToolUseBlock, BetaComputerRightClickConfig, BetaComputerRightClickConfigParam, + BetaComputerRightClickInput, + BetaComputerRightClickToolUseBlock, BetaComputerScreenshotConfig, BetaComputerScreenshotConfigParam, + BetaComputerScreenshotInput, + BetaComputerScreenshotToolUseBlock, BetaComputerScrollConfig, BetaComputerScrollConfigParam, + BetaComputerScrollDirection, + BetaComputerScrollInput, + BetaComputerScrollToolUseBlock, + BetaComputerToolUseBlock, BetaComputerToolset20260801, BetaComputerToolset20260801Param, BetaComputerToolsetConfigs, BetaComputerToolsetConfigsParam, BetaComputerTripleClickConfig, BetaComputerTripleClickConfigParam, + BetaComputerTripleClickInput, + BetaComputerTripleClickToolUseBlock, BetaComputerTypeConfig, BetaComputerTypeConfigParam, + BetaComputerTypeInput, + BetaComputerTypeToolUseBlock, BetaComputerWaitConfig, BetaComputerWaitConfigParam, + BetaComputerWaitInput, + BetaComputerWaitToolUseBlock, BetaComputerZoomConfig, BetaComputerZoomConfigParam, + BetaComputerZoomInput, + BetaComputerZoomToolUseBlock, BetaContainer, BetaContainerParams, BetaContainerSkill, @@ -1108,8 +1383,10 @@ from anthropic.types.beta import ( BetaToolUnionParam, BetaToolUseBlock, BetaToolUseBlockParam, + BetaToolUseCaller, BetaToolUsesKeepParam, BetaToolUsesTriggerParam, + BetaToolsetToolUseBlock, BetaURLImageSourceParam, BetaURLPDFSourceParam, BetaUsage, @@ -1157,6 +1434,57 @@ from anthropic.types.beta import ( BetaWebSearchToolResultBlockParamContentParam, BetaWebSearchToolResultError, BetaWebSearchToolResultErrorCode, + BetaResponseToolUseBlockUnion, + BetaResponseBrowserToolUseBlock, + BetaResponseBrowserCloseTabToolUseBlock, + BetaResponseBrowserDoubleClickToolUseBlock, + BetaResponseBrowserFileUploadToolUseBlock, + BetaResponseBrowserFindToolUseBlock, + BetaResponseBrowserFormInputToolUseBlock, + BetaResponseBrowserGetPageTextToolUseBlock, + BetaResponseBrowserHoldKeyToolUseBlock, + BetaResponseBrowserHoverToolUseBlock, + BetaResponseBrowserJavascriptExecToolUseBlock, + BetaResponseBrowserKeyToolUseBlock, + BetaResponseBrowserLeftClickDragToolUseBlock, + BetaResponseBrowserLeftClickToolUseBlock, + BetaResponseBrowserLeftMouseDownToolUseBlock, + BetaResponseBrowserLeftMouseUpToolUseBlock, + BetaResponseBrowserListTabsToolUseBlock, + BetaResponseBrowserMiddleClickToolUseBlock, + BetaResponseBrowserMouseMoveToolUseBlock, + BetaResponseBrowserNavigateToolUseBlock, + BetaResponseBrowserNewTabToolUseBlock, + BetaResponseBrowserReadConsoleToolUseBlock, + BetaResponseBrowserReadNetworkToolUseBlock, + BetaResponseBrowserReadPageToolUseBlock, + BetaResponseBrowserRightClickToolUseBlock, + BetaResponseBrowserScreenshotToolUseBlock, + BetaResponseBrowserScrollToToolUseBlock, + BetaResponseBrowserScrollToolUseBlock, + BetaResponseBrowserSwitchTabToolUseBlock, + BetaResponseBrowserTripleClickToolUseBlock, + BetaResponseBrowserTypeToolUseBlock, + BetaResponseBrowserWaitToolUseBlock, + BetaResponseBrowserZoomToolUseBlock, + BetaResponseComputerToolUseBlock, + BetaResponseComputerCursorPositionToolUseBlock, + BetaResponseComputerDoubleClickToolUseBlock, + BetaResponseComputerHoldKeyToolUseBlock, + BetaResponseComputerKeyToolUseBlock, + BetaResponseComputerLeftClickDragToolUseBlock, + BetaResponseComputerLeftClickToolUseBlock, + BetaResponseComputerLeftMouseDownToolUseBlock, + BetaResponseComputerLeftMouseUpToolUseBlock, + BetaResponseComputerMiddleClickToolUseBlock, + BetaResponseComputerMouseMoveToolUseBlock, + BetaResponseComputerRightClickToolUseBlock, + BetaResponseComputerScreenshotToolUseBlock, + BetaResponseComputerScrollToolUseBlock, + BetaResponseComputerTripleClickToolUseBlock, + BetaResponseComputerTypeToolUseBlock, + BetaResponseComputerWaitToolUseBlock, + BetaResponseComputerZoomToolUseBlock, ) ``` @@ -1266,6 +1594,25 @@ from anthropic.types.beta import ( BetaManagedAgentsUserLocationParam, BetaManagedAgentsWebFetchToolConfig, BetaManagedAgentsWebFetchToolConfigParams, + BetaManagedAgentsWebFetchURLSourceAll, + BetaManagedAgentsWebFetchURLSourceAllParam, + BetaManagedAgentsWebFetchURLSourceExcept, + BetaManagedAgentsWebFetchURLSourceExceptParam, + BetaManagedAgentsWebFetchURLSourceNone, + BetaManagedAgentsWebFetchURLSourceNoneParam, + BetaManagedAgentsWebFetchURLSourceOnly, + BetaManagedAgentsWebFetchURLSourceOnlyParam, + BetaManagedAgentsWebFetchURLSourceShorthand, + BetaManagedAgentsWebFetchURLSourceToolFilter, + BetaManagedAgentsWebFetchURLSourceToolFilterParam, + BetaManagedAgentsWebFetchURLSourceToolFilterParams, + BetaManagedAgentsWebFetchURLSourceToolReference, + BetaManagedAgentsWebFetchURLSourceToolReferenceParam, + BetaManagedAgentsWebFetchURLSourceUserInput, + BetaManagedAgentsWebFetchURLSourceUserInputParam, + BetaManagedAgentsWebFetchURLSourceUserInputParams, + BetaManagedAgentsWebFetchURLSources, + BetaManagedAgentsWebFetchURLSourcesParams, BetaManagedAgentsWebSearchToolConfig, BetaManagedAgentsWebSearchToolConfigParams, BetaManagedAgentsWriteToolConfig, diff --git a/browser-toolset.md b/browser-toolset.md new file mode 100644 index 000000000..0938a26f0 --- /dev/null +++ b/browser-toolset.md @@ -0,0 +1,189 @@ +# Browser toolset + +`browser_toolset_20260801` is a *toolset*: one `tools[]` entry that declares a family of member tools (`navigate`, `screenshot`, `left_click`, …). Each call arrives as a `tool_use` block whose `name` is the member and whose `toolset_name` is `"browser"`. + +The SDK ships no browser driver and no URL policy. It ships the abstract class a driver subclasses. Before a call reaches the driver, the SDK runs your `url_policy` (on `navigate`), your `file_policy` (on `file_upload`) and then your `confirm` (on every call). An example driver for the Chrome DevTools Protocol is in [claude-quickstarts](https://github.com/anthropics/claude-quickstarts). + +## Quick start + +```py +from anthropic import Anthropic +from my_browser import MyBrowser, example_policy # your driver and your URL policy, both shown below + +with MyBrowser(backend, url_policy=example_policy(["example.com", "iana.org"])) as browser: + runner = Anthropic().beta.messages.tool_runner( + model="claude-sonnet-5", + max_tokens=1024, + tools=[browser], + messages=[{"role": "user", "content": "Open example.com and tell me the page heading."}], + ) + for message in runner: + print(message) +``` + +- You close the browser. The runner ([tools.md](tools.md#tool-runner)) never does, so one instance can serve several runs. +- The runner runs a turn's browser actions in order and stops at the first one that fails or is refused. Other tools in the turn still run. +- Before you deploy a driver, read [Running a browser toolset safely](#running-a-browser-toolset-safely). + +Without the runner, pass `browser` in the `tools` of `client.beta.messages.create()` and answer each browser `tool_use` in the reply (`message` below) with `browser.tool_result(tool_use)`. A refusal or failure comes back as an `is_error` result, and your loop must not run the turn's later browser actions: + +```py +from anthropic.types.beta import BetaToolResultBlockParam + +uses = [b for b in message.content if b.type == "tool_use" and b.toolset_name == browser.toolset_name] +results: list[BetaToolResultBlockParam] = [] +for use in uses: + if results and results[-1].get("is_error"): # a skipped tool_use still needs a result + results.append({ + "type": "tool_result", "tool_use_id": use.id, "toolset_name": use.toolset_name, "is_error": True, + "content": "Not executed: an earlier action in this turn failed.", + }) + else: + results.append(browser.tool_result(use)) +``` + +## Implement a driver + +```py +from anthropic.tools.browser import ( + BetaAbstractBrowserToolset20260801, BetaBrowserNavigateResult, BetaBrowserState, + BetaScreenshotResult, BetaToolsetCallContext, +) +from anthropic.types.beta import BetaBrowserLeftClickInput, BetaBrowserNavigateInput, BetaBrowserScreenshotInput + +class MyBrowser(BetaAbstractBrowserToolset20260801): + def __init__(self, backend, **options): + super().__init__(**options) + self.backend = backend # whatever reaches your browser: a DevTools client, a hosted browser's API + + def _browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + return BetaBrowserState( + tabs=[{"tab_id": t.id, "title": t.title, "url": t.url, "active": t.active} for t in self.backend.tabs()], + state_changes=self.backend.drain_changes(), + ) + + def navigate(self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput) -> BetaBrowserNavigateResult: + page = self.backend.goto(input.url, input.tab_id) # as the model wrote it: see Addresses + return BetaBrowserNavigateResult(url=page.url, status=page.status, title=page.title) + + def screenshot(self, context: BetaToolsetCallContext, input: BetaBrowserScreenshotInput) -> BetaScreenshotResult: + return BetaScreenshotResult(data=self.backend.png_base64(input.tab_id)) + + def left_click(self, context: BetaToolsetCallContext, input: BetaBrowserLeftClickInput) -> None: + self.backend.click(input.target, input.tab_id) + + def close(self) -> None: + super().close() # first: it waits for calls in flight + self.backend.close() +``` + +Override the members your backend supports. A member you don't override is sent to the API as `enabled: false`. `input` is the parsed `BetaBrowserInput`, and its docstring is the member's contract. For `AsyncAnthropic`, subclass `BetaAsyncAbstractBrowserToolset20260801`, whose members, `_browser_state` and `close` are `async def`. Import the names in this guide from `anthropic.tools.browser`, the errors from `anthropic.tools`, and the generated inputs, blocks and `*Param` types from `anthropic.types.beta`. + +Members: `navigate`, `list_tabs`, `new_tab`, `switch_tab`, `close_tab`, `read_page`, `get_page_text`, `read_console`, `read_network`, `find`, `form_input`, `file_upload`, `scroll_to`, `screenshot`, `zoom`, `left_click`, `right_click`, `middle_click`, `double_click`, `triple_click`, `hover`, `left_click_drag`, `left_mouse_down`, `left_mouse_up`, `mouse_move`, `scroll`, `type`, `key`, `hold_key`, `wait`, `javascript_exec`. + +- **State.** Every driver implements `_browser_state(context)`, its report of the browser. The SDK calls it after every call, failed and refused ones included. Return every open tab and drain your state changes: tabs opened and downloads (the generated `BetaBrowserStateChange*Param` types), and a `BetaDialogDismissed(kind=..., message=...)` for every native dialog you dismiss, since no member answers a dialog. Catch failures inside it: an exception from it stops the run. +- **Tabs.** Mark exactly one tab `active`, truthfully, and honor `tab_id` in every member that takes one: `confirm` is shown the tab URL from your last report, not from the browser. After `new_tab`, the new tab must be the only active one, or the call fails. +- **Results.** Each member's signature in the base class gives its return type. A string is text for the model: at most one line from an action such as a click. The SDK sends the text of a reading member such as `get_page_text` uncut, so bound its length. The model does not see what `new_tab`, `switch_tab` and `list_tabs` return: it reads the `browser_state` block. +- **Addresses.** `navigate` receives `input.url` as the model wrote it, or the word `back`, `forward` or `reload`. Read the address the way your `url_policy` does: add `https://` when it has no scheme, and refuse every other scheme you don't mean to open, such as `javascript:`, `view-source:`, `data:` and `file:`. +- **Coordinates.** Coordinates are viewport pixels, in the frame of a full-viewport screenshot. Capture the viewport, not the full page, at device scale factor 1, or scale the coordinates yourself. The SDK never resizes an image, and the API rejects one over [the model's image limits](https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool#handle-coordinate-scaling-for-higher-resolutions). +- **Input values.** The SDK checks the types in `input`, not the values. Raise `ToolError` for a `wait` or `hold_key` `duration` longer than you support. +- **Errors.** When a member raises `ToolError` or any other exception, the model reads its text, unredacted, and the run continues. Keep paths and URLs out of it and use fixed phrases, in `download_failed.error` too: never `str(exc)`. A `ToolsetUsageError`, a mistake in your code or options, propagates and stops the run. +- **Try it without the API.** Pass a hand-built `BetaToolUseBlock` to `tool_result()`, which runs the policies and `confirm` too: `browser.tool_result(BetaToolUseBlock(type="tool_use", id="toolu_1", toolset_name="browser", name="navigate", input={"url": "example.com"}))`. + +## Options + +To change an option, build a new toolset and a new runner. An exception from `url_policy`, `file_policy` or `confirm` refuses the call. + +| option | what it is | left unset | +|---|---|---| +| `configs` | `{"": {"enabled": bool}}`, which turns a member on or off | `file_upload`, `read_console`, `read_network` and `javascript_exec` stay off | +| `url_policy` | `(context, url) -> None`, your check on the address of each `navigate` | `navigate` is not checked | +| `file_policy` | a `BetaFilePolicy`, such as `BetaLocalFilePolicy` | uploads are refused, and download paths stay hidden | +| `confirm` | `(context) -> bool`, asked before every call | nothing is asked | +| `tool_configs` | other fields of the `tools[]` entry, such as `{"cache_control": {"type": "ephemeral"}}` | none are sent | + +**Approval.** Only `True` from `confirm(context)` approves: any other answer refuses the call (raise `ToolError` to word the refusal). Return `True` for the members you don't gate. `javascript_exec` and `file_upload` cannot be enabled without a `confirm`. + +`context.tab_url` is the target tab's URL in the driver's last report. The page may have moved since, and the approved call runs wherever the tab is by then. `None` (the first call, a tab the report does not list) means the page is unknown, not that the tab is empty. If you remember approvals per site, ask again whenever the URL is missing or has no host (`about:blank`, a browser error page). + +Show the approver the member, the page and the input, with every invisible character escaped (in `tab_url` too): the model may be relaying page content, and what the approver reads must be what runs. + +```py +import json +from anthropic.tools.browser import BetaConfirmContext + +def confirm(context: BetaConfirmContext) -> bool: + if context.member not in {"javascript_exec", "file_upload"}: + return True + # json.dumps escapes every non-ASCII and control character; ask_user is how you ask a person + page = json.dumps(context.tab_url) if context.tab_url else "an unknown page" + return ask_user(f"Allow {context.member} on {page}?\n{json.dumps(context.input.to_dict(), indent=2)}") +``` + +**Hooks.** Override `execute(context, name, input)` and call `super().execute(...)` to run code around every call, or skip `super()` to forward every call to a remote browser. It runs after the policies and `confirm`, so nothing checks an input you change there. Overriding `execute` marks every member as served, so the model is offered every member that is on by default: turn off the ones you don't serve in `configs`. + +## Running a browser toolset safely + +The pages the model visits steer its actions: a page can try to point the browser at internal services, pull files off the host or trigger consequential actions. Do all of this before you run a driver against anything but a throwaway profile. + +1. **Pass a `url_policy`.** Left unset, `navigate` is not checked. See [URL policy](#url-policy). +2. **Intercept requests in the driver.** The policy sees only the address the model asks for: not a redirect, a subresource or a navigation a page starts. The toolset does not expose the policy, so keep it (`self._policy = options.get("url_policy")`) and call it from your request handler as `self._policy(BetaURLContext(), url)`. An async policy refuses nothing unless you await it. Abort the request on any exception, and report a page-started navigation you refuse as a `BetaNavigationRefused()` state change. +3. **Put egress rules on the browser's container.** Allow only the hosts the task needs. This is the backstop: interception does not see every request. An open DevTools port is unauthenticated full control of the browser and bypasses the policies and `confirm`, so let only the model loop's host reach it. +4. **Pass a `file_policy`, or leave `file_upload` off.** `file_upload` lets the page the model is browsing read files from the browser host. See [File policy](#file-policy). +5. **Gate consequential members with `confirm`.** It sees the member, its input and the tab's URL, not what a click does. Ask a person for `javascript_exec` and `file_upload` everywhere, and for clicks, `type`, `key` and `form_input` on sites where purchases, messages or accepting terms can happen. +6. **Isolate the browser.** Run it in one container or VM per session, with a fresh profile, no credentials, no mounts beyond the upload roots and the download directory, and no filesystem shared with other tools the model can call. Keep the model loop, the toolset and the API key outside it. +7. **Treat everything a page returns as untrusted.** That includes titles, URLs, file names and downloaded files: never execute, store as trusted or forward them unchecked. URLs reach the model and `confirm` unredacted, with any credentials and `data:` bodies. + +**Hosted browsers.** Egress rules and isolation (3, 6) are the provider's, so a request your interception misses can reach whatever the provider's network can. A hosted browser cannot mount your upload roots: refuse path uploads and use `document_ids`, or check paths where the browser runs. + +### URL policy + +`url_policy(context, url)` returns `None` to allow and raises `ToolError` to refuse. `url` is the string exactly as the model wrote it: not trimmed, not normalized, no scheme added. `back`, `forward` and `reload` are not put to the policy. + +```py +import re +from urllib.parse import urlsplit +from anthropic.tools import ToolError +from anthropic.tools.browser import BetaURLContext, BetaURLPolicy + +def example_policy(allowed_hosts: list[str]) -> BetaURLPolicy: + """An example, not a production policy: http(s) pages on the allowed hosts or their subdomains.""" + hosts = [host for host in (entry.strip().lower().rstrip(".") for entry in allowed_hosts) if host] + + def policy(_context: BetaURLContext, url: str) -> None: + # A browser, or a strip() in your driver, drops these before the scheme is read: " file:" would pass below. + if re.search(r"[\s\x00-\x1f\x7f]", url): + raise ToolError("blocked: the address contains whitespace or a control character") + # Judge what the browser will open: no scheme means https://, and a backslash reads as a slash. + with_scheme = url if re.match(r"[a-z][a-z0-9+.-]*:", url, re.I) else f"https://{url}" + parts = urlsplit(with_scheme.replace("\\", "/")) # a parse error raises, which also refuses + host = (parts.hostname or "").lower() + if parts.scheme not in ("http", "https") or not any(host == h or host.endswith("." + h) for h in hosts): + raise ToolError(f"blocked: {url} is not on an allowed host") + + return policy +``` + +A production policy needs more: parse the address the way a browser reads it, and account for the many spellings one host or IP address has. This one reads a bare `localhost:3000` as a scheme and refuses it. + +### File policy + +`BetaLocalFilePolicy` is the `file_policy` the SDK ships. It judges paths on the machine that runs the SDK, so mount the upload roots at the same path in the browser's container. To write your own, implement `BetaFilePolicy` and build on `beta_check_upload_path(path, roots)`. + +```py +from anthropic.tools.browser import BetaLocalFilePolicy + +browser = MyBrowser( + backend, + configs={"file_upload": {"enabled": True}}, # MyBrowser must also implement file_upload + confirm=confirm, + file_policy=BetaLocalFilePolicy( + upload_roots=["/task/uploads"], + upload_document_ids=["file_011CNha8iCJcU1wXNR6q4V8w"], # Files API documents the model may attach + download_dir="/task/downloads", + ), +) +``` + +- Create the upload roots first: a missing root grants nothing. Let nothing the model can reach write to them, because the check runs once, before the driver opens the file. +- Point the driver at one dedicated download directory, outside every upload root and out of reach of the model's other tools. `download_dir` does not move downloads: it bounds the paths the model may see, and only with `expose_download_paths=True`. diff --git a/computer-toolset.md b/computer-toolset.md new file mode 100644 index 000000000..6a96470f5 --- /dev/null +++ b/computer-toolset.md @@ -0,0 +1,100 @@ +# Computer toolset + +`computer_toolset_20260801` is one `tools[]` entry that gives the model a family of desktop tools (`screenshot`, `left_click`, `type`, …), which the SDK's identifiers call *members*. The SDK ships no desktop driver: you subclass `BetaAbstractComputerToolset20260801` and implement the tools your backend supports. + +## Quick start + +```py +from anthropic import Anthropic +from my_desktop import MyDesktop, confirm # both shown below + +with MyDesktop(backend, confirm=confirm) as desktop: + runner = Anthropic().beta.messages.tool_runner( + model="claude-sonnet-5", + max_tokens=1024, + tools=[desktop], + messages=[{"role": "user", "content": "Open the calculator and compute 17 * 23."}], + ) + for message in runner: + print(message) +``` + +- You close the desktop. The runner ([tools.md](tools.md#tool-runner)) never does. +- The runner runs a turn's computer actions in order and stops at the first one that fails or is refused. Other tools in the turn still run. + +Without the runner, pass `desktop` in the `tools` of `client.beta.messages.create()` and answer each computer `tool_use` in the reply (`message` below) with `desktop.tool_result(tool_use)`. A refusal or failure comes back as an `is_error` result, and your loop must not run the turn's later computer actions: + +```py +from anthropic.types.beta import BetaToolResultBlockParam + +uses = [b for b in message.content if b.type == "tool_use" and b.toolset_name == desktop.toolset_name] +results: list[BetaToolResultBlockParam] = [] +for use in uses: + if results and results[-1].get("is_error"): # a skipped tool_use still needs a result + results.append({ + "type": "tool_result", "tool_use_id": use.id, "toolset_name": use.toolset_name, "is_error": True, + "content": "Not executed: an earlier computer action in this turn failed.", + }) + else: + results.append(desktop.tool_result(use)) +``` + +## Implement a driver + +```py +from anthropic.tools.computer import BetaAbstractComputerToolset20260801, BetaScreenshotResult, BetaToolsetCallContext +from anthropic.types.beta import BetaComputerLeftClickInput, BetaComputerScreenshotInput, BetaComputerTypeInput + +class MyDesktop(BetaAbstractComputerToolset20260801): + def __init__(self, backend, **options): + super().__init__(**options) + self.backend = backend # whatever reaches your desktop: a VNC client, a remote-desktop API, xdotool + + def screenshot(self, context: BetaToolsetCallContext, input: BetaComputerScreenshotInput) -> BetaScreenshotResult: + return BetaScreenshotResult(data=self.backend.png_base64()) + + def left_click(self, context: BetaToolsetCallContext, input: BetaComputerLeftClickInput) -> None: + self.backend.click(input.coordinate, input.text) # see Coordinates + + def type(self, context: BetaToolsetCallContext, input: BetaComputerTypeInput) -> None: + self.backend.type(input.text) + + def close(self) -> None: + super().close() # first: it waits for calls in flight + self.backend.close() +``` + +Override the tools your backend supports: `key`, `hold_key`, `type`, `cursor_position`, `mouse_move`, `left_mouse_down`, `left_mouse_up`, `left_click`, `left_click_drag`, `right_click`, `middle_click`, `double_click`, `triple_click`, `scroll`, `wait`, `screenshot`, `zoom`. `input` is the parsed `BetaComputerInput` from `anthropic.types.beta`, and its docstring is the tool's contract. A tool you don't override is sent to the API as `enabled: false`. For `AsyncAnthropic`, subclass `BetaAsyncAbstractComputerToolset20260801`, whose tools and `close` are `async def`. + +- **Results.** `screenshot` and `zoom` return a `BetaScreenshotResult`, and `cursor_position` a `BetaComputerCursorPositionResult`. Every other tool returns nothing, or one line of text for the model. The SDK never resizes an image, and the API rejects one over [the model's image limits](https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool#handle-coordinate-scaling-for-higher-resolutions). Capture a `zoom` region at display resolution, not from the scaled screenshot. +- **Coordinates.** Every coordinate the model sends is in pixels of the full screenshot, not of the display or a `zoom` image. Your driver scales them to display pixels, reports `cursor_position` back in screenshot pixels (rounded to `int`), and keeps the capture size fixed for the session. +- **Input values.** The SDK checks the types in `input`, not the values. Raise `ToolError` for a coordinate outside the display (don't clamp it) and for a `duration`, `repeat` or `scroll_amount` your backend should not honor. +- **Errors.** When a tool raises `ToolError` (from `anthropic.tools`) or any other exception, the model reads its text, unredacted, and the run continues. Keep error text to fixed phrases: an exception's message can carry screen content, a host name or a session token. A `ToolsetUsageError`, a mistake in your code or options, propagates and stops the run. +- **Try it without the API.** Pass a hand-built `BetaToolUseBlock` (from `anthropic.types.beta`) to `tool_result()`: `desktop.tool_result(BetaToolUseBlock(type="tool_use", id="toolu_1", toolset_name="computer", name="left_click", input={"coordinate": [640, 360]}))`. + +## Options + +- `configs={"zoom": {"enabled": False}}` turns a tool off. +- `tool_configs={"cache_control": {"type": "ephemeral"}}` sets `cache_control` on the `tools[]` entry. +- `confirm=confirm` is asked before each call runs, and only `True` approves: any other answer refuses the call, and so does an exception (raise `ToolError` to word the refusal). Without one, nothing is asked, clicks included. The constructor requires one while `type`, `key` or `hold_key` is enabled. Show the approver the tool and its input, with every invisible character escaped: the model may be relaying screen content, and what the approver reads must be what runs. + +```py +import json +from anthropic.tools.computer import BetaComputerConfirmContext + +def confirm(context: BetaComputerConfirmContext) -> bool: + if context.member not in {"type", "key", "hold_key"}: + return True + # json.dumps escapes every non-ASCII and control character + return ask_user(f"Allow {context.member}?\n{json.dumps(context.input.to_dict(), indent=2)}") +``` + +**Hooks.** Override `execute(context, name, input)` and call `super().execute(...)` to run code around every call, or skip `super()` to forward every call to a remote desktop. It runs after `confirm`, so nothing checks an input you change there. Overriding `execute` marks every tool as served, so the model is offered all of them: turn off the ones you don't serve in `configs`. + +## Running a computer toolset safely + +What is on the screen steers the model: a page, a document or a message can try to make it type into the wrong window or trigger consequential actions. Before you run a driver against anything but a throwaway machine: + +1. **Isolate the desktop.** Run it in one container or VM per session, with no credentials or host mounts and egress only to the hosts the task needs. Keep the model loop, the toolset and the API key outside it. +2. **Gate consequential actions with `confirm`.** It sees the tool and its input, not what a click does on the screen. If the desktop has applications that can send, pay or delete, have a person approve clicks too. With a terminal, a run dialog or a launcher focused, whatever is typed runs as a command. +3. **Treat the screen as untrusted.** Never execute, store as trusted or forward screen contents, window titles or clipboard text unchecked. diff --git a/pyproject.toml b/pyproject.toml index 71d92aa63..6e6a4561c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "anthropic" -version = "1.11.0" +version = "1.12.0" description = "The official Python library for the anthropic API" dynamic = ["readme"] license = "MIT" @@ -221,3 +221,6 @@ known-first-party = ["anthropic", "tests"] "scripts/**.py" = ["T201", "T203"] "tests/**.py" = ["T201", "T203"] "examples/**.py" = ["T201", "T203"] +# the abstract toolsets' default member bodies ignore their arguments; subclasses use them +"src/anthropic/lib/tools/_toolsets/_browser.py" = ["ARG002"] +"src/anthropic/lib/tools/_toolsets/_computer.py" = ["ARG002"] diff --git a/requirements-dev.lock b/requirements-dev.lock index 569fd6045..d284df368 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -57,7 +57,7 @@ httpx==0.28.1 # respx httpx2==2.9.1 # via anthropic -hypothesis==6.165.10 +hypothesis==6.167.1 idna==3.18 # via # anyio diff --git a/scripts/mock-spec.json.gz b/scripts/mock-spec.json.gz index 0373316a6..0573ff2dc 100644 Binary files a/scripts/mock-spec.json.gz and b/scripts/mock-spec.json.gz differ diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py index 9bbb53bb5..7e12cc806 100644 --- a/scripts/utils/ruffen-docs.py +++ b/scripts/utils/ruffen-docs.py @@ -1,13 +1,15 @@ # fork of https://github.com/asottile/blacken-docs adapted for ruff from __future__ import annotations +import os import re import sys import argparse +import tempfile import textwrap import contextlib import subprocess -from typing import Match, Optional, Sequence, Generator, NamedTuple, cast +from typing import Match, Callable, Optional, Sequence, Generator, NamedTuple, cast MD_RE = re.compile( r"(?P^(?P *)```\s*python\n)" r"(?P.*?)" r"(?P^(?P=indent)```\s*$)", @@ -32,6 +34,7 @@ class CodeBlockError(NamedTuple): def format_str( src: str, + format_code_block: Callable[[str], str], ) -> tuple[str, Sequence[CodeBlockError]]: errors: list[CodeBlockError] = [] @@ -119,13 +122,44 @@ def format_code_block(code: str) -> str: ) +def format_code_blocks(blocks: Sequence[str]) -> dict[str, str]: + """Formats all the given blocks with one run of ruff, as starting ruff takes far longer than formatting a block. + + Returns nothing if ruff can't format one of them. `format_code_block` then says which one. + """ + # without a path, ruff formats the whole directory + if not blocks: + return {} + + # in this directory, so that ruff finds the same config as for `script.py` + with tempfile.TemporaryDirectory(prefix=".ruffen-docs-", dir=os.curdir) as directory: + paths = [os.path.join(directory, f"{index}.py") for index in range(len(blocks))] + for index, block in enumerate(blocks): + with open(paths[index], "w", encoding="UTF-8") as f: + f.write(block) + + result = subprocess.run( + [sys.executable, "-m", "ruff", "format", "--quiet", f"--line-length={DEFAULT_LINE_LENGTH}", *paths], + stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + return {} + + formatted: dict[str, str] = {} + for index, block in enumerate(blocks): + with open(paths[index], encoding="UTF-8") as f: + formatted[block] = f.read() + return formatted + + def format_file( filename: str, skip_errors: bool, + format_code_block: Callable[[str], str], ) -> int: with open(filename, encoding="UTF-8") as f: contents = f.read() - new_contents, errors = format_str(contents) + new_contents, errors = format_str(contents, format_code_block) for error in errors: lineno = contents[: error.offset].count("\n") + 1 print(f"{filename}:{lineno}: code block parse error {error.exc}") @@ -157,9 +191,24 @@ def main(argv: Sequence[str] | None = None) -> int: parser.add_argument("filenames", nargs="*") args = parser.parse_args(argv) + blocks: list[str] = [] + + def collect(code: str) -> str: + blocks.append(code) + return code + + for filename in args.filenames: + with open(filename, encoding="UTF-8") as f: + format_str(f.read(), collect) + + formatted = format_code_blocks(blocks) + + def format_collected(code: str) -> str: + return formatted[code] if code in formatted else format_code_block(code) + retv = 0 for filename in args.filenames: - retv |= format_file(filename, skip_errors=args.skip_errors) + retv |= format_file(filename, skip_errors=args.skip_errors, format_code_block=format_collected) return retv diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index 75dab40f7..dc90c8b5f 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -405,7 +405,8 @@ class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]): _version: str _base_url: URL max_retries: int - timeout: Union[float, Timeout, None] + _timeout: Union[float, Timeout, None] + _timeout_overridden: bool _strict_response_validation: bool _idempotency_header: str | None _default_stream_cls: type[_DefaultStreamT] | None = None @@ -428,7 +429,8 @@ def __init__( self._version = version self._base_url = self._enforce_trailing_slash(URL(base_url)) self.max_retries = max_retries - self.timeout = timeout + self._timeout = timeout + self._timeout_overridden = False self._custom_headers = custom_headers or {} self._custom_query = custom_query or {} self._strict_response_validation = _strict_response_validation @@ -642,7 +644,7 @@ def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, o for key, value in items: existing = serialized.get(key) - if not existing: + if existing is None: serialized[key] = value continue @@ -751,6 +753,15 @@ def _validate_headers( def user_agent(self) -> str: return f"{self.__class__.__name__}/Python {self._version}" + @property + def timeout(self) -> Union[float, Timeout, None]: + return self._timeout + + @timeout.setter + def timeout(self, timeout: Union[float, Timeout, None]) -> None: + self._timeout = timeout + self._timeout_overridden = True + @property def base_url(self) -> URL: return self._base_url @@ -1007,6 +1018,7 @@ def __init__( ) -> None: _reject_httpx_object("http_client", http_client) + timeout_overridden = is_given(timeout) if not is_given(timeout): # if the user passed in a custom http client with a non-default # timeout set then we use that timeout. @@ -1017,6 +1029,7 @@ def __init__( # pass in a timeout and will ignore it if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: timeout = http_client.timeout + timeout_overridden = timeout != DEFAULT_TIMEOUT else: timeout = DEFAULT_TIMEOUT @@ -1043,6 +1056,7 @@ def __init__( middleware=middleware, _strict_response_validation=_strict_response_validation, ) + self._timeout_overridden = timeout_overridden self._middleware_chain = self._build_middleware_chain() self._client = http_client or SyncHttpxClientWrapper( base_url=base_url, @@ -1725,6 +1739,7 @@ def __init__( ) -> None: _reject_httpx_object("http_client", http_client) + timeout_overridden = is_given(timeout) if not is_given(timeout): # if the user passed in a custom http client with a non-default # timeout set then we use that timeout. @@ -1735,6 +1750,7 @@ def __init__( # pass in a timeout and will ignore it if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: timeout = http_client.timeout + timeout_overridden = timeout != DEFAULT_TIMEOUT else: timeout = DEFAULT_TIMEOUT @@ -1761,6 +1777,7 @@ def __init__( middleware=middleware, _strict_response_validation=_strict_response_validation, ) + self._timeout_overridden = timeout_overridden self._middleware_chain = self._build_middleware_chain() self._client = http_client or AsyncHttpxClientWrapper( base_url=base_url, diff --git a/src/anthropic/_client.py b/src/anthropic/_client.py index 766b719a2..de7e76ea0 100644 --- a/src/anthropic/_client.py +++ b/src/anthropic/_client.py @@ -515,7 +515,7 @@ def copy( if isinstance(credentials, NotGiven) and _keeps_base_url(self.base_url, base_url): _extra_kwargs = {"_token_cache": self._token_cache, **_extra_kwargs} # --- end credentials support --- - return self.__class__( + client = self.__class__( api_key=api_key or self.api_key, auth_token=auth_token or self.auth_token, webhook_key=webhook_key or self.webhook_key, @@ -528,6 +528,8 @@ def copy( middleware=self._middleware if isinstance(middleware, NotGiven) else middleware, **_extra_kwargs, ) + client._timeout_overridden = self._timeout_overridden or is_given(timeout) + return client # Alias for `copy` for nicer inline usage, e.g. # client.with_options(timeout=10).foo.create(...) @@ -941,7 +943,7 @@ def copy( if isinstance(credentials, NotGiven) and _keeps_base_url(self.base_url, base_url): _extra_kwargs = {"_token_cache": self._token_cache, **_extra_kwargs} # --- end credentials support --- - return self.__class__( + client = self.__class__( api_key=api_key or self.api_key, auth_token=auth_token or self.auth_token, webhook_key=webhook_key or self.webhook_key, @@ -954,6 +956,8 @@ def copy( middleware=self._middleware if isinstance(middleware, NotGiven) else middleware, **_extra_kwargs, ) + client._timeout_overridden = self._timeout_overridden or is_given(timeout) + return client # Alias for `copy` for nicer inline usage, e.g. # client.with_options(timeout=10).foo.create(...) diff --git a/src/anthropic/_data/__init__.py b/src/anthropic/_data/__init__.py new file mode 100644 index 000000000..10c2bf580 --- /dev/null +++ b/src/anthropic/_data/__init__.py @@ -0,0 +1,8 @@ +from .deprecated_models import DEPRECATED_MODELS as DEPRECATED_MODELS +from .browser_toolset_members import ( + BROWSER_TOOLSET_MEMBERS as BROWSER_TOOLSET_MEMBERS, + BrowserToolsetMembers as BrowserToolsetMembers, +) +from .models_to_warn_with_thinking_enabled import ( + MODELS_TO_WARN_WITH_THINKING_ENABLED as MODELS_TO_WARN_WITH_THINKING_ENABLED, +) diff --git a/src/anthropic/_data/browser_toolset_members.py b/src/anthropic/_data/browser_toolset_members.py new file mode 100644 index 000000000..c8d1e7289 --- /dev/null +++ b/src/anthropic/_data/browser_toolset_members.py @@ -0,0 +1,202 @@ +from typing import Dict, Optional +from typing_extensions import Required, TypedDict + + +class BrowserToolsetMembers(TypedDict, total=False): + confirm_required: Required[bool] + + confirmation: Required[Optional[str]] + + enabled_by_default: Required[bool] + + result: Required[str] + + +BROWSER_TOOLSET_MEMBERS: Dict[str, BrowserToolsetMembers] = { + "navigate": { + "result": "navigate", + "confirmation": None, + "enabled_by_default": True, + "confirm_required": False, + }, + "screenshot": { + "result": "screenshot", + "confirmation": None, + "enabled_by_default": True, + "confirm_required": False, + }, + "zoom": { + "result": "screenshot", + "confirmation": None, + "enabled_by_default": True, + "confirm_required": False, + }, + "left_click": { + "result": "none", + "confirmation": "Clicked.", + "enabled_by_default": True, + "confirm_required": False, + }, + "right_click": { + "result": "none", + "confirmation": "Right-clicked.", + "enabled_by_default": True, + "confirm_required": False, + }, + "middle_click": { + "result": "none", + "confirmation": "Middle-clicked.", + "enabled_by_default": True, + "confirm_required": False, + }, + "double_click": { + "result": "none", + "confirmation": "Double-clicked.", + "enabled_by_default": True, + "confirm_required": False, + }, + "triple_click": { + "result": "none", + "confirmation": "Triple-clicked.", + "enabled_by_default": True, + "confirm_required": False, + }, + "hover": { + "result": "none", + "confirmation": "Hovered.", + "enabled_by_default": True, + "confirm_required": False, + }, + "left_click_drag": { + "result": "none", + "confirmation": "Dragged.", + "enabled_by_default": True, + "confirm_required": False, + }, + "left_mouse_down": { + "result": "none", + "confirmation": "Mouse button pressed.", + "enabled_by_default": True, + "confirm_required": False, + }, + "left_mouse_up": { + "result": "none", + "confirmation": "Mouse button released.", + "enabled_by_default": True, + "confirm_required": False, + }, + "mouse_move": { + "result": "none", + "confirmation": "Moved the mouse.", + "enabled_by_default": True, + "confirm_required": False, + }, + "scroll": { + "result": "none", + "confirmation": "Scrolled {scroll_direction}.", + "enabled_by_default": True, + "confirm_required": False, + }, + "scroll_to": { + "result": "none", + "confirmation": "Scrolled to {ref}.", + "enabled_by_default": True, + "confirm_required": False, + }, + "type": { + "result": "none", + "confirmation": "Typed.", + "enabled_by_default": True, + "confirm_required": False, + }, + "key": { + "result": "none", + "confirmation": "Pressed {text}.", + "enabled_by_default": True, + "confirm_required": False, + }, + "hold_key": { + "result": "none", + "confirmation": "Held {text} for {duration}s.", + "enabled_by_default": True, + "confirm_required": False, + }, + "form_input": { + "result": "none", + "confirmation": "Set the value of {ref}.", + "enabled_by_default": True, + "confirm_required": False, + }, + "read_page": { + "result": "text", + "confirmation": None, + "enabled_by_default": True, + "confirm_required": False, + }, + "find": { + "result": "text", + "confirmation": None, + "enabled_by_default": True, + "confirm_required": False, + }, + "get_page_text": { + "result": "text", + "confirmation": None, + "enabled_by_default": True, + "confirm_required": False, + }, + "wait": { + "result": "none", + "confirmation": "Waited {duration}s.", + "enabled_by_default": True, + "confirm_required": False, + }, + "file_upload": { + "result": "none", + "confirmation": "Uploaded.", + "enabled_by_default": False, + "confirm_required": True, + }, + "read_console": { + "result": "text", + "confirmation": None, + "enabled_by_default": False, + "confirm_required": False, + }, + "read_network": { + "result": "text", + "confirmation": None, + "enabled_by_default": False, + "confirm_required": False, + }, + "javascript_exec": { + "result": "text", + "confirmation": None, + "enabled_by_default": False, + "confirm_required": True, + }, + "new_tab": { + "result": "tab", + "confirmation": None, + "enabled_by_default": True, + "confirm_required": False, + }, + "list_tabs": { + "result": "tabs", + "confirmation": None, + "enabled_by_default": True, + "confirm_required": False, + }, + "switch_tab": { + "result": "tab", + "confirmation": None, + "enabled_by_default": True, + "confirm_required": False, + }, + "close_tab": { + "result": "none", + "confirmation": None, + "enabled_by_default": True, + "confirm_required": False, + }, +} diff --git a/src/anthropic/_data/deprecated_models.py b/src/anthropic/_data/deprecated_models.py new file mode 100644 index 000000000..3d3641257 --- /dev/null +++ b/src/anthropic/_data/deprecated_models.py @@ -0,0 +1,7 @@ +from typing import Dict + +DEPRECATED_MODELS: Dict[str, str] = { + "claude-mythos-preview": "June 30th, 2026", + "claude-sonnet-4-5": "November 30th, 2026", + "claude-sonnet-4-5-20250929": "November 30th, 2026", +} diff --git a/src/anthropic/_data/models_to_warn_with_thinking_enabled.py b/src/anthropic/_data/models_to_warn_with_thinking_enabled.py new file mode 100644 index 000000000..d76578157 --- /dev/null +++ b/src/anthropic/_data/models_to_warn_with_thinking_enabled.py @@ -0,0 +1,3 @@ +from .._types import SequenceNotStr + +MODELS_TO_WARN_WITH_THINKING_ENABLED: SequenceNotStr[str] = ["claude-opus-4-6", "claude-mythos-preview"] diff --git a/src/anthropic/_qs.py b/src/anthropic/_qs.py index e292bcf58..02741a530 100644 --- a/src/anthropic/_qs.py +++ b/src/anthropic/_qs.py @@ -113,10 +113,9 @@ def _stringify_item( f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" ) - serialised = self._primitive_value_to_str(value) - if not serialised: + if value is None: return [] - return [(key, serialised)] + return [(key, self._primitive_value_to_str(value))] def _primitive_value_to_str(self, value: PrimitiveData) -> str: # copied from httpx diff --git a/src/anthropic/_version.py b/src/anthropic/_version.py index 2979aaad0..76753be65 100644 --- a/src/anthropic/_version.py +++ b/src/anthropic/_version.py @@ -1,2 +1,2 @@ __title__ = "anthropic" -__version__ = "1.11.0" # x-release-please-version +__version__ = "1.12.0" # x-release-please-version diff --git a/src/anthropic/lib/_stainless_helpers.py b/src/anthropic/lib/_stainless_helpers.py index 375bd0b24..7df6fc96a 100644 --- a/src/anthropic/lib/_stainless_helpers.py +++ b/src/anthropic/lib/_stainless_helpers.py @@ -48,7 +48,9 @@ StainlessHelperHeaderValue = Literal[ "beta.messages.parse", "BetaToolRunner", + "browser-toolset", "compaction", + "computer-toolset", "environments-work-poller", "environments-worker", "fallback-refusal-middleware", diff --git a/src/anthropic/lib/tools/__init__.py b/src/anthropic/lib/tools/__init__.py index 7405c289d..38873e75e 100644 --- a/src/anthropic/lib/tools/__init__.py +++ b/src/anthropic/lib/tools/__init__.py @@ -10,6 +10,7 @@ beta_tool, beta_async_tool, ) +from ._toolsets._runnable import BetaRunnableToolset, BetaAsyncRunnableToolset from ._beta_builtin_memory_tool import BetaAbstractMemoryTool, BetaAsyncAbstractMemoryTool __all__ = [ @@ -23,6 +24,8 @@ "BetaAsyncStreamingToolRunner", "BetaStreamingToolRunner", "BetaAsyncToolRunner", + "BetaRunnableToolset", + "BetaAsyncRunnableToolset", "BetaFunctionToolResultType", "BetaAbstractMemoryTool", "BetaAsyncAbstractMemoryTool", diff --git a/src/anthropic/lib/tools/_beta_runner.py b/src/anthropic/lib/tools/_beta_runner.py index af006d081..30c4d9cc4 100644 --- a/src/anthropic/lib/tools/_beta_runner.py +++ b/src/anthropic/lib/tools/_beta_runner.py @@ -25,7 +25,7 @@ import httpx2 from ..._types import Body, Query, Headers, NotGiven -from ..._utils import is_given, consume_sync_iterator, consume_async_iterator +from ..._utils import is_list, is_given, is_mapping, consume_sync_iterator, consume_async_iterator from ..streaming import BetaMessageStream, BetaAsyncMessageStream, ParsedBetaMessageStreamEvent from ...types.beta import ( BetaMessage, @@ -36,7 +36,13 @@ BetaRequestToolAdditionBlockParam, ) from ..._base_client import merge_headers -from ._tool_dispatch import tool_registry, tool_error_content, available_tool_names +from ._tool_dispatch import ( + tool_family, + index_runnables, + tool_error_content, + tool_result_content, + available_tool_names, +) from ._beta_functions import ( ToolError, BetaFunctionTool, @@ -46,7 +52,17 @@ BetaBuiltinFunctionTool, BetaAsyncBuiltinFunctionTool, ) +from ._toolsets._errors import ToolsetsFixedError, ToolsetContractError from .._stainless_helpers import stainless_helper_header +from ._toolsets._runnable import ( + TOOLSET_CLASSES, + BetaRunnableToolset, + BetaAnyRunnableToolset, + BetaAsyncRunnableToolset, + not_executed_result, + toolset_result_block, +) +from ._toolsets._sanitize import quoted_name from ...types.beta.beta_stop_reason import BetaStopReason from ...types.beta.parsed_beta_message import ResponseFormatT, ParsedBetaMessage, ParsedBetaContentBlock from ...types.beta.message_create_params import ParseMessageCreateParamsBase @@ -64,11 +80,13 @@ BetaFunctionTool[Any], BetaAsyncFunctionTool[Any], BetaBuiltinFunctionTool, BetaAsyncBuiltinFunctionTool ], ) +AnyToolsetT = TypeVar("AnyToolsetT", bound=BetaAnyRunnableToolset) RunnerItemT = TypeVar("RunnerItemT") log = logging.getLogger(__name__) + _Step = Literal["run_tools", "resume", "stop"] # Every stop reason maps to exactly one step. The runner tests assert this mapping @@ -143,25 +161,23 @@ class RequestOptions(TypedDict, total=False): timeout: float | httpx2.Timeout | None | NotGiven -class BaseToolRunner(Generic[AnyFunctionToolT, ResponseFormatT]): +class BaseToolRunner(Generic[AnyFunctionToolT, AnyToolsetT, ResponseFormatT]): def __init__( self, *, params: ParseMessageCreateParamsBase[ResponseFormatT], options: RequestOptions, - tools: Iterable[AnyFunctionToolT], + tools: Iterable[AnyFunctionToolT | AnyToolsetT], max_iterations: int | None = None, ) -> None: _reject_compaction_param(params) - self._tools_by_name = tool_registry(tools) + tools = list(tools) + self._tools_by_name, self._toolsets_by_family = index_runnables(tools) self._params: ParseMessageCreateParamsBase[ResponseFormatT] = { **params, "messages": [message for message in params["messages"]], } - helper_header = stainless_helper_header( - tools=self._tools_by_name.values(), - messages=params.get("messages"), - ) + helper_header = stainless_helper_header(tools=tools, messages=params.get("messages")) if helper_header: merged_headers = merge_headers(helper_header, options.get("extra_headers") or {}) options = {**options, "extra_headers": merged_headers} @@ -198,6 +214,10 @@ def set_messages_params( ) if self._pending_compaction is not None or self._messages_being_compacted is not None: self._check_can_compact(params) + if iter(messages := params["messages"]) is messages: # a generator is used up by the first request + params = {**params, "messages": list(messages)} + if (tools := params.get("tools")) and iter(tools) is tools: + params = {**params, "tools": list(tools)} self._params = params def append_messages(self, *messages: BetaMessageParam | ParsedBetaMessage[ResponseFormatT]) -> None: @@ -292,11 +312,25 @@ def add_tools(self, *tools: AnyFunctionToolT | BetaToolUnionParam) -> None: away, in place of any tool of the same name, even for a call already in the message being handled. A call that ran while the reply streamed keeps its result. A raw definition is for server tools, such as web search: the tool runner never runs it, and it stops running a function tool of the same name. Requires the - `inline-tools-2026-09-15` beta. + `inline-tools-2026-09-15` beta. Passing a runnable toolset, a raw definition of a toolset the runner has, or a + tool named like one of its toolsets raises `ToolsetContractError` and adds nothing: a runner's toolsets are + fixed when it is created, and the API rejects another tool with a toolset's name. Args: *tools: Function tools, such as `@beta_tool` functions, or raw tool definitions. """ + for tool in tools: + if isinstance(tool, TOOLSET_CLASSES): + raise ToolsetsFixedError("add", tool.toolset_name) + # The API reads a definition of one of the runner's toolset families as replacing that toolset. + if isinstance(tool, dict) and (family := tool_family(tool)) in self._toolsets_by_family: + raise ToolsetsFixedError("replace", family) + name = tool.get("name") if isinstance(tool, dict) else tool.name + if isinstance(name, str) and name in self._toolsets_by_family: + raise ToolsetContractError( + f"add_tools() can't add a tool named '{quoted_name(name)}': the runner's {quoted_name(name)} " + "toolset has that name; give the tool another name" + ) for tool in tools: if isinstance(tool, dict): definition: BetaToolUnionParam = copy(tool) @@ -314,11 +348,18 @@ def remove_tools(self, *tools: AnyFunctionToolT | str) -> None: """Take tools away from the model without changing the `tools` param, which would miss the prompt cache. The tools stop being run at once, and the model is told in `tool_removal` blocks with the next request. A - call that ran while the reply streamed keeps its result. Requires the `inline-tools-2026-09-15` beta. + call that ran while the reply streamed keeps its result. Requires the `inline-tools-2026-09-15` beta. Passing + a toolset, or the name of one of the runner's toolsets, raises `ToolsetContractError` and removes nothing. Args: *tools: The tools to remove, or their names. """ + for tool in tools: + if isinstance(tool, TOOLSET_CLASSES): + raise ToolsetsFixedError("remove", tool.toolset_name) + name = tool if isinstance(tool, str) else tool.name + if name in self._toolsets_by_family: + raise ToolsetsFixedError("remove", name) for tool in tools: name = tool if isinstance(tool, str) else tool.name self._tools_by_name.pop(name, None) @@ -349,19 +390,48 @@ def _available_tool_names(self) -> set[str]: Removal is only a hint to the model, which can still emit a `tool_use` for a withdrawn tool; a name absent from this set routes that call down the same unknown-tool path as a tool that was never declared. + + Toolsets are not in this set: a `tool_reference` names a tool, + not a toolset, and member names are not reserved, so a toolset stays + available for as long as it is registered. """ # Changes made since the last request are not in the history yet. pending: BetaMessageParam = {"role": "system", "content": self._pending_tool_changes} return available_tool_names([*self._params["messages"], pending], self._tools_by_name) + def _toolset_for(self, tool_use: BetaToolUseBlock) -> AnyToolsetT | None: + assert tool_use.toolset_name is not None + toolset = self._toolsets_by_family.get(tool_use.toolset_name) + if toolset is None: + # both names are model output: folded to one bounded line before they reach a log + warnings.warn( + f"Toolset '{quoted_name(tool_use.toolset_name)}' (member '{quoted_name(tool_use.name)}') not found in tool runner. " + f"Registered toolsets: {list(self._toolsets_by_family.keys())}. " + f"If using a raw toolset definition, handle the tool call manually and use `append_messages()` to add the result. " + f"Otherwise, pass a `BetaRunnableToolset` / `BetaAsyncRunnableToolset` instance.", + UserWarning, + stacklevel=5, # points the warning at the user's call into generate_tool_call_response + ) + return toolset + + @staticmethod + def _toolset_not_found_result(tool_use: BetaToolUseBlock) -> BetaToolResultBlockParam: + return toolset_result_block( + tool_use, + f"Error: Toolset '{quoted_name(tool_use.toolset_name)}' member '{quoted_name(tool_use.name)}' not found", + is_error=True, + ) + -class BaseSyncToolRunner(BaseToolRunner[BetaRunnableTool, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC): +class BaseSyncToolRunner( + BaseToolRunner[BetaRunnableTool, BetaRunnableToolset, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC +): def __init__( self, *, params: ParseMessageCreateParamsBase[ResponseFormatT], options: RequestOptions, - tools: Iterable[BetaRunnableTool], + tools: Iterable[BetaRunnableTool | BetaRunnableToolset], client: Anthropic, max_iterations: int | None = None, ) -> None: @@ -490,14 +560,35 @@ def _generate_tool_call_response(self) -> BetaMessageParam | None: results: list[BetaToolResultBlockParam] = [] available = self._available_tool_names() + failed_toolsets: set[str] = set() # a toolset whose member failed this turn: its later members are not run eager_calls = self._eager_tool_calls for tool_use in tool_use_blocks: + if tool_use.toolset_name is not None: + if tool_use.toolset_name in failed_toolsets: + results.append(not_executed_result(tool_use)) + continue + # With `run_tools_eagerly`, the result of a member that ran while the reply streamed is kept, so that + # it doesn't run twice. + member_result = eager_calls.results.get(tool_use.id) if eager_calls is not None else None + if member_result is None: + member_result = self._run_toolset_member(tool_use) + if eager_calls is not None: + eager_calls.record(tool_use, member_result) + if member_result.get("is_error") and tool_use.toolset_name in self._toolsets_by_family: + failed_toolsets.add(tool_use.toolset_name) + results.append(member_result) + continue + result: BetaToolResultBlockParam | None = None if eager_calls is not None: result = eager_calls.results.get(tool_use.id) if result is None: tool = self._tools_by_name.get(tool_use.name) if tool_use.name in available else None + if tool is None and tool_use.name in self._toolsets_by_family: + failed_toolsets.add(tool_use.name) + results.append(malformed_toolset_call_result(tool_use)) + continue if tool is None: warnings.warn( f"Tool '{tool_use.name}' not found in tool runner. " @@ -525,7 +616,11 @@ def _generate_tool_call_response(self) -> BetaMessageParam | None: def _run_tool(self, tool: BetaRunnableTool, tool_use: BetaToolUseBlock) -> BetaToolResultBlockParam: try: - return {"type": "tool_result", "tool_use_id": tool_use.id, "content": tool.call(tool_use.input)} + return { + "type": "tool_result", + "tool_use_id": tool_use.id, + "content": tool_result_content(tool.call(tool_use.input)), + } except ToolError as exc: return { "type": "tool_result", @@ -542,6 +637,12 @@ def _run_tool(self, tool: BetaRunnableTool, tool_use: BetaToolUseBlock) -> BetaT "is_error": True, } + def _run_toolset_member(self, tool_use: BetaToolUseBlock) -> BetaToolResultBlockParam: + toolset = self._toolset_for(tool_use) + if toolset is None: + return self._toolset_not_found_result(tool_use) + return toolset.tool_result(tool_use) + def _get_last_message(self) -> ParsedBetaMessage[ResponseFormatT] | None: if callable(self._last_message): return self._last_message() @@ -579,7 +680,7 @@ def __init__( *, params: ParseMessageCreateParamsBase[ResponseFormatT], options: RequestOptions, - tools: Iterable[BetaRunnableTool], + tools: Iterable[BetaRunnableTool | BetaRunnableToolset], client: Anthropic, max_iterations: int | None = None, run_tools_eagerly: bool = False, @@ -594,8 +695,9 @@ def defer_tool_call(self, tool_use: BetaToolUseBlock | str) -> None: With `run_tools_eagerly` the runner otherwise runs each call while the reply streams, once the model has moved on from it: when the next block starts, or the reply stops with `tool_use`. The call runs when you ask for the event after that one, so you can hold it while you handle any event up to and including that one. - The other calls of the reply still run early. It does nothing for a call that has run, outside the loop - body, and without `run_tools_eagerly`. + The other calls of the reply still run early, except the later calls of the same toolset, which a toolset + runs in order. It does nothing for a call that has run, outside the loop body, and without + `run_tools_eagerly`. ```py for stream in runner: @@ -658,11 +760,28 @@ def _run_tools_while_streaming( moved_on_from: BetaToolUseBlock | None = None # The next event is read first, so that no call runs once reading the reply has failed. for event in events: - if moved_on_from is not None and not calls.is_held(moved_on_from): + if moved_on_from is not None: name = moved_on_from.name - tool = self._tools_by_name.get(name) if name in self._available_tool_names() else None + family = moved_on_from.toolset_name + tool = ( + self._tools_by_name.get(name) if family is None and name in self._available_tool_names() else None + ) + if family is None and tool is None and name in self._toolsets_by_family: + # A call named like a toolset, with no `toolset_name`, is a malformed toolset call. + family = name + calls.waiting_toolsets.add(family) + if family is not None: + # A toolset runs its calls in the model's order. So once one of its calls waits for the tool + # response, or fails, its later calls wait for the tool response too. + if calls.is_held(moved_on_from): + calls.waiting_toolsets.add(family) + if family not in calls.waiting_toolsets: + member_result = self._run_toolset_member(moved_on_from) + calls.record(moved_on_from, member_result) + if member_result.get("is_error"): + calls.waiting_toolsets.add(family) # A call to a tool the runner doesn't have gets its error result with the tool response. - if tool is not None: + elif tool is not None and not calls.is_held(moved_on_from): calls.record(moved_on_from, self._run_tool(tool, moved_on_from)) moved_on_from = calls.track(event) @@ -670,14 +789,16 @@ def _run_tools_while_streaming( class BaseAsyncToolRunner( - BaseToolRunner[BetaAsyncRunnableTool, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC + BaseToolRunner[BetaAsyncRunnableTool, BetaAsyncRunnableToolset, ResponseFormatT], + Generic[RunnerItemT, ResponseFormatT], + ABC, ): def __init__( self, *, params: ParseMessageCreateParamsBase[ResponseFormatT], options: RequestOptions, - tools: Iterable[BetaAsyncRunnableTool], + tools: Iterable[BetaAsyncRunnableTool | BetaAsyncRunnableToolset], client: AsyncAnthropic, max_iterations: int | None = None, ) -> None: @@ -836,14 +957,35 @@ async def _generate_tool_call_response(self) -> BetaMessageParam | None: results: list[BetaToolResultBlockParam] = [] available = self._available_tool_names() + failed_toolsets: set[str] = set() # a toolset whose member failed this turn: its later members are not run eager_calls = self._eager_tool_calls for tool_use in tool_use_blocks: + if tool_use.toolset_name is not None: + if tool_use.toolset_name in failed_toolsets: + results.append(not_executed_result(tool_use)) + continue + # With `run_tools_eagerly`, the result of a member that ran while the reply streamed is kept, so that + # it doesn't run twice. + member_result = eager_calls.results.get(tool_use.id) if eager_calls is not None else None + if member_result is None: + member_result = await self._run_toolset_member(tool_use) + if eager_calls is not None: + eager_calls.record(tool_use, member_result) + if member_result.get("is_error") and tool_use.toolset_name in self._toolsets_by_family: + failed_toolsets.add(tool_use.toolset_name) + results.append(member_result) + continue + result: BetaToolResultBlockParam | None = None if eager_calls is not None: result = eager_calls.results.get(tool_use.id) if result is None: tool = self._tools_by_name.get(tool_use.name) if tool_use.name in available else None + if tool is None and tool_use.name in self._toolsets_by_family: + failed_toolsets.add(tool_use.name) + results.append(malformed_toolset_call_result(tool_use)) + continue if tool is None: warnings.warn( f"Tool '{tool_use.name}' not found in tool runner. " @@ -871,7 +1013,11 @@ async def _generate_tool_call_response(self) -> BetaMessageParam | None: async def _run_tool(self, tool: BetaAsyncRunnableTool, tool_use: BetaToolUseBlock) -> BetaToolResultBlockParam: try: - return {"type": "tool_result", "tool_use_id": tool_use.id, "content": await tool.call(tool_use.input)} + return { + "type": "tool_result", + "tool_use_id": tool_use.id, + "content": tool_result_content(await tool.call(tool_use.input)), + } except ToolError as exc: return { "type": "tool_result", @@ -888,6 +1034,12 @@ async def _run_tool(self, tool: BetaAsyncRunnableTool, tool_use: BetaToolUseBloc "is_error": True, } + async def _run_toolset_member(self, tool_use: BetaToolUseBlock) -> BetaToolResultBlockParam: + toolset = self._toolset_for(tool_use) + if toolset is None: + return self._toolset_not_found_result(tool_use) + return await toolset.tool_result(tool_use) + class BetaAsyncToolRunner(BaseAsyncToolRunner[ParsedBetaMessage[ResponseFormatT], ResponseFormatT]): @override @@ -906,7 +1058,7 @@ def __init__( *, params: ParseMessageCreateParamsBase[ResponseFormatT], options: RequestOptions, - tools: Iterable[BetaAsyncRunnableTool], + tools: Iterable[BetaAsyncRunnableTool | BetaAsyncRunnableToolset], client: AsyncAnthropic, max_iterations: int | None = None, run_tools_eagerly: bool = False, @@ -921,8 +1073,9 @@ def defer_tool_call(self, tool_use: BetaToolUseBlock | str) -> None: With `run_tools_eagerly` the runner otherwise runs each call while the reply streams, once the model has moved on from it: when the next block starts, or the reply stops with `tool_use`. The call runs when you ask for the event after that one, so you can hold it while you handle any event up to and including that one. - The other calls of the reply still run early. It does nothing for a call that has run, outside the loop - body, and without `run_tools_eagerly`. + The other calls of the reply still run early, except the later calls of the same toolset, which a toolset + runs in order. It does nothing for a call that has run, outside the loop body, and without + `run_tools_eagerly`. ```py async for stream in runner: @@ -985,11 +1138,28 @@ async def _run_tools_while_streaming( moved_on_from: BetaToolUseBlock | None = None # The next event is read first, so that no call runs once reading the reply has failed. async for event in events: - if moved_on_from is not None and not calls.is_held(moved_on_from): + if moved_on_from is not None: name = moved_on_from.name - tool = self._tools_by_name.get(name) if name in self._available_tool_names() else None + family = moved_on_from.toolset_name + tool = ( + self._tools_by_name.get(name) if family is None and name in self._available_tool_names() else None + ) + if family is None and tool is None and name in self._toolsets_by_family: + # A call named like a toolset, with no `toolset_name`, is a malformed toolset call. + family = name + calls.waiting_toolsets.add(family) + if family is not None: + # A toolset runs its calls in the model's order. So once one of its calls waits for the tool + # response, or fails, its later calls wait for the tool response too. + if calls.is_held(moved_on_from): + calls.waiting_toolsets.add(family) + if family not in calls.waiting_toolsets: + member_result = await self._run_toolset_member(moved_on_from) + calls.record(moved_on_from, member_result) + if member_result.get("is_error"): + calls.waiting_toolsets.add(family) # A call to a tool the runner doesn't have gets its error result with the tool response. - if tool is not None: + elif tool is not None and not calls.is_held(moved_on_from): calls.record(moved_on_from, await self._run_tool(tool, moved_on_from)) moved_on_from = calls.track(event) @@ -1009,6 +1179,9 @@ def __init__(self) -> None: self._held_ids: set[str] = set() """The ids of the calls that `defer_tool_call()` is holding.""" + self.waiting_toolsets: set[str] = set() + """The toolsets that have a call waiting for the tool response. Their later calls wait for it too.""" + self._closed: BetaToolUseBlock | None = None """The last `tool_use` block to close, until the model moves on from it.""" @@ -1056,3 +1229,46 @@ def track(self, event: ParsedBetaMessageStreamEvent[Any]) -> BetaToolUseBlock | moved_on_from, self._closed = self._closed, None return moved_on_from + + +MAX_NAMED_ACTIONS = 10 + + +def malformed_toolset_call_result(tool_use: BetaToolUseBlock) -> BetaToolResultBlockParam: + """Build the error result for a call that is named after a toolset in the run but has no `toolset_name`. + + Its input holds actions for that toolset in a form the runner cannot run. The text says what is wrong and names + the actions where it can. Apart from action names, it does not repeat the model's input. + """ + detail = malformed_call_detail(tool_use.input) + return { + "type": "tool_result", + "tool_use_id": tool_use.id, + "content": f"Error: the '{quoted_name(tool_use.name)}' toolset could not run this call{detail}; " + "nothing in the batch was executed", + "is_error": True, + } + + +def malformed_call_detail(input: Dict[str, object]) -> str: + if "actions" not in input: + action = input.get("action") + if isinstance(action, str): + return f": the call has no 'actions' list (action: '{quoted_name(action)}')" + return ": the call has no 'actions' list" + actions = input["actions"] + if isinstance(actions, str): + return ": 'actions' is text, not a list of actions" + if not is_list(actions): + return ": 'actions' is not a list of actions" + if not actions: + return ": the 'actions' list is empty" + names: List[str] = [] + for i, entry in enumerate(actions): + name = entry.get("action") if is_mapping(entry) else None + if not isinstance(name, str): + return f": action {i} must be an object with a string 'action' field" + names.append(f"'{quoted_name(name)}'") + more = len(names) - MAX_NAMED_ACTIONS + shown = ", ".join(names[:MAX_NAMED_ACTIONS]) + (f", and {more} more" if more > 0 else "") + return f": its actions could not be run as sent (actions: {shown})" diff --git a/src/anthropic/lib/tools/_beta_session_runner.py b/src/anthropic/lib/tools/_beta_session_runner.py index 25f5468fb..43614b3c6 100644 --- a/src/anthropic/lib/tools/_beta_session_runner.py +++ b/src/anthropic/lib/tools/_beta_session_runner.py @@ -31,7 +31,7 @@ from .._retry import TRANSIENT_ERRORS, jitter, backoff, is_fatal_status_error from ..._types import Headers -from ._tool_dispatch import tool_registry, run_runnable_tool, tool_error_content +from ._tool_dispatch import tool_registry, run_runnable_tool, tool_error_content, tool_result_content from .._scoped_client import _copy_client_with_bearer_auth from ._beta_functions import ( ToolError, @@ -305,13 +305,15 @@ def _scoped_client(client: AsyncAnthropic, environment_key: str | None) -> Async return client.with_options(default_headers=helper_header("session-tool-runner")) -def _to_session_content(content: BetaFunctionToolResultType) -> list[_SessionContent]: +def _to_session_content(content: BetaFunctionToolResultType | None) -> list[_SessionContent]: """Bridge Messages-API tool-result content to the narrower Sessions-API content union. The two APIs share text/image/document/search_result block shapes but use distinct nominal TypedDicts; ToolReference blocks have no Sessions equivalent - so they are stringified. + so they are stringified. A tool that returned `None` reads `(no output)`. """ + if content is None: + return [{"type": "text", "text": "(no output)"}] if isinstance(content, str): return [{"type": "text", "text": content or "(no output)"}] out: list[_SessionContent] = [] @@ -329,7 +331,7 @@ def _to_session_content(content: BetaFunctionToolResultType) -> list[_SessionCon def _build_result_event( ev: DispatchedToolUseEvent, - content: BetaFunctionToolResultType, + content: BetaFunctionToolResultType | None, is_error: bool, ) -> DispatchedToolResultParams: """Build the result-event params matching `ev`'s tool-call kind. @@ -879,12 +881,12 @@ async def _execute(self, ev: DispatchedToolUseEvent, confirmation: Literal["allo is_error = False sent = False else: - content: BetaFunctionToolResultType + content: BetaFunctionToolResultType | None # a tool may return None whatever its annotation says is_error = False input_ = dict(ev.input) try: with anyio.fail_after(TOOL_TIMEOUT): - content = await run_runnable_tool(tool, input_) + content = tool_result_content(await run_runnable_tool(tool, input_)) except TimeoutError: content = f"tool {ev.name!r} timed out" is_error = True diff --git a/src/anthropic/lib/tools/_memories.py b/src/anthropic/lib/tools/_memories.py index 12e77f32c..f993e2143 100644 --- a/src/anthropic/lib/tools/_memories.py +++ b/src/anthropic/lib/tools/_memories.py @@ -135,6 +135,9 @@ class _AttachedStore: refused_shas: dict[str, str] = field(default_factory=dict[str, str]) #: `{rel_path → monotonic time first seen missing}`; the server delete waits for a later sync. pending_deletes: dict[str, float] = field(default_factory=dict[str, float]) + #: `{rel_path → sha}` of content sent whose outcome is unknown — recorded before the + #: request, dropped after a successful response, else settled by the next listing. + sent_shas: dict[str, str] = field(default_factory=dict[str, str]) @dataclass @@ -175,6 +178,10 @@ class SessionMemoryStores: - a file the server refuses (too large, invalid content) is skipped — warned once and retried only after the file changes; other files keep syncing; + - an upload whose response never arrives may still have been saved, so + the sha of the bytes sent is remembered until the next sync has listed + the server: a memory holding exactly that sha is this sync's own + upload, not a remote edit, and a newer local edit goes out over it; - a file deleted locally is deleted on the server, guarded by an `expected_content_sha256` precondition — a concurrent server-side edit wins and the file is restored instead. The delete never goes @@ -378,13 +385,14 @@ async def _sync_store(self, store: _AttachedStore, final: bool = False) -> None: return await self._recover(store, "the folder or its marker is gone") return - # One file gone is a delete; every file gone at once is a wipe. + remote = {item.path.lstrip("/"): item async for item in self._list_memories(store.memory_store_id)} + self._settle_sent(store, remote) + # One file gone is a delete; every file gone at once is a wipe — + # counted after settling, so an upload that landed unheard counts. if not local and len(store.baseline) > 1: await self._recover(store, "every memory file is gone at once") return - remote = {item.path.lstrip("/"): item async for item in self._list_memories(store.memory_store_id)} - deletes = _DeletePass( mode=self._sync_deletions, cap=max(_DELETE_CAP_FLOOR, min(_DELETE_CAP_CEILING, len(store.baseline) // 4)), @@ -470,15 +478,18 @@ async def _flush_store(self, store: _AttachedStore) -> None: store.memory_store_id, ) return + # A path with an unsettled send counts even at its baseline sha: + # the send may have landed, leaving the server ahead of the file. dirty = { rel: sha for rel, sha in scan.files.items() - if sha != store.baseline.get(rel) and store.refused_shas.get(rel) != sha + if (sha != store.baseline.get(rel) or rel in store.sent_shas) and store.refused_shas.get(rel) != sha } if not dirty: return unsent.update(dirty) remote = {item.path.lstrip("/"): item async for item in self._list_memories(store.memory_store_id)} + self._settle_sent(store, remote) limiter = anyio.CapacityLimiter(_UPLOAD_CONCURRENCY) async def upload_one(rel: str, local_sha: str, existing: BetaManagedAgentsMemory | None) -> None: @@ -493,6 +504,11 @@ async def upload_one(rel: str, local_sha: str, existing: BetaManagedAgentsMemory local_sha = dirty[rel] base_sha = store.baseline.get(rel) existing = remote.get(rel) + if local_sha == base_sha: + # Listed only for its unsettled send, which never + # landed: the file holds nothing the server lacks. + unsent.discard(rel) + continue if existing is not None and existing.content_sha256 == local_sha: store.baseline[rel] = local_sha unsent.discard(rel) @@ -565,6 +581,20 @@ async def _recover(self, store: _AttachedStore, reason: str) -> None: await store.files.create_root() await self._stamp_and_pull(store) + def _settle_sent(self, store: _AttachedStore, remote: dict[str, BetaManagedAgentsMemory]) -> None: + """Resolve the uploads whose response never arrived, against a fresh listing. + + A memory holding exactly the sha that was sent is that upload, saved + after all: it enters the baseline, so it reads as synced rather than as + a remote edit. Every record is then dropped — kept past this listing, + one would claim another writer's identical bytes. + """ + for rel, sha in store.sent_shas.items(): + listed = remote.get(rel) + if listed is not None and listed.content_sha256 == sha: + store.baseline[rel] = sha + store.sent_shas.clear() + async def _stamp_and_pull(self, store: _AttachedStore) -> None: """Write the trust marker, then pull every remote memory; pushes nothing. @@ -574,6 +604,7 @@ async def _stamp_and_pull(self, store: _AttachedStore) -> None: pages cost far fewer round-trips than a request per memory. """ store.baseline = {} + store.sent_shas.clear() # The disk was just rebuilt, so earlier absence observations mean nothing. store.pending_deletes.clear() await store.files.put(MARKER_PATH, f"version {_MARKER_VERSION}\n{store.memory_store_id}") @@ -777,6 +808,7 @@ async def _upload( data = await store.files.get(rel) if data is None: return None + store.sent_shas[rel] = hashlib.sha256(data).hexdigest() if existing is None: item = await self._client.beta.memory_stores.memories.create( store.memory_store_id, path="/" + rel, content=data.decode("utf-8") @@ -797,7 +829,9 @@ async def _upload( if existing is not None and status == 409: # The precondition lost a race: the remote moved under us, so # the push is dropped. The next sync pulls the winner over the - # file; from the shutdown flush there is no next sync. + # file; from the shutdown flush there is no next sync. A client + # retry of an attempt that was saved ends here too, so the sent + # sha stays for the next listing to tell the two apart. log.warning( "memory changed both locally and remotely; the upload was refused and the " "local edit loses path=%s memory_store_id=%s", @@ -817,6 +851,7 @@ async def _upload( log.warning("failed to upload memory path=%s memory_store_id=%s: %s", rel, store.memory_store_id, e) return None store.refused_shas.pop(rel, None) + store.sent_shas.pop(rel, None) return item.content_sha256 async def _corroborated_delete( diff --git a/src/anthropic/lib/tools/_tool_dispatch.py b/src/anthropic/lib/tools/_tool_dispatch.py index aade47b4b..3384e0354 100644 --- a/src/anthropic/lib/tools/_tool_dispatch.py +++ b/src/anthropic/lib/tools/_tool_dispatch.py @@ -11,23 +11,111 @@ from __future__ import annotations +import re import inspect -from typing import Union, TypeVar, Iterable, Awaitable, cast -from typing_extensions import Protocol +from typing import Union, TypeVar, Iterable, Awaitable, cast, overload +from typing_extensions import Literal, Protocol from anyio.to_thread import run_sync -from ._beta_functions import ToolError, BetaFunctionTool, BetaBuiltinFunctionTool, BetaFunctionToolResultType +from ..._utils import is_dict +from ...types.beta import BetaToolUnionParam +from ._beta_functions import ( + ToolError, + BetaFunctionTool, + BetaRunnableTool, + BetaAsyncFunctionTool, + BetaAsyncRunnableTool, + BetaBuiltinFunctionTool, + BetaFunctionToolResultType, + BetaAsyncBuiltinFunctionTool, +) +from ._toolsets._errors import ToolsetContractError +from ._toolsets._runnable import ( + TOOLSET_CLASSES, + BetaRunnableToolset, + BetaAnyRunnableToolset, + BetaAsyncRunnableToolset, +) +from ._toolsets._sanitize import error_text, well_formed from ...types.beta.beta_message_param import BetaMessageParam from ...types.beta.beta_compaction_block import BetaCompactionBlock from ...types.beta.beta_content_block_param import BetaContentBlockParam +from ...types.beta.beta_tool_result_block_param import Content as BetaContent from ...types.beta.beta_request_tool_removal_block_param import BetaRequestToolRemovalBlockParam from ...types.beta.beta_request_tool_addition_block_param import ( Tool as _ToolChangeTool, BetaRequestToolAdditionBlockParam, ) -__all__ = ["tool_registry", "tool_error_content", "run_runnable_tool", "available_tool_names"] +__all__ = [ + "tool_registry", + "index_runnables", + "partition_sync_tools", + "partition_async_tools", + "wrong_flavour_error", + "tool_error_content", + "tool_result_content", + "run_runnable_tool", + "available_tool_names", + "tool_family", +] + +_SYNC_RUNNABLES = (BetaFunctionTool, BetaBuiltinFunctionTool, BetaRunnableToolset) +_ASYNC_RUNNABLES = (BetaAsyncFunctionTool, BetaAsyncBuiltinFunctionTool, BetaAsyncRunnableToolset) + + +def wrong_flavour_error(tool: object, *, expected: Literal["sync", "async"]) -> Exception: + """The error for a sync tool or toolset handed to the async runner (or the reverse), raised when the + runner is built. A toolset mismatch is a `ToolsetContractError`.""" + if expected == "sync": + message = ( + f"Received an async tool or toolset ({type(tool).__name__}) in the synchronous tool_runner. Create the " + "runner from an AsyncAnthropic client (and await its result), or pass a synchronous tool or " + "toolset here." + ) + else: + message = ( + f"Received a synchronous tool or toolset ({type(tool).__name__}) in the asynchronous tool_runner. Create " + "the runner from an Anthropic client, or pass an async tool or toolset here." + ) + if isinstance(tool, TOOLSET_CLASSES): + return ToolsetContractError(message) + return TypeError(message) + + +def partition_sync_tools( + tools: Iterable[BetaRunnableTool | BetaRunnableToolset | BetaToolUnionParam], +) -> tuple[list[BetaRunnableTool | BetaRunnableToolset], list[BetaToolUnionParam]]: + """Split a synchronous `tool_runner`'s `tools` argument into the runnable objects the runner dispatches (function + tools and toolsets) and the raw `tools[]` params that go on the wire as given. An async runnable raises + `wrong_flavour_error`.""" + runnable: list[BetaRunnableTool | BetaRunnableToolset] = [] + raw: list[BetaToolUnionParam] = [] + for tool in tools: + if isinstance(tool, _SYNC_RUNNABLES): + runnable.append(tool) + elif isinstance(tool, _ASYNC_RUNNABLES): + raise wrong_flavour_error(tool, expected="sync") + else: + raw.append(tool) + return runnable, raw + + +def partition_async_tools( + tools: Iterable[BetaAsyncRunnableTool | BetaAsyncRunnableToolset | BetaToolUnionParam], +) -> tuple[list[BetaAsyncRunnableTool | BetaAsyncRunnableToolset], list[BetaToolUnionParam]]: + """The async twin of `partition_sync_tools`: a synchronous runnable raises `wrong_flavour_error`.""" + runnable: list[BetaAsyncRunnableTool | BetaAsyncRunnableToolset] = [] + raw: list[BetaToolUnionParam] = [] + for tool in tools: + if isinstance(tool, _ASYNC_RUNNABLES): + runnable.append(tool) + elif isinstance(tool, _SYNC_RUNNABLES): + raise wrong_flavour_error(tool, expected="async") + else: + raw.append(tool) + return runnable, raw class _NamedTool(Protocol): @@ -45,14 +133,30 @@ def call(self, input: object) -> Union[BetaFunctionToolResultType, Awaitable[Bet NamedToolT = TypeVar("NamedToolT", bound=_NamedTool) +ToolsetT = TypeVar("ToolsetT", bound=BetaAnyRunnableToolset) def tool_registry(tools: Iterable[NamedToolT]) -> dict[str, NamedToolT]: - """Index `tools` by their `name` for O(1) dispatch lookup. + """Index the named `tools` by their `name`. On a duplicate name the later tool wins.""" + return {tool.name: tool for tool in tools} + - On a duplicate name the later tool wins, matching a plain dict comprehension. +def index_runnables( + tools: Iterable[NamedToolT | ToolsetT], +) -> tuple[dict[str, NamedToolT], dict[str, ToolsetT]]: + """Index a runner's tools by `name` and its toolsets by family (`toolset_name`). + + A toolset's `tools[]` entry has no `name`, so toolsets are indexed apart and never shadow a + same-named custom tool. On a duplicate key the later one wins. """ - return {tool.name: tool for tool in tools} + by_name: dict[str, NamedToolT] = {} + by_family: dict[str, ToolsetT] = {} + for tool in tools: + if isinstance(tool, TOOLSET_CLASSES): + by_family[tool.toolset_name] = tool + else: + by_name[tool.name] = tool + return by_name, by_family def available_tool_names(messages: Iterable[BetaMessageParam], tool_names: Iterable[str]) -> set[str]: @@ -133,16 +237,50 @@ def _changed_tool_name(tool: _ToolChangeTool) -> str | None: return None +def tool_family(definition: BetaToolUnionParam) -> str: + """A tool definition's family, as the API computes it for a toolset: its `type` ("custom" when absent) without a + trailing `_YYYYMMDD` date and then without `_toolset`. `browser_toolset_20260801` and any later dated version are + "browser".""" + return re.sub(r"_\d{8}$", "", definition.get("type") or "custom").removesuffix("_toolset") + + def tool_error_content(exc: BaseException) -> BetaFunctionToolResultType: """Render an exception raised by a tool as tool-result content. - A `ToolError` carries its own structured content; anything else is - rendered with `repr` (which, unlike `str`, keeps the exception type). - The caller owns the `is_error` flag and any logging. + A `ToolError` has its own structured content, made well formed but + never cut. Anything else is rendered with `repr` (which, unlike `str`, keeps + the exception type), well formed and cut to the field limit so the next + request can include it whatever the exception's text holds. The caller owns + the `is_error` flag and any logging. """ if isinstance(exc, ToolError): - return exc.content - return repr(exc) + return tool_result_content(exc.content) + return error_text(exc) + + +@overload +def tool_result_content(content: None) -> None: ... +@overload +def tool_result_content(content: BetaFunctionToolResultType) -> BetaFunctionToolResultType: ... +def tool_result_content(content: BetaFunctionToolResultType | None) -> BetaFunctionToolResultType | None: + """A tool's result, or a `ToolError`'s content, in a form the next request + can encode: an unpaired surrogate in a string, or in a text block's text, is + folded to U+FFFD. Nothing is cut and no block is dropped, so the length and + shape stay the tool's own. A tool that returns `None` (whatever its + annotation says) sends `content: null`. + """ + if content is None: + return None + if isinstance(content, str): + return well_formed(content) + + blocks = list(content) + for i, block in enumerate(blocks): + if is_dict(block) and block.get("type") == "text": + text = block.get("text") + if isinstance(text, str) and (fixed := well_formed(text)) != text: + blocks[i] = cast(BetaContent, {**block, "text": fixed}) + return blocks async def run_runnable_tool(tool: _CallableTool, input: dict[str, object]) -> BetaFunctionToolResultType: diff --git a/src/anthropic/lib/tools/_toolsets/__init__.py b/src/anthropic/lib/tools/_toolsets/__init__.py new file mode 100644 index 000000000..51ef0f67b --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/__init__.py @@ -0,0 +1,56 @@ +"""Internal machinery for Anthropic-defined toolsets (`browser_toolset_20260801`, `computer_toolset_20260801`). + +Nothing in this package is a supported import. User code imports the browser and computer +toolset classes and their option, context, result and error types from `anthropic.tools.browser` and +`anthropic.tools.computer`. +""" + +from ._errors import ( + TabMissingError as TabMissingError, + URLRefusedError as URLRefusedError, + ToolsetUsageError as ToolsetUsageError, + ConfirmFailedError as ConfirmFailedError, + ToolsetClosedError as ToolsetClosedError, + ToolsetConfigError as ToolsetConfigError, + UnknownMemberError as UnknownMemberError, + UploadRefusedError as UploadRefusedError, + DisabledMemberError as DisabledMemberError, + ConfirmDeclinedError as ConfirmDeclinedError, + ToolsetContractError as ToolsetContractError, + UnavailableMemberError as UnavailableMemberError, + InvalidMemberInputError as InvalidMemberInputError, +) +from ._runnable import ( + ToolsetFamily as ToolsetFamily, + BetaToolsetParam as BetaToolsetParam, + BetaToolsetContent as BetaToolsetContent, + BetaRunnableToolset as BetaRunnableToolset, + BetaAnyRunnableToolset as BetaAnyRunnableToolset, + BetaToolsetCallContext as BetaToolsetCallContext, + BetaAsyncRunnableToolset as BetaAsyncRunnableToolset, + toolset_result_block as toolset_result_block, +) + +__all__ = [ + "ToolsetUsageError", + "ToolsetClosedError", + "ToolsetConfigError", + "ToolsetContractError", + "UnknownMemberError", + "DisabledMemberError", + "UnavailableMemberError", + "InvalidMemberInputError", + "URLRefusedError", + "TabMissingError", + "ConfirmDeclinedError", + "ConfirmFailedError", + "UploadRefusedError", + "ToolsetFamily", + "BetaToolsetContent", + "BetaToolsetParam", + "BetaToolsetCallContext", + "BetaRunnableToolset", + "BetaAnyRunnableToolset", + "BetaAsyncRunnableToolset", + "toolset_result_block", +] diff --git a/src/anthropic/lib/tools/_toolsets/_base.py b/src/anthropic/lib/tools/_toolsets/_base.py new file mode 100644 index 000000000..0114f986e --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_base.py @@ -0,0 +1,567 @@ +"""What the toolset families share: the member registry, the options resolved at construction, the stages of a +member call that every family runs, and the serialized sync and async call loops. + +A family module (`_browser`, `_computer`) supplies its registry and its two abstract classes; the pipeline stages +that are its own (the browser's URL and file policies, its state report) sit beside them in its own modules. +""" + +from __future__ import annotations + +import copy +import inspect +import functools +import threading +from abc import abstractmethod +from typing import Any, Generic, TypeVar, ClassVar, cast +from collections.abc import Mapping, Callable, Iterable, Sequence +from typing_extensions import Literal, TypeAlias, TypedDict, final, get_args, override + +import anyio +import pydantic + +from ._errors import ( + ConfirmFailedError, + ToolsetConfigError, + UnknownMemberError, + DisabledMemberError, + ConfirmDeclinedError, + ToolsetContractError, + UnavailableMemberError, + InvalidMemberInputError, +) +from ._runnable import ( + ToolsetFamily, + BetaToolsetContent, + BaseRunnableToolset, + BetaRunnableToolset, + BetaToolsetCallContext, + BetaAsyncRunnableToolset, + hook_refusal, +) +from ._sanitize import FIELD_MAX, error_text_content +from ...._compat import field_outer_type, get_model_fields +from ...._models import BaseModel, GenericModel, validate_type +from ....types.beta import BetaTextBlockParam, BetaCacheControlEphemeralParam +from .._beta_functions import ToolError +from ..._stainless_helpers import StainlessHelperHeaderValue, tag_helper + +__all__ = [ + "BetaToolConfigs", + "BetaScreenshotResult", + "ResultKind", + "Member", + "Registry", + "BaseToolsetOptions", + "BaseToolset", + "BaseSyncToolset", + "BaseAsyncToolset", + "NestedCallError", + "AsyncOnSyncToolsetError", + "SyncOnAsyncToolsetError", + "check_sync_hook", + "members_from_union", + "overridden", + "build_wire_configs", + "parse_input", + "input_problem", + "confirm_outcome", + "error_content", + "checked_error", + "bounded_error", +] + +_NameT = TypeVar("_NameT", bound=str, covariant=True) +_InputT = TypeVar("_InputT", bound=BaseModel, covariant=True) +_ConfirmT = TypeVar("_ConfirmT", bound="Callable[..., object]") + +ABSENT = object() + + +class BetaToolConfigs(TypedDict, total=False): + """Fields set on the toolset's `tools[]` entry itself rather than on a member.""" + + cache_control: BetaCacheControlEphemeralParam | None + + +class BetaScreenshotResult(BaseModel): + """A screenshot or zoom image from a browser or computer toolset, rendered as one image block. The image must + already fit the model's image limits: the API rejects an oversized one rather than scaling it down.""" + + data: str + """Base64 image bytes, without a `data:` prefix.""" + + media_type: Literal["image/png", "image/jpeg", "image/gif", "image/webp"] = "image/png" + + +ResultKind: TypeAlias = Literal["navigate", "screenshot", "point", "text", "tab", "tabs", "none"] +"""What a member returns: a navigation result, a screenshot, a cursor position, a string, one tab entry, the tab +list, or nothing (a pure action, rendered from its confirmation template followed by the one line of text it may +return).""" + + +class Member(GenericModel, Generic[_NameT, _InputT]): + """One member of a toolset: the model its `tool_use.input` is parsed into, its result kind, the confirmation the + model reads after a pure action (`{field}` placeholders are filled from the input, `{ref}` reads the browser's + `target.ref`, and a line the action returned follows it), and whether it is on by default.""" + + name: _NameT + input: type[_InputT] + result: ResultKind + text: str | None = None + enabled_by_default: bool = True + + +class Registry(Generic[_NameT, _InputT]): + """One family's members by name, in the API's order, with what the family is called on the wire.""" + + def __init__( + self, + *, + family: ToolsetFamily, + helper_tag: StainlessHelperHeaderValue, + members: Mapping[str, Member[_NameT, _InputT]], + ) -> None: + self.family: ToolsetFamily = family + self.helper_tag: StainlessHelperHeaderValue = helper_tag + """The `x-stainless-helper` value a toolset of this family sends.""" + self.members = members + self.names: list[_NameT] = [member.name for member in members.values()] + self.default_disabled: frozenset[str] = frozenset( + name for name, member in members.items() if not member.enabled_by_default + ) + """Members the API withholds unless `configs` enables them.""" + + def member_enabled(self, name: str, configs: Mapping[str, object] | None) -> bool: + """Whether `configs` leave `name` enabled, failing closed when the answer is unclear. + + Only a real boolean `enabled` turns a member on or off. Any other value fails closed, so a + malformed entry can never turn on a member that is off by default. + """ + entry = configs.get(name) if configs else None + if isinstance(entry, Mapping): + enabled: object = entry.get("enabled") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + if enabled is True: + return True + if enabled is not None: + return False + elif entry is not None: + return False # an entry that is neither a mapping nor None (`{"navigate": False}`) reads as off, not as default + return name not in self.default_disabled + + +class NestedCallError(ToolsetContractError): + def __init__(self) -> None: + super().__init__( + "call() was invoked from inside a member; a member that composes other members calls their methods directly" + ) + + +class AsyncOnSyncToolsetError(ToolsetContractError): + def __init__(self, what: str, *, twin: str) -> None: + super().__init__(f"{what} is async on the synchronous toolset; define it with def, or subclass {twin}") + + +class SyncOnAsyncToolsetError(ToolsetContractError): + def __init__(self, what: str, *, twin: str) -> None: + super().__init__(f"{what} is sync on the asynchronous toolset; define it with async def, or subclass {twin}") + + +class BaseToolsetOptions(Generic[_NameT, _InputT, _ConfirmT]): + """The constructor options every family has, validated and resolved once at construction. A mistake in them + raises `ToolsetConfigError` here, and a member, an `execute` or a `confirm` written for the other flavour + (`async def` on the synchronous class, a plain `def` member on the asynchronous one) raises + `ToolsetContractError`. + + - `wire_configs` is what the `tools[]` entry sends: the caller's `configs` plus `enabled: false` for every member + `cls` does not serve. + - `served` is the members a sampled call may reach (the implemented ones, or all of them for a class that + overrides `execute`). + - `configured` is the members present in the caller's own `configs`. + + A family with more options subclasses this and validates them after `super().__init__`.""" + + def __init__( + self, + cls: type[BaseToolset[Any, Any, Any]], + *, + registry: Registry[_NameT, _InputT], + default_bodies: frozenset[object], + configs: Mapping[str, object] | None, + confirm: _ConfirmT | None, + tool_configs: BetaToolConfigs | None, + family_methods: Sequence[str] = (), + ) -> None: + self.registry = registry + implemented = overridden(cls, registry.names, default_bodies) + check_flavour(cls, [*sorted(implemented), "execute", *family_methods], registry=registry) + self.served = frozenset(registry.names) if overridden(cls, ["execute"], default_bodies) else implemented + self.wire_configs = build_wire_configs(configs, self.served, registry.names) + self.configured = frozenset(() if configs is None else configs) + + if confirm is not None: + check_sync_hook(cls, "confirm", confirm) + self.confirm = confirm + + if "configs" in (tool_configs or {}): + raise ToolsetConfigError("tool_configs cannot set 'configs'; pass configs= instead") + self.tool_configs: Mapping[str, object] = copy.deepcopy(dict(tool_configs or {})) + + def is_enabled(self, name: str) -> bool: + """Whether the member is offered to the model and may be dispatched.""" + return self.registry.member_enabled(name, self.wire_configs) + + def resolve(self, name: str) -> Member[_NameT, _InputT]: + """Stage one of a call: the registry row for `name`, or the refusal the model reads (an + unknown name, a member this application disabled, or one the driver does not implement).""" + member = self.registry.members.get(name) + if member is None: + raise UnknownMemberError(name, family=self.registry.family) + if not self.is_enabled(member.name): + if member.name not in self.served and member.name not in self.configured: + raise UnavailableMemberError(member.name, family=self.registry.family) + raise DisabledMemberError(member.name) + return member + + +class BaseToolset(BaseRunnableToolset, Generic[_NameT, _InputT, _ConfirmT]): + """What every toolset class shares below its family: the options resolved at construction and the registry + lookup that keeps model output away from the instance's other attributes.""" + + _toolset_twin: ClassVar[str] + """The name of the class with the other calling convention (the async class's sync twin and vice versa), for the + contract error that tells a driver which class its `def` or `async def` members belong on.""" + + def __init__(self, options: BaseToolsetOptions[_NameT, _InputT, _ConfirmT]) -> None: + self._toolset_options = options + tag_helper(self, options.registry.helper_tag) + + def _toolset_member_method(self, name: str) -> Callable[..., Any]: + """Resolve `name` through the registry before touching the instance: `getattr(self, name)` + on raw model output would reach `__init__` or any other attribute.""" + if name not in self._toolset_options.registry.members: + raise UnknownMemberError(name, family=self._toolset_options.registry.family) + method: Callable[..., Any] = getattr(self, name) + return method + + +class BaseSyncToolset(BaseToolset[_NameT, _InputT, _ConfirmT], BetaRunnableToolset): + """The synchronous call loop: member calls run one at a time, `close` waits for the calls in flight, and the + family's `_toolset_run` does the work of one call under the lock.""" + + def __init__(self, options: BaseToolsetOptions[_NameT, _InputT, _ConfirmT]) -> None: + super().__init__(options) + + # One member call runs at a time: `__lock` serialises them, and `__lock_holder` records the thread inside it + # so a nested `call()` or a `close()` from within a member is recognised. + self.__lock = threading.Lock() + self.__lock_holder: int | None = None + + # `close()` waits on `__settled` until `__pending` (the calls accepted and not yet finished, running or queued) + # drops to zero. + self.__pending = 0 + self.__settled = threading.Condition() + + @final + @override + def call(self, context: BetaToolsetCallContext, name: str, input: Mapping[str, object]) -> BetaToolsetContent: + """The entry point the tool runner and `tool_result` use: runs one member call through the + pipeline and returns its `tool_result` content. + + A refusal or failure is raised as a `ToolError` that carries the `tool_result` content. Its `__cause__` is the + specific refusal (`DisabledMemberError`, …) or, when a member raised something other than `ToolError`, the + `ToolError` that wraps it. + + Calls run one at a time (no order promised). + + Not an extension point (override `execute` or a member method) and not for use from inside a member: a member + that composes others calls their methods directly.""" + if self.__lock_holder == threading.get_ident(): + raise NestedCallError() + + with self.__settled: + self._toolset_refuse_if_closed() + self.__pending += 1 + + try: + with self.__lock: + self.__lock_holder = threading.get_ident() + try: + return self._toolset_run(context, name, input) + finally: + self.__lock_holder = None + finally: + with self.__settled: + self.__pending -= 1 + if not self.__pending: + self.__settled.notify_all() + + @override + def close(self) -> None: + """Release what the toolset holds when you are done. The tool runner never calls this, so one instance can + serve several runs, and a `with` block calls it for you. + + Marks the toolset closed (a call that arrives afterwards raises `ToolsetClosedError`), then waits for the + calls already accepted, in flight or queued, to settle, so an override that calls `super().close()` first + tears the backend down with nothing still using it. + + Called from inside a member, `close` returns without waiting: that member's own call is the one in flight.""" + super().close() + + if self.__lock_holder == threading.get_ident(): + return # the caller is the call in flight + + with self.__settled: + while self.__pending: + self.__settled.wait() + + @abstractmethod + def _toolset_run( + self, context: BetaToolsetCallContext, name: str, input: Mapping[str, object] + ) -> BetaToolsetContent: + """One member call, under the lock: the family's pipeline from the raw `tool_use.input` to the content.""" + ... + + def _toolset_confirm(self, name: str, shown: BetaToolsetCallContext) -> None: + """The approval gate: every call about to run is shown to `confirm` first, inside the pipeline + and under the lock, so no override of `execute` or of the member can skip it.""" + confirm = self._toolset_options.confirm + if confirm is None: + return + + returned: object = None + raised: Exception | None = None + try: + returned = confirm(shown) + except Exception as exc: + raised = exc + + confirm_outcome(name, returned, raised) + + +class BaseAsyncToolset(BaseToolset[_NameT, _InputT, _ConfirmT], BetaAsyncRunnableToolset): + """The asynchronous call loop; see `BaseSyncToolset`.""" + + def __init__(self, options: BaseToolsetOptions[_NameT, _InputT, _ConfirmT]) -> None: + super().__init__(options) + + # One member call runs at a time: `__lock` (created on first use, so the toolset can be constructed outside an + # event loop) serialises them, and `__lock_holder` records the task inside it so a nested `call()` is + # recognised. + self.__lock: anyio.Lock | None = None + self.__lock_holder: object = None + + # `close()` waits on `__settled` until `__pending` (the calls accepted and not yet finished) drops to zero. A + # fresh event is armed whenever a call arrives with none pending. + self.__pending = 0 + self.__settled: anyio.Event | None = None + + @final + @override + async def call(self, context: BetaToolsetCallContext, name: str, input: Mapping[str, object]) -> BetaToolsetContent: + """The entry point the tool runner and `tool_result` use. See the synchronous class's + `call`. A cancelled task raises out of here at the member's own `await` and the lock is + released.""" + self._toolset_refuse_if_closed() + + if self.__lock is None: + self.__lock = anyio.Lock() + current = anyio.get_current_task() + if self.__lock_holder == current: + raise NestedCallError() + + lock = self.__lock + if not self.__pending: + self.__settled = anyio.Event() + self.__pending += 1 + + try: + async with lock: + self.__lock_holder = current + try: + return await self._toolset_run(context, name, input) + finally: + self.__lock_holder = None + finally: + self.__pending -= 1 + if not self.__pending and self.__settled is not None: + self.__settled.set() + + @override + async def close(self) -> None: + """Release what the toolset holds when you are done. See the synchronous class's `close`. Marks the toolset + closed, then waits for the calls already accepted to settle. An override awaits `super().close()` first. + Called from inside a member it returns without waiting.""" + await super().close() + + if self.__lock is None or self.__lock_holder == anyio.get_current_task(): + return # no call was ever accepted, or the caller is the call in flight + + while self.__pending and self.__settled is not None: + await self.__settled.wait() + + @abstractmethod + async def _toolset_run( + self, context: BetaToolsetCallContext, name: str, input: Mapping[str, object] + ) -> BetaToolsetContent: ... + + async def _toolset_confirm(self, name: str, shown: BetaToolsetCallContext) -> None: + confirm = self._toolset_options.confirm + if confirm is None: + return + + returned: object = None + raised: Exception | None = None + try: + returned = confirm(shown) + if inspect.isawaitable(returned): + returned = await returned + except Exception as exc: + raised = exc + + confirm_outcome(name, returned, raised) + + +def members_from_union( + block_union: object, stand_ins: Mapping[str, type[BaseModel]] +) -> list[tuple[str, type[BaseModel]]]: + """One `(name, input model)` per variant of a generated `tool_use` union, in its order. A variant whose `input` + is typed as a bare object (a member that takes none) gets its model from `stand_ins`.""" + annotated = get_args(block_union) # (Union[...], discriminator) + members: list[tuple[str, type[BaseModel]]] = [] + for variant in cast("tuple[type[BaseModel], ...]", get_args(annotated[0])): + fields = get_model_fields(variant) + (name,) = cast("tuple[str]", get_args(field_outer_type(fields["name"]))) + generated = field_outer_type(fields["input"]) + members.append((name, stand_ins[name] if generated is object else cast("type[BaseModel]", generated))) + return members + + +def overridden(cls: type, names: Sequence[str], default_bodies: frozenset[object]) -> frozenset[str]: + """Those of `names` that `cls` overrides, by method identity against the family's abstract classes (their own + member and `execute` bodies are `default_bodies`). Nothing is called.""" + return frozenset(name for name in names if getattr(cls, name) not in default_bodies) + + +def is_async_callable(obj: object) -> bool: + """Whether calling `obj` returns a coroutine: an `async def` function, an object whose `__call__` is one, or a + `functools.partial` of either.""" + while isinstance(obj, functools.partial): + obj = obj.func + return inspect.iscoroutinefunction(obj) or inspect.iscoroutinefunction(getattr(obj, "__call__", None)) # noqa: B004 + + +def check_flavour(cls: type[BaseToolset[Any, Any, Any]], names: Iterable[str], *, registry: Registry[Any, Any]) -> None: + """Raise when one of toolset class `cls`'s methods `names` is written for the other flavour: `async def` on the + synchronous toolset, where nothing would await it, or a plain `def` on the asynchronous one, where it would run + blocking I/O on the event loop.""" + is_async = issubclass(cls, BetaAsyncRunnableToolset) + for name in names: + if is_async_callable(getattr(cls, name)) == is_async: + continue + what = f"{registry.family} member {name!r}" if name in registry.members else name + error = SyncOnAsyncToolsetError if is_async else AsyncOnSyncToolsetError + raise error(what, twin=cls._toolset_twin) + + +def check_sync_hook(cls: type[BaseToolset[Any, Any, Any]], what: str, hook: object) -> None: + """Raise when `hook` (`confirm`, or a family's own hook) is async and `cls` is a synchronous toolset. The + asynchronous classes take either kind.""" + if not issubclass(cls, BetaAsyncRunnableToolset) and is_async_callable(hook): + raise AsyncOnSyncToolsetError(what, twin=cls._toolset_twin) + + +def build_wire_configs( + configs: Mapping[str, object] | None, served: frozenset[str], names: Sequence[str] +) -> dict[str, dict[str, object] | None] | None: + """The caller's `configs`, deep-copied, with `enabled: false` added for every member that is + not served. Enabling such a member is a configuration error.""" + # This copy is what the dispatch gate reads and what the request sends. None means "this member's defaults". A + # malformed entry fails closed (member_enabled reads it as off, the API rejects it), and one that is not a mapping + # raises here. + wire: dict[str, dict[str, object] | None] = { + name: None if given is None else copy.deepcopy({**cast("Mapping[str, object]", given)}) + for name, given in (configs.items() if configs is not None else ()) + } + + unserved = [name for name in names if name not in served and (wire.get(name) or {}).get("enabled") is True] + if unserved: # the model would be offered a member that can only ever answer "not available" + raise ToolsetConfigError( + f"configs enables member(s) this toolset does not implement: {sorted(unserved)!r} " + "(override the member method; a subclass that overrides only execute() serves every member)" + ) + + for name in names: + if name in served: + continue + wire[name] = {**(wire.get(name) or {}), "enabled": False} + return wire if wire else None + + +def parse_input(member: Member[_NameT, _InputT], raw: Mapping[str, object], *, family: ToolsetFamily) -> _InputT: + """Parse the model's `tool_use.input` into the member's input model. + + A mistyped field is the model's mistake, so it comes back as a `ToolError` the model can read + and correct rather than an exception that ends the loop. The value is not echoed. A key the schema + does not declare stays on the parsed input, so the driver and `confirm` read what the model sent, except an + undeclared key named after an attribute of the model, which is removed before validation.""" + fields = get_model_fields(member.input) + declared = {*fields, *(field.alias for field in fields.values() if field.alias)} + # On pydantic v1 an extra field becomes an instance attribute, so a key named after an attribute of the model class, + # such as `to_dict`, would replace it. `getattr_static` avoids reading `__fields__`, which warns on pydantic v2. + raw = { + key: value + for key, value in raw.items() + if key in declared or inspect.getattr_static(member.input, key, ABSENT) is ABSENT + } + + try: + return validate_type(type_=member.input, value=raw) + except pydantic.ValidationError as exc: + # every failed field, so the model can correct them in one round trip + problems = "; ".join(input_problem(error["loc"], error["msg"]) for error in exc.errors()) or "invalid input" + raise InvalidMemberInputError(member.name, problems, family=family) from None + + +def input_problem(loc: Sequence[int | str], msg: str) -> str: + """One pydantic error as `loc: message` (the value is not echoed).""" + where = ".".join(str(part) for part in loc) + first = msg.splitlines()[0] if msg else "invalid input" + return f"{where}: {first}" if where else first + + +def confirm_outcome(name: str, returned: object, raised: Exception | None) -> None: + """Turn what the `confirm` callable did into the gate's decision: return to proceed, or raise the + refusal the model reads. + + A `ToolError` that `confirm` raised is relayed as-is. Any other exception means the prompt failed: the call is + refused and the run continues. Any answer other than `True` declines the call.""" + if raised is not None: + raise hook_refusal(raised, ConfirmFailedError(name)) + if returned is not True: + if inspect.iscoroutine(returned): + returned.close() # from a confirm that returns a coroutine: declined, with no "never awaited" warning + raise ConfirmDeclinedError(name) + + +def error_content(error: ToolError) -> list[BetaTextBlockParam]: + content = error_text_content(error.content) + return [{"type": "text", "text": content}] if isinstance(content, str) else content + + +def checked_error(error: ToolError, check: Callable[[str], str]) -> ToolError: + """`error` with each text block run through `check` and then bounded, or the same object when nothing changed. + The bound comes after the check, so the check reads the driver's text whole.""" + content = error_content(error) + checked: list[BetaTextBlockParam] = [] + for block in content: + block = copy.copy(block) + block["text"] = check(block["text"])[:FIELD_MAX] + checked.append(block) + if checked == content: + return error + return ToolError(checked) + + +def bounded_error(error: ToolError) -> ToolError: + """`checked_error` with no check: each text block cut to the field limit, for a family with nothing to redact.""" + return checked_error(error, lambda text: text) diff --git a/src/anthropic/lib/tools/_toolsets/_browser.py b/src/anthropic/lib/tools/_toolsets/_browser.py new file mode 100644 index 000000000..6425b5d12 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_browser.py @@ -0,0 +1,699 @@ +"""The browser toolset interface: `BetaAbstractBrowserToolset20260801` and its async twin. + +A driver subclasses one of them and overrides the members its backend supports. A member it does +not override is reported to the API as disabled and, if the model calls it anyway, answered with an +`is_error` result. `execute` is the dispatch layer: override it (calling `super().execute`) +for before/after hooks around every member. `call` runs the SDK's pipeline around it. +""" + +from __future__ import annotations + +import copy +import inspect +from abc import abstractmethod +from typing import Any, cast +from collections.abc import Mapping +from typing_extensions import override + +import anyio.to_thread + +from ._base import ( + BaseToolset, + BaseSyncToolset, + BaseAsyncToolset, + BaseToolsetOptions, + BetaScreenshotResult, + parse_input, + bounded_error, + check_sync_hook, +) +from ._hooks import ( + BetaURLPolicy, + BetaFilePolicy, + BetaURLContext, + BetaToolConfigs, + BetaAsyncURLPolicy, + BetaConfirmCallable, + BetaAsyncConfirmCallable, +) +from ._errors import ToolsetUsageError, ToolsetConfigError, ToolsetContractError, UnavailableMemberError +from ._inputs import BROWSER_MEMBER_NAMES, BetaBrowserMemberName +from ._results import BetaBrowserState, BetaBrowserMemberResult, BetaBrowserNavigateResult +from ...._types import NOT_GIVEN, NotGiven +from ...._utils import is_given +from ._pipeline import ( + CallRecord, + PipelineState, + url_context, + navigate_url, + bounded_result, + policy_outcome, + confirm_context, + resolve_upload_paths, + with_bounded_tab_urls, +) +from ._registry import FAMILY, BROWSER, TOOLSET_TYPE, CONFIRM_REQUIRED +from ._runnable import BetaToolsetContent, BetaToolsetCallContext, classify +from ....types.beta import ( + BetaBrowserKeyInput, + BetaBrowserFindInput, + BetaBrowserTypeInput, + BetaBrowserWaitInput, + BetaBrowserZoomInput, + BetaBrowserHoverInput, + BetaBrowserMemberInput, + BetaBrowserNewTabInput, + BetaBrowserScrollInput, + BetaBrowserHoldKeyInput, + BetaBrowserCloseTabInput, + BetaBrowserListTabsInput, + BetaBrowserNavigateInput, + BetaBrowserReadPageInput, + BetaBrowserScrollToInput, + BetaBrowserFormInputInput, + BetaBrowserLeftClickInput, + BetaBrowserMouseMoveInput, + BetaBrowserSwitchTabInput, + BetaBrowserFileUploadInput, + BetaBrowserRightClickInput, + BetaBrowserScreenshotInput, + BetaBrowserDoubleClickInput, + BetaBrowserGetPageTextInput, + BetaBrowserLeftMouseUpInput, + BetaBrowserMiddleClickInput, + BetaBrowserReadConsoleInput, + BetaBrowserReadNetworkInput, + BetaBrowserTripleClickInput, + BetaBrowserLeftClickDragInput, + BetaBrowserLeftMouseDownInput, + BetaBrowserStateTabEntryParam, + BetaBrowserJavascriptExecInput, + BetaBrowserToolsetConfigsParam, + BetaBrowserToolset20260801Param, +) +from .._beta_functions import ToolError + +__all__ = ["BetaAbstractBrowserToolset20260801", "BetaAsyncAbstractBrowserToolset20260801"] + + +class BrowserStateError(ToolsetContractError): + """`_browser_state` raised or returned something that is not a `BetaBrowserState`: the driver's bug, not the model's.""" + + def __init__(self, exc: Exception) -> None: + super().__init__( + f"_browser_state raised {exc!r} or returned no BetaBrowserState; return one, and catch failures inside the hook" + ) + + +class BrowserOptions( + BaseToolsetOptions[BetaBrowserMemberName, BetaBrowserMemberInput, BetaConfirmCallable | BetaAsyncConfirmCallable] +): + """The constructor options of browser toolset class `cls`, validated and resolved once at construction. A mistake + in them raises `ToolsetConfigError` here, and a member or hook written for the other flavour (`async def` on the + synchronous class, a plain `def` member on the asynchronous one) raises `ToolsetContractError`. Beyond what every + family has (`BaseToolsetOptions`): `url_policy` is what the caller passed, or `NOT_GIVEN` when `navigate` is + not checked, and `file_policy` the caller's, if any.""" + + def __init__( + self, + cls: type[BaseToolset[Any, Any, Any]], + *, + configs: BetaBrowserToolsetConfigsParam | None, + confirm: BetaConfirmCallable | BetaAsyncConfirmCallable | None, + url_policy: BetaURLPolicy | BetaAsyncURLPolicy | NotGiven, + file_policy: BetaFilePolicy | None, + tool_configs: BetaToolConfigs | None, + ) -> None: + super().__init__( + cls, + registry=BROWSER, + default_bodies=DEFAULT_BODIES, + configs=configs, + confirm=confirm, + tool_configs=tool_configs, + family_methods=["_browser_state"], + ) + self.file_policy = file_policy + # Kept as passed: an explicit None is called like any other policy, so it refuses navigate rather than reading + # as "unset" and leaving it unchecked. + self.url_policy = url_policy + + if is_given(url_policy): + check_sync_hook(cls, "url_policy", url_policy) + + required = sorted(name for name in CONFIRM_REQUIRED if name in self.served and self.is_enabled(name)) + if required and confirm is None: + # these members act on page-supplied intent (a script to run, files to send), so they are always gated + raise ToolsetConfigError( + f"{required!r} requires a confirm callable: pass confirm=, or disable it in configs" + ) + + +class BaseBrowserToolset( + BaseToolset[BetaBrowserMemberName, BetaBrowserMemberInput, BetaConfirmCallable | BetaAsyncConfirmCallable] +): + """What the synchronous and asynchronous browser toolsets share: the options resolved at construction, the + `tools[]` entry built from them, and the per-instance pipeline state. Subclass one of the two public classes, + not this one.""" + + toolset_name = FAMILY + + def __init__( + self, + *, + configs: BetaBrowserToolsetConfigsParam | None, + confirm: BetaConfirmCallable | BetaAsyncConfirmCallable | None, + url_policy: BetaURLPolicy | BetaAsyncURLPolicy | NotGiven, + file_policy: BetaFilePolicy | None, + tool_configs: BetaToolConfigs | None, + ) -> None: + self._toolset_options: BrowserOptions = BrowserOptions( + type(self), + configs=configs, + confirm=confirm, + url_policy=url_policy, + file_policy=file_policy, + tool_configs=tool_configs, + ) + super().__init__(self._toolset_options) + self._toolset_pipeline: PipelineState = PipelineState() + + @override + def to_dict(self) -> BetaBrowserToolset20260801Param: + options = self._toolset_options + + # fresh copies each call: the runner keeps what to_dict() returns, so a later in-place edit must not reach it + param = cast(BetaBrowserToolset20260801Param, {**copy.deepcopy(options.tool_configs), "type": TOOLSET_TYPE}) + if options.wire_configs is not None: + param["configs"] = cast("BetaBrowserToolsetConfigsParam", copy.deepcopy(options.wire_configs)) + return param + + @property + def configs(self) -> BetaBrowserToolsetConfigsParam | None: + """The wire `configs` that `to_dict()` sends: yours, plus `enabled: False` for every member this class does + not serve. A copy: nothing you do to it changes the toolset.""" + return self.to_dict().get("configs") + + +class BetaAbstractBrowserToolset20260801( + BaseBrowserToolset, + BaseSyncToolset[BetaBrowserMemberName, BetaBrowserMemberInput, BetaConfirmCallable | BetaAsyncConfirmCallable], +): + """The synchronous browser toolset for `browser_toolset_20260801`. + + Subclass it and override the members your backend supports. Each takes `(context, input)` + and returns the result type in its signature. A pure action (a click, typing, scrolling) returns + nothing, or a string the model reads in a text block of its own after the SDK's fixed + acknowledgment (such as `Clicked.`), folded to one line. See the browser use tool + documentation (https://platform.claude.com/docs/en/agents-and-tools/tool-use/browser-use-tool) and read + "Running a browser toolset safely" in the SDK guide (`browser-toolset.md`) before deploying one. + """ + + _toolset_twin = "BetaAsyncAbstractBrowserToolset20260801" + + def __init__( + self, + *, + configs: BetaBrowserToolsetConfigsParam | None = None, + confirm: BetaConfirmCallable | None = None, + url_policy: BetaURLPolicy | NotGiven = NOT_GIVEN, + file_policy: BetaFilePolicy | None = None, + tool_configs: BetaToolConfigs | None = None, + ) -> None: + """A browser toolset with the given options. A mistake in them raises `ToolsetConfigError` here. + + Args: + configs: The `configs` object of the `tools[]` entry, sent as given: + `{"": {"enabled": bool, "defer_loading": bool}}`. This is how a member is + switched on or off. The SDK adds `enabled: False` for every member the subclass + does not implement and never dispatches a disabled member. A subclass that overrides + `execute` serves every member, so turn off the ones it does not serve here. + confirm: Optional. `(context) -> bool`, called before every member call the SDK is about + to run (after the URL policy and the file policy, never for a call already refused). Return + `True` to run it or `False` to answer the model with a refusal, and decide by + `context.member` which members prompt a person. `context` also has the + parsed `input` and the target tab's `tab_url` / `tab_id` as of the last + `browser_state` report the SDK collected (see `BetaConfirmContext`). Required when `javascript_exec` or + `file_upload` is enabled. + url_policy: Optional. `(BetaURLContext, str) -> None`, raising `ToolError` to refuse. Called once + for each `navigate` that has a URL, with the URL exactly as the model wrote it, before the + driver receives it (`back`, `forward` and `reload` are not checked). Left unset, `navigate` is + not checked. This is a hook for your own policy, not a security + boundary: it does not see redirects, sub-resources or addresses a page reaches on its own. + Request interception in the driver and egress rules around the browser cover those. + file_policy: Optional `BetaFilePolicy` (the SDK ships `BetaLocalFilePolicy`): which local paths and + Files API document ids `file_upload` may use, and whether a download's local path is + shown to the model. Without one, an upload with a path or document id is refused and + download paths stay hidden. + tool_configs: Optional fields set on the `tools[]` entry itself rather than on a member: + `{"cache_control": {"type": "ephemeral"}}`. + """ + super().__init__( + configs=configs, confirm=confirm, url_policy=url_policy, file_policy=file_policy, tool_configs=tool_configs + ) + + @override + def _toolset_run( + self, context: BetaToolsetCallContext, name: str, input: Mapping[str, object] + ) -> BetaToolsetContent: + record = CallRecord(context=context, name=name, raw_input=input) + options = self._toolset_options + + try: + member = options.resolve(record.name) + record.member = member + record.input = parse_input(member, record.raw_input, family=FAMILY) + + url = navigate_url(record) + if url is not None: + self.__apply_policy(url, url_context(record)) + resolve_upload_paths(record, options.file_policy) + self._toolset_confirm(member.name, confirm_context(record, self._toolset_pipeline)) + + try: + result = self.execute(record.context, member.name, record.input) + except Exception as exc: + # The driver's own error text reaches the model, cut to the field limit. + record.error = bounded_error(classify(exc)) + else: + record.result = bounded_result(member, result) + except ToolError as exc: + record.error = exc + + state = self.__browser_state(record.context) + content, is_error = self._toolset_pipeline.finish(record, state, options.file_policy) + if is_error: + raise ToolError(content) from record.error + return content + + def __browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + # Called after every call, failed and refused ones included, so the driver can report what + # changed meanwhile. Those changes go out with the next block that reaches the model. + try: + return with_bounded_tab_urls(self._browser_state(context)) + except ToolsetUsageError: + raise + except Exception as exc: + raise BrowserStateError(exc) from exc + + def __apply_policy(self, url: str, context: BetaURLContext) -> None: + """Run the `url_policy` callable on one address, raising the refusal unless it passes or there is no policy.""" + policy = self._toolset_options.url_policy + if not is_given(policy): + return + + returned: object = None + raised: Exception | None = None + try: + returned = policy(context, url) + except Exception as exc: + raised = exc + + policy_outcome(returned, raised) + + def execute( + self, context: BetaToolsetCallContext, name: BetaBrowserMemberName, input: BetaBrowserMemberInput + ) -> BetaBrowserMemberResult: + """Dispatch one member call to its method. + + Override it, calling `super().execute(context, name, input)`, for before/after hooks around every member + (telemetry, logging, rewriting the input or the typed result). The pipeline still wraps the override, so the URL + policy, the file policy and `confirm` run before it. A subclass that forwards every member elsewhere (a remote + browser, a recording proxy) implements this method and `_browser_state`. A subclass that overrides it serves + every member: one it implements runs through `super().execute`, one it does not raises `ToolError` from its + default body there, and `configs` is how it turns off the members it does not serve. A call the SDK refused + before dispatch never reaches it.""" + return self._toolset_member_method(name)(context, input) + + @abstractmethod + def _browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + """The browser after a call: the open tabs (one marked active) and the state changes since the previous + report. Every driver implements it. The SDK calls it once after every member call, refused and failed ones + included (not one that raised `ToolsetUsageError` or was interrupted), and attaches the result as the + `browser_state` block, less the download paths the file policy hides. An async method is a contract error.""" + raise NotImplementedError + + def navigate(self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput) -> BetaBrowserNavigateResult: + raise UnavailableMemberError("navigate", family=FAMILY) + + def screenshot(self, context: BetaToolsetCallContext, input: BetaBrowserScreenshotInput) -> BetaScreenshotResult: + raise UnavailableMemberError("screenshot", family=FAMILY) + + def zoom(self, context: BetaToolsetCallContext, input: BetaBrowserZoomInput) -> BetaScreenshotResult: + raise UnavailableMemberError("zoom", family=FAMILY) + + def left_click(self, context: BetaToolsetCallContext, input: BetaBrowserLeftClickInput) -> str | None: + raise UnavailableMemberError("left_click", family=FAMILY) + + def right_click(self, context: BetaToolsetCallContext, input: BetaBrowserRightClickInput) -> str | None: + raise UnavailableMemberError("right_click", family=FAMILY) + + def middle_click(self, context: BetaToolsetCallContext, input: BetaBrowserMiddleClickInput) -> str | None: + raise UnavailableMemberError("middle_click", family=FAMILY) + + def double_click(self, context: BetaToolsetCallContext, input: BetaBrowserDoubleClickInput) -> str | None: + raise UnavailableMemberError("double_click", family=FAMILY) + + def triple_click(self, context: BetaToolsetCallContext, input: BetaBrowserTripleClickInput) -> str | None: + raise UnavailableMemberError("triple_click", family=FAMILY) + + def hover(self, context: BetaToolsetCallContext, input: BetaBrowserHoverInput) -> str | None: + raise UnavailableMemberError("hover", family=FAMILY) + + def left_click_drag(self, context: BetaToolsetCallContext, input: BetaBrowserLeftClickDragInput) -> str | None: + raise UnavailableMemberError("left_click_drag", family=FAMILY) + + def left_mouse_down(self, context: BetaToolsetCallContext, input: BetaBrowserLeftMouseDownInput) -> str | None: + raise UnavailableMemberError("left_mouse_down", family=FAMILY) + + def left_mouse_up(self, context: BetaToolsetCallContext, input: BetaBrowserLeftMouseUpInput) -> str | None: + raise UnavailableMemberError("left_mouse_up", family=FAMILY) + + def mouse_move(self, context: BetaToolsetCallContext, input: BetaBrowserMouseMoveInput) -> str | None: + raise UnavailableMemberError("mouse_move", family=FAMILY) + + def scroll(self, context: BetaToolsetCallContext, input: BetaBrowserScrollInput) -> str | None: + raise UnavailableMemberError("scroll", family=FAMILY) + + def scroll_to(self, context: BetaToolsetCallContext, input: BetaBrowserScrollToInput) -> str | None: + raise UnavailableMemberError("scroll_to", family=FAMILY) + + def type(self, context: BetaToolsetCallContext, input: BetaBrowserTypeInput) -> str | None: + raise UnavailableMemberError("type", family=FAMILY) + + def key(self, context: BetaToolsetCallContext, input: BetaBrowserKeyInput) -> str | None: + raise UnavailableMemberError("key", family=FAMILY) + + def hold_key(self, context: BetaToolsetCallContext, input: BetaBrowserHoldKeyInput) -> str | None: + raise UnavailableMemberError("hold_key", family=FAMILY) + + def form_input(self, context: BetaToolsetCallContext, input: BetaBrowserFormInputInput) -> str | None: + raise UnavailableMemberError("form_input", family=FAMILY) + + def read_page(self, context: BetaToolsetCallContext, input: BetaBrowserReadPageInput) -> str: + raise UnavailableMemberError("read_page", family=FAMILY) + + def find(self, context: BetaToolsetCallContext, input: BetaBrowserFindInput) -> str: + raise UnavailableMemberError("find", family=FAMILY) + + def get_page_text(self, context: BetaToolsetCallContext, input: BetaBrowserGetPageTextInput) -> str: + raise UnavailableMemberError("get_page_text", family=FAMILY) + + def wait(self, context: BetaToolsetCallContext, input: BetaBrowserWaitInput) -> str | None: + raise UnavailableMemberError("wait", family=FAMILY) + + def file_upload(self, context: BetaToolsetCallContext, input: BetaBrowserFileUploadInput) -> str | None: + """Default-disabled. Enabling it in `configs` lets a page read files on the machine running + the browser: pass a `file_policy` confined to one dedicated upload directory. `input.paths` + arrive already resolved by it.""" + raise UnavailableMemberError("file_upload", family=FAMILY) + + def read_console(self, context: BetaToolsetCallContext, input: BetaBrowserReadConsoleInput) -> str: + raise UnavailableMemberError("read_console", family=FAMILY) + + def read_network(self, context: BetaToolsetCallContext, input: BetaBrowserReadNetworkInput) -> str: + raise UnavailableMemberError("read_network", family=FAMILY) + + def javascript_exec(self, context: BetaToolsetCallContext, input: BetaBrowserJavascriptExecInput) -> str: + """Default-disabled. Page content can steer what the model asks to run, so enabling it takes a + `confirm` callable, which sees every call first. The script runs with the page's own authority (its + cookies, storage and signed-in sessions), so the browser profile you drive must not be signed into accounts + whose data or actions you would not hand the model.""" + raise UnavailableMemberError("javascript_exec", family=FAMILY) + + def new_tab(self, context: BetaToolsetCallContext, input: BetaBrowserNewTabInput) -> BetaBrowserStateTabEntryParam: + raise UnavailableMemberError("new_tab", family=FAMILY) + + def list_tabs( + self, context: BetaToolsetCallContext, input: BetaBrowserListTabsInput + ) -> list[BetaBrowserStateTabEntryParam]: + raise UnavailableMemberError("list_tabs", family=FAMILY) + + def switch_tab( + self, context: BetaToolsetCallContext, input: BetaBrowserSwitchTabInput + ) -> BetaBrowserStateTabEntryParam: + raise UnavailableMemberError("switch_tab", family=FAMILY) + + def close_tab(self, context: BetaToolsetCallContext, input: BetaBrowserCloseTabInput) -> None: + raise UnavailableMemberError("close_tab", family=FAMILY) + + +class BetaAsyncAbstractBrowserToolset20260801( + BaseBrowserToolset, + BaseAsyncToolset[BetaBrowserMemberName, BetaBrowserMemberInput, BetaConfirmCallable | BetaAsyncConfirmCallable], +): + """The asynchronous browser toolset for `browser_toolset_20260801`, for `AsyncAnthropic`. + + Identical to `BetaAbstractBrowserToolset20260801` except that members and `_browser_state` are + async (a sync one is a contract error), `confirm` and `url_policy` may be either, and `close` (also the + async context-manager exit) is awaited. + """ + + _toolset_twin = "BetaAbstractBrowserToolset20260801" + + def __init__( + self, + *, + configs: BetaBrowserToolsetConfigsParam | None = None, + confirm: BetaAsyncConfirmCallable | None = None, + url_policy: BetaAsyncURLPolicy | NotGiven = NOT_GIVEN, + file_policy: BetaFilePolicy | None = None, + tool_configs: BetaToolConfigs | None = None, + ) -> None: + """An asynchronous browser toolset with the given options. `confirm` and `url_policy` may be plain or + `async` callables here. A mistake in the options raises `ToolsetConfigError`. + + Args: + configs: The `configs` object of the `tools[]` entry, sent as given: + `{"": {"enabled": bool, "defer_loading": bool}}`. This is how a member is + switched on or off. The SDK adds `enabled: False` for every member the subclass + does not implement and never dispatches a disabled member. A subclass that overrides + `execute` serves every member, so turn off the ones it does not serve here. + confirm: Optional. `(context) -> bool`, called before every member call the SDK is about + to run (after the URL policy and the file policy, never for a call already refused). Return + `True` to run it or `False` to answer the model with a refusal, and decide by + `context.member` which members prompt a person. `context` also has the + parsed `input` and the target tab's `tab_url` / `tab_id` as of the last + `browser_state` report the SDK collected (see `BetaConfirmContext`). Required when `javascript_exec` or + `file_upload` is enabled. + url_policy: Optional. `(BetaURLContext, str) -> None`, raising `ToolError` to refuse. Called once + for each `navigate` that has a URL, with the URL exactly as the model wrote it, before the + driver receives it (`back`, `forward` and `reload` are not checked). Left unset, `navigate` is + not checked. This is a hook for your own policy, not a security + boundary: it does not see redirects, sub-resources or addresses a page reaches on its own. + Request interception in the driver and egress rules around the browser cover those. + file_policy: Optional `BetaFilePolicy` (the SDK ships `BetaLocalFilePolicy`): which local paths and + Files API document ids `file_upload` may use, and whether a download's local path is + shown to the model. Without one, an upload with a path or document id is refused and + download paths stay hidden. + tool_configs: Optional fields set on the `tools[]` entry itself rather than on a member: + `{"cache_control": {"type": "ephemeral"}}`. + """ + super().__init__( + configs=configs, confirm=confirm, url_policy=url_policy, file_policy=file_policy, tool_configs=tool_configs + ) + + @override + async def _toolset_run( + self, context: BetaToolsetCallContext, name: str, input: Mapping[str, object] + ) -> BetaToolsetContent: + record = CallRecord(context=context, name=name, raw_input=input) + options = self._toolset_options + + try: + member = options.resolve(record.name) + record.member = member + record.input = parse_input(member, record.raw_input, family=FAMILY) + + url = navigate_url(record) + if url is not None: + await self.__apply_policy(url, url_context(record)) + if member.name == "file_upload": + # the file policy resolves upload paths on disk: kept off the event loop + await anyio.to_thread.run_sync(resolve_upload_paths, record, options.file_policy) + await self._toolset_confirm(member.name, confirm_context(record, self._toolset_pipeline)) + + try: + result = await self.execute(record.context, member.name, record.input) + except Exception as exc: + record.error = bounded_error(classify(exc)) + else: + record.result = bounded_result(member, result) + except ToolError as exc: + record.error = exc + + state = await self.__browser_state(record.context) + + # `finish` writes the pipeline's bookkeeping, so it works on a copy that becomes the pipeline once the hop is + # back on the loop: a call cancelled natively mid-hop leaves the thread writing to a copy nothing reads. + pipeline = copy.copy(self._toolset_pipeline) + try: + content, is_error = await anyio.to_thread.run_sync(pipeline.finish, record, state, options.file_policy) + except Exception: + self._toolset_pipeline = pipeline # it raised after draining this report's changes; kept, as on the loop + raise + + self._toolset_pipeline = pipeline + if is_error: + raise ToolError(content) from record.error + return content + + async def __browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + try: + return with_bounded_tab_urls(await self._browser_state(context)) + except ToolsetUsageError: + raise + except Exception as exc: + raise BrowserStateError(exc) from exc + + async def __apply_policy(self, url: str, context: BetaURLContext) -> None: + policy = self._toolset_options.url_policy + if not is_given(policy): + return + + returned: object = None + raised: Exception | None = None + try: + returned = policy(context, url) + if inspect.isawaitable(returned): + returned = await returned + except Exception as exc: + raised = exc + + policy_outcome(returned, raised) + + async def execute( + self, context: BetaToolsetCallContext, name: BetaBrowserMemberName, input: BetaBrowserMemberInput + ) -> BetaBrowserMemberResult: + """Dispatch one member call to its method. See the synchronous class's `execute`. Members are + async here, and one written sync is a contract error.""" + return await self._toolset_member_method(name)(context, input) + + @abstractmethod + async def _browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + """The browser after a call. See the synchronous class's `_browser_state`. Awaited, so a sync method here + is a contract error.""" + raise NotImplementedError + + async def navigate( + self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput + ) -> BetaBrowserNavigateResult: + raise UnavailableMemberError("navigate", family=FAMILY) + + async def screenshot( + self, context: BetaToolsetCallContext, input: BetaBrowserScreenshotInput + ) -> BetaScreenshotResult: + raise UnavailableMemberError("screenshot", family=FAMILY) + + async def zoom(self, context: BetaToolsetCallContext, input: BetaBrowserZoomInput) -> BetaScreenshotResult: + raise UnavailableMemberError("zoom", family=FAMILY) + + async def left_click(self, context: BetaToolsetCallContext, input: BetaBrowserLeftClickInput) -> str | None: + raise UnavailableMemberError("left_click", family=FAMILY) + + async def right_click(self, context: BetaToolsetCallContext, input: BetaBrowserRightClickInput) -> str | None: + raise UnavailableMemberError("right_click", family=FAMILY) + + async def middle_click(self, context: BetaToolsetCallContext, input: BetaBrowserMiddleClickInput) -> str | None: + raise UnavailableMemberError("middle_click", family=FAMILY) + + async def double_click(self, context: BetaToolsetCallContext, input: BetaBrowserDoubleClickInput) -> str | None: + raise UnavailableMemberError("double_click", family=FAMILY) + + async def triple_click(self, context: BetaToolsetCallContext, input: BetaBrowserTripleClickInput) -> str | None: + raise UnavailableMemberError("triple_click", family=FAMILY) + + async def hover(self, context: BetaToolsetCallContext, input: BetaBrowserHoverInput) -> str | None: + raise UnavailableMemberError("hover", family=FAMILY) + + async def left_click_drag( + self, context: BetaToolsetCallContext, input: BetaBrowserLeftClickDragInput + ) -> str | None: + raise UnavailableMemberError("left_click_drag", family=FAMILY) + + async def left_mouse_down( + self, context: BetaToolsetCallContext, input: BetaBrowserLeftMouseDownInput + ) -> str | None: + raise UnavailableMemberError("left_mouse_down", family=FAMILY) + + async def left_mouse_up(self, context: BetaToolsetCallContext, input: BetaBrowserLeftMouseUpInput) -> str | None: + raise UnavailableMemberError("left_mouse_up", family=FAMILY) + + async def mouse_move(self, context: BetaToolsetCallContext, input: BetaBrowserMouseMoveInput) -> str | None: + raise UnavailableMemberError("mouse_move", family=FAMILY) + + async def scroll(self, context: BetaToolsetCallContext, input: BetaBrowserScrollInput) -> str | None: + raise UnavailableMemberError("scroll", family=FAMILY) + + async def scroll_to(self, context: BetaToolsetCallContext, input: BetaBrowserScrollToInput) -> str | None: + raise UnavailableMemberError("scroll_to", family=FAMILY) + + async def type(self, context: BetaToolsetCallContext, input: BetaBrowserTypeInput) -> str | None: + raise UnavailableMemberError("type", family=FAMILY) + + async def key(self, context: BetaToolsetCallContext, input: BetaBrowserKeyInput) -> str | None: + raise UnavailableMemberError("key", family=FAMILY) + + async def hold_key(self, context: BetaToolsetCallContext, input: BetaBrowserHoldKeyInput) -> str | None: + raise UnavailableMemberError("hold_key", family=FAMILY) + + async def form_input(self, context: BetaToolsetCallContext, input: BetaBrowserFormInputInput) -> str | None: + raise UnavailableMemberError("form_input", family=FAMILY) + + async def read_page(self, context: BetaToolsetCallContext, input: BetaBrowserReadPageInput) -> str: + raise UnavailableMemberError("read_page", family=FAMILY) + + async def find(self, context: BetaToolsetCallContext, input: BetaBrowserFindInput) -> str: + raise UnavailableMemberError("find", family=FAMILY) + + async def get_page_text(self, context: BetaToolsetCallContext, input: BetaBrowserGetPageTextInput) -> str: + raise UnavailableMemberError("get_page_text", family=FAMILY) + + async def wait(self, context: BetaToolsetCallContext, input: BetaBrowserWaitInput) -> str | None: + raise UnavailableMemberError("wait", family=FAMILY) + + async def file_upload(self, context: BetaToolsetCallContext, input: BetaBrowserFileUploadInput) -> str | None: + """Default-disabled. Enabling it in `configs` lets a page read files on the machine running + the browser: pass a `file_policy` confined to one dedicated upload directory. `input.paths` + arrive already resolved by it.""" + raise UnavailableMemberError("file_upload", family=FAMILY) + + async def read_console(self, context: BetaToolsetCallContext, input: BetaBrowserReadConsoleInput) -> str: + raise UnavailableMemberError("read_console", family=FAMILY) + + async def read_network(self, context: BetaToolsetCallContext, input: BetaBrowserReadNetworkInput) -> str: + raise UnavailableMemberError("read_network", family=FAMILY) + + async def javascript_exec(self, context: BetaToolsetCallContext, input: BetaBrowserJavascriptExecInput) -> str: + """Default-disabled. Page content can steer what the model asks to run, so enabling it takes a + `confirm` callable, which sees every call first. The script runs with the page's own authority (its + cookies, storage and signed-in sessions), so the browser profile you drive must not be signed into accounts + whose data or actions you would not hand the model.""" + raise UnavailableMemberError("javascript_exec", family=FAMILY) + + async def new_tab( + self, context: BetaToolsetCallContext, input: BetaBrowserNewTabInput + ) -> BetaBrowserStateTabEntryParam: + raise UnavailableMemberError("new_tab", family=FAMILY) + + async def list_tabs( + self, context: BetaToolsetCallContext, input: BetaBrowserListTabsInput + ) -> list[BetaBrowserStateTabEntryParam]: + raise UnavailableMemberError("list_tabs", family=FAMILY) + + async def switch_tab( + self, context: BetaToolsetCallContext, input: BetaBrowserSwitchTabInput + ) -> BetaBrowserStateTabEntryParam: + raise UnavailableMemberError("switch_tab", family=FAMILY) + + async def close_tab(self, context: BetaToolsetCallContext, input: BetaBrowserCloseTabInput) -> None: + raise UnavailableMemberError("close_tab", family=FAMILY) + + +DEFAULT_BODIES: frozenset[object] = frozenset( + getattr(cls, name) + for cls in (BetaAbstractBrowserToolset20260801, BetaAsyncAbstractBrowserToolset20260801) + for name in (*BROWSER_MEMBER_NAMES, "execute") +) +"""The two abstract classes' own member and `execute` bodies: a subclass whose attribute is still one of these did +not override it.""" diff --git a/src/anthropic/lib/tools/_toolsets/_computer.py b/src/anthropic/lib/tools/_toolsets/_computer.py new file mode 100644 index 000000000..0e88970a8 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_computer.py @@ -0,0 +1,430 @@ +"""The computer toolset interface: `BetaAbstractComputerToolset20260801` and its async twin. + +A driver subclasses one of them and overrides the members its backend supports; a member it does +not override is reported to the API as disabled and, if the model calls it anyway, answered with an +`is_error` result. +""" + +from __future__ import annotations + +import copy +from typing import Any, TypeVar, cast +from collections.abc import Mapping +from typing_extensions import override + +from ._base import ( + BaseToolset, + BaseSyncToolset, + BetaToolConfigs, + BaseAsyncToolset, + BaseToolsetOptions, + BetaScreenshotResult, + parse_input, + bounded_error, +) +from ._errors import ToolsetConfigError, UnavailableMemberError, InvalidMemberInputError +from ._render import action_line, render_result +from ._runnable import BetaToolsetContent, BetaToolsetCallContext, classify, unvalidated +from ._sanitize import FIELD_MAX, folded +from ....types.beta import ( + BetaComputerKeyInput, + BetaComputerTypeInput, + BetaComputerWaitInput, + BetaComputerZoomInput, + BetaComputerScrollInput, + BetaComputerHoldKeyInput, + BetaComputerLeftClickInput, + BetaComputerMouseMoveInput, + BetaComputerRightClickInput, + BetaComputerDoubleClickInput, + BetaComputerMiddleClickInput, + BetaComputerTripleClickInput, + BetaComputerLeftClickDragInput, + BetaComputerToolsetConfigsParam, + BetaComputerToolset20260801Param, +) +from .._beta_functions import ToolError, BetaFunctionToolResultType +from ._computer_inputs import ( + BetaComputerMemberName, + BetaComputerMemberInput, + BetaComputerScreenshotInput, + BetaComputerLeftMouseUpInput, + BetaComputerLeftMouseDownInput, + BetaComputerCursorPositionInput, +) +from ._computer_results import ( + BetaComputerMemberResult, + BetaComputerConfirmContext, + BetaComputerConfirmCallable, + BetaAsyncComputerConfirmCallable, + BetaComputerCursorPositionResult, +) +from ._computer_registry import FAMILY, COMPUTER, TOOLSET_TYPE, CONFIRM_REQUIRED, ComputerMember + +__all__ = ["BetaAbstractComputerToolset20260801", "BetaAsyncAbstractComputerToolset20260801"] + +_ConfirmT = TypeVar("_ConfirmT", bound=BetaComputerConfirmCallable | BetaAsyncComputerConfirmCallable) + + +class ComputerOptions(BaseToolsetOptions[BetaComputerMemberName, BetaComputerMemberInput, _ConfirmT]): + """The constructor options of computer toolset class `cls`, validated and resolved once at construction; a + mistake in them raises `ToolsetConfigError` here, and so does a missing `confirm` while `type`, `key` or `hold_key` + is enabled. The computer toolset has no options beyond the ones every family has (`BaseToolsetOptions`).""" + + def __init__( + self, + cls: type[BaseToolset[Any, Any, Any]], + *, + configs: BetaComputerToolsetConfigsParam | None, + confirm: _ConfirmT | None, + tool_configs: BetaToolConfigs | None, + ) -> None: + super().__init__( + cls, + registry=COMPUTER, + default_bodies=DEFAULT_BODIES, + configs=configs, + confirm=confirm, + tool_configs=tool_configs, + ) + + required = sorted(name for name in CONFIRM_REQUIRED if name in self.served and self.is_enabled(name)) + if required and confirm is None: + # a toolset that serves `type`, `key` or `hold_key` needs a `confirm`, unless `configs` turns them off + raise ToolsetConfigError( + f"{required!r} requires a confirm callable: pass confirm=, or disable it in configs" + ) + + +class BaseComputerToolset(BaseToolset[BetaComputerMemberName, BetaComputerMemberInput, _ConfirmT]): + """What the synchronous and asynchronous computer toolsets share: the options resolved at construction and the + `tools[]` entry built from them. Subclass one of the two public classes, not this one.""" + + toolset_name = FAMILY + + def __init__( + self, + *, + configs: BetaComputerToolsetConfigsParam | None, + confirm: _ConfirmT | None, + tool_configs: BetaToolConfigs | None, + ) -> None: + self._toolset_options: ComputerOptions[_ConfirmT] = ComputerOptions( + type(self), configs=configs, confirm=confirm, tool_configs=tool_configs + ) + super().__init__(self._toolset_options) + + @override + def to_dict(self) -> BetaComputerToolset20260801Param: + options = self._toolset_options + # fresh copies each call: the runner keeps what to_dict() returns, so a later in-place edit must not reach it + param = cast(BetaComputerToolset20260801Param, {**copy.deepcopy(options.tool_configs), "type": TOOLSET_TYPE}) + if options.wire_configs is not None: + param["configs"] = cast("BetaComputerToolsetConfigsParam", copy.deepcopy(options.wire_configs)) + return param + + @property + def configs(self) -> BetaComputerToolsetConfigsParam | None: + """The wire `configs`: yours, plus `enabled: False` for every member this class does not serve — what + `to_dict()` sends. A copy: nothing you do to it changes the toolset.""" + return self.to_dict().get("configs") + + +class BetaAbstractComputerToolset20260801( + BaseComputerToolset[BetaComputerConfirmCallable], + BaseSyncToolset[BetaComputerMemberName, BetaComputerMemberInput, BetaComputerConfirmCallable], +): + """The synchronous computer toolset for `computer_toolset_20260801`. + + Subclass it and override the members your desktop supports. Each takes `(context, input)` and returns the + result type in its signature: + + - A pure action (a click, typing, scrolling) returns nothing, or one line of text. The SDK folds that text to + one line and bounds its length. The model reads it in a text block of its own, after the SDK's fixed + acknowledgment (`Clicked.`). + - `screenshot` and `zoom` return an image that already fits the model's image limits. + - `cursor_position` returns the cursor in screenshot pixels. + + Read "Running a computer toolset safely" in the SDK guide (`computer-toolset.md`) before deploying one. + """ + + _toolset_twin = "BetaAsyncAbstractComputerToolset20260801" + + def __init__( + self, + *, + configs: BetaComputerToolsetConfigsParam | None = None, + confirm: BetaComputerConfirmCallable | None = None, + tool_configs: BetaToolConfigs | None = None, + ) -> None: + """A computer toolset with the given options; a mistake in them raises `ToolsetConfigError` here. + + Args: + configs: The `configs` object of the `tools[]` entry, sent as given: + `{"": {"enabled": bool, "defer_loading": bool}}`. This is how a member is + switched on or off; the SDK adds `enabled: False` for every member the subclass + does not implement and never dispatches a disabled member. A subclass that overrides + `execute` serves every member; turn off the ones it does not serve here. Every member is on by + default. + confirm: `(context) -> bool`, called before every member call the SDK is about to run (never for a + call already refused). Required while `type`, `key` or `hold_key` is enabled, and optional + otherwise. Return `True` to run the call, `False` to answer the model with a refusal; decide by + `context.member` which members actually prompt a person. `context` also carries the parsed `input`. + tool_configs: Optional fields set on the `tools[]` entry itself rather than on a member: + `{"cache_control": {"type": "ephemeral"}}`. + """ + super().__init__(configs=configs, confirm=confirm, tool_configs=tool_configs) + + @override + def _toolset_run( + self, context: BetaToolsetCallContext, name: str, input: Mapping[str, object] + ) -> BetaToolsetContent: + try: + member = self._toolset_options.resolve(name) + parsed = parse_computer_input(member, input) + self._toolset_confirm(member.name, confirm_context(context, member, parsed)) + try: + result = self.execute(context, member.name, parsed) + except Exception as exc: + # The driver's own error text reaches the model, cut to the field limit. + error = bounded_error(classify(exc)) + if error is exc: + raise + raise error from exc + return render_result(member, parsed, bounded_result(member, result)) + except ToolError as exc: + raise ToolError(raised_content(bounded_error(exc))) from exc + + def execute( + self, context: BetaToolsetCallContext, name: BetaComputerMemberName, input: BetaComputerMemberInput + ) -> BetaComputerMemberResult: + """Dispatch one member call to its method. Override it, calling `super().execute(context, name, input)`, for + before/after hooks around every member; `confirm` has already run and the result is rendered from what the + override returns.""" + return self._toolset_member_method(name)(context, input) + + def key(self, context: BetaToolsetCallContext, input: BetaComputerKeyInput) -> str | None: + raise UnavailableMemberError("key", family=FAMILY) + + def hold_key(self, context: BetaToolsetCallContext, input: BetaComputerHoldKeyInput) -> str | None: + raise UnavailableMemberError("hold_key", family=FAMILY) + + def type(self, context: BetaToolsetCallContext, input: BetaComputerTypeInput) -> str | None: + raise UnavailableMemberError("type", family=FAMILY) + + def cursor_position( + self, context: BetaToolsetCallContext, input: BetaComputerCursorPositionInput + ) -> BetaComputerCursorPositionResult: + raise UnavailableMemberError("cursor_position", family=FAMILY) + + def mouse_move(self, context: BetaToolsetCallContext, input: BetaComputerMouseMoveInput) -> str | None: + raise UnavailableMemberError("mouse_move", family=FAMILY) + + def left_mouse_down(self, context: BetaToolsetCallContext, input: BetaComputerLeftMouseDownInput) -> str | None: + raise UnavailableMemberError("left_mouse_down", family=FAMILY) + + def left_mouse_up(self, context: BetaToolsetCallContext, input: BetaComputerLeftMouseUpInput) -> str | None: + raise UnavailableMemberError("left_mouse_up", family=FAMILY) + + def left_click(self, context: BetaToolsetCallContext, input: BetaComputerLeftClickInput) -> str | None: + raise UnavailableMemberError("left_click", family=FAMILY) + + def left_click_drag(self, context: BetaToolsetCallContext, input: BetaComputerLeftClickDragInput) -> str | None: + raise UnavailableMemberError("left_click_drag", family=FAMILY) + + def right_click(self, context: BetaToolsetCallContext, input: BetaComputerRightClickInput) -> str | None: + raise UnavailableMemberError("right_click", family=FAMILY) + + def middle_click(self, context: BetaToolsetCallContext, input: BetaComputerMiddleClickInput) -> str | None: + raise UnavailableMemberError("middle_click", family=FAMILY) + + def double_click(self, context: BetaToolsetCallContext, input: BetaComputerDoubleClickInput) -> str | None: + raise UnavailableMemberError("double_click", family=FAMILY) + + def triple_click(self, context: BetaToolsetCallContext, input: BetaComputerTripleClickInput) -> str | None: + raise UnavailableMemberError("triple_click", family=FAMILY) + + def scroll(self, context: BetaToolsetCallContext, input: BetaComputerScrollInput) -> str | None: + raise UnavailableMemberError("scroll", family=FAMILY) + + def wait(self, context: BetaToolsetCallContext, input: BetaComputerWaitInput) -> str | None: + raise UnavailableMemberError("wait", family=FAMILY) + + def screenshot(self, context: BetaToolsetCallContext, input: BetaComputerScreenshotInput) -> BetaScreenshotResult: + raise UnavailableMemberError("screenshot", family=FAMILY) + + def zoom(self, context: BetaToolsetCallContext, input: BetaComputerZoomInput) -> BetaScreenshotResult: + raise UnavailableMemberError("zoom", family=FAMILY) + + +class BetaAsyncAbstractComputerToolset20260801( + BaseComputerToolset[BetaAsyncComputerConfirmCallable], + BaseAsyncToolset[BetaComputerMemberName, BetaComputerMemberInput, BetaAsyncComputerConfirmCallable], +): + """The asynchronous computer toolset for `computer_toolset_20260801`, for `AsyncAnthropic`. + + Identical to `BetaAbstractComputerToolset20260801` except that members are async (a sync one is a contract + error), `confirm` may be either, and `close` (also the async context-manager exit) is awaited. + """ + + _toolset_twin = "BetaAbstractComputerToolset20260801" + + def __init__( + self, + *, + configs: BetaComputerToolsetConfigsParam | None = None, + confirm: BetaAsyncComputerConfirmCallable | None = None, + tool_configs: BetaToolConfigs | None = None, + ) -> None: + """An asynchronous computer toolset with the given options; `confirm` may be a plain or an `async` callable + here. A mistake in the options raises `ToolsetConfigError`. + + Args: + configs: The `configs` object of the `tools[]` entry, sent as given: + `{"": {"enabled": bool, "defer_loading": bool}}`. This is how a member is + switched on or off; the SDK adds `enabled: False` for every member the subclass + does not implement and never dispatches a disabled member. A subclass that overrides + `execute` serves every member; turn off the ones it does not serve here. Every member is on by + default. + confirm: `(context) -> bool`, called before every member call the SDK is about to run (never for a + call already refused). Required while `type`, `key` or `hold_key` is enabled, and optional + otherwise. Return `True` to run the call, `False` to answer the model with a refusal; decide by + `context.member` which members actually prompt a person. `context` also carries the parsed `input`. + tool_configs: Optional fields set on the `tools[]` entry itself rather than on a member: + `{"cache_control": {"type": "ephemeral"}}`. + """ + super().__init__(configs=configs, confirm=confirm, tool_configs=tool_configs) + + @override + async def _toolset_run( + self, context: BetaToolsetCallContext, name: str, input: Mapping[str, object] + ) -> BetaToolsetContent: + try: + member = self._toolset_options.resolve(name) + parsed = parse_computer_input(member, input) + await self._toolset_confirm(member.name, confirm_context(context, member, parsed)) + try: + result = await self.execute(context, member.name, parsed) + except Exception as exc: + error = bounded_error(classify(exc)) + if error is exc: + raise + raise error from exc + return render_result(member, parsed, bounded_result(member, result)) + except ToolError as exc: + raise ToolError(raised_content(bounded_error(exc))) from exc + + async def execute( + self, context: BetaToolsetCallContext, name: BetaComputerMemberName, input: BetaComputerMemberInput + ) -> BetaComputerMemberResult: + """Dispatch one member call to its method; see the synchronous class's `execute`. Members are + async here; one written sync is a contract error.""" + return await self._toolset_member_method(name)(context, input) + + async def key(self, context: BetaToolsetCallContext, input: BetaComputerKeyInput) -> str | None: + raise UnavailableMemberError("key", family=FAMILY) + + async def hold_key(self, context: BetaToolsetCallContext, input: BetaComputerHoldKeyInput) -> str | None: + raise UnavailableMemberError("hold_key", family=FAMILY) + + async def type(self, context: BetaToolsetCallContext, input: BetaComputerTypeInput) -> str | None: + raise UnavailableMemberError("type", family=FAMILY) + + async def cursor_position( + self, context: BetaToolsetCallContext, input: BetaComputerCursorPositionInput + ) -> BetaComputerCursorPositionResult: + raise UnavailableMemberError("cursor_position", family=FAMILY) + + async def mouse_move(self, context: BetaToolsetCallContext, input: BetaComputerMouseMoveInput) -> str | None: + raise UnavailableMemberError("mouse_move", family=FAMILY) + + async def left_mouse_down( + self, context: BetaToolsetCallContext, input: BetaComputerLeftMouseDownInput + ) -> str | None: + raise UnavailableMemberError("left_mouse_down", family=FAMILY) + + async def left_mouse_up(self, context: BetaToolsetCallContext, input: BetaComputerLeftMouseUpInput) -> str | None: + raise UnavailableMemberError("left_mouse_up", family=FAMILY) + + async def left_click(self, context: BetaToolsetCallContext, input: BetaComputerLeftClickInput) -> str | None: + raise UnavailableMemberError("left_click", family=FAMILY) + + async def left_click_drag( + self, context: BetaToolsetCallContext, input: BetaComputerLeftClickDragInput + ) -> str | None: + raise UnavailableMemberError("left_click_drag", family=FAMILY) + + async def right_click(self, context: BetaToolsetCallContext, input: BetaComputerRightClickInput) -> str | None: + raise UnavailableMemberError("right_click", family=FAMILY) + + async def middle_click(self, context: BetaToolsetCallContext, input: BetaComputerMiddleClickInput) -> str | None: + raise UnavailableMemberError("middle_click", family=FAMILY) + + async def double_click(self, context: BetaToolsetCallContext, input: BetaComputerDoubleClickInput) -> str | None: + raise UnavailableMemberError("double_click", family=FAMILY) + + async def triple_click(self, context: BetaToolsetCallContext, input: BetaComputerTripleClickInput) -> str | None: + raise UnavailableMemberError("triple_click", family=FAMILY) + + async def scroll(self, context: BetaToolsetCallContext, input: BetaComputerScrollInput) -> str | None: + raise UnavailableMemberError("scroll", family=FAMILY) + + async def wait(self, context: BetaToolsetCallContext, input: BetaComputerWaitInput) -> str | None: + raise UnavailableMemberError("wait", family=FAMILY) + + async def screenshot( + self, context: BetaToolsetCallContext, input: BetaComputerScreenshotInput + ) -> BetaScreenshotResult: + raise UnavailableMemberError("screenshot", family=FAMILY) + + async def zoom(self, context: BetaToolsetCallContext, input: BetaComputerZoomInput) -> BetaScreenshotResult: + raise UnavailableMemberError("zoom", family=FAMILY) + + +DEFAULT_BODIES: frozenset[object] = frozenset( + getattr(cls, name) + for cls in (BetaAbstractComputerToolset20260801, BetaAsyncAbstractComputerToolset20260801) + for name in (*COMPUTER.names, "execute") +) +"""The two abstract classes' own member and `execute` bodies: a subclass whose attribute is still one of these did +not override it.""" + +SHAPES = (("coordinate", "[x, y]", 2), ("start_coordinate", "[x, y]", 2), ("region", "[x0, y0, x1, y1]", 4)) +"""The list-valued input fields, the shape the model is told to send, and the length that shape has.""" + + +def parse_computer_input(member: ComputerMember, raw: Mapping[str, object]) -> BetaComputerMemberInput: + """`parse_input` for a computer member, plus the shape of each coordinate list: the generated models type them + as lists of integers of any length, and a driver indexes them.""" + parsed = parse_input(member, raw, family=FAMILY) + problems = [ + f"{field}: expected {shape}" + for field, shape, length in SHAPES + if (value := getattr(parsed, field, None)) is not None and len(value) != length + ] + if problems: + raise InvalidMemberInputError(member.name, "; ".join(problems), family=FAMILY) + return parsed + + +def bounded_result(member: ComputerMember, result: BetaComputerMemberResult) -> BetaComputerMemberResult: + """`result` as it is rendered: the line a pure action returned is folded to one line and cut, so one that is only + whitespace or control characters becomes the empty string and renders as the acknowledgment alone, as the browser + toolset's does, instead of as a text block that carries nothing. Anything else is kept as returned.""" + line = action_line(member, result) + return result if line is None else folded(line)[:FIELD_MAX] + + +def raised_content(error: ToolError) -> BetaFunctionToolResultType: + """The content `call()` raises for a refusal or failure. A string becomes one text block. A list of blocks is kept + as raised, images included. `tool_result()` later keeps only the text blocks.""" + content = error.content + return [{"type": "text", "text": content}] if isinstance(content, str) else content + + +def confirm_context( + context: BetaToolsetCallContext, member: ComputerMember, input: BetaComputerMemberInput +) -> BetaComputerConfirmContext: + """What the `confirm` callable is told: the member, its parsed input and the `tool_use` being answered. Built + from values the pipeline already holds, so nothing here is validated again.""" + return unvalidated(BetaComputerConfirmContext, tool_use=context.tool_use, member=member.name, input=input) diff --git a/src/anthropic/lib/tools/_toolsets/_computer_inputs.py b/src/anthropic/lib/tools/_toolsets/_computer_inputs.py new file mode 100644 index 000000000..52bd575c2 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_computer_inputs.py @@ -0,0 +1,45 @@ +"""The member input models: what the model sends a computer toolset member. + +Member names and input models are read off the generated `tool_use` union +(`anthropic.types.beta.BetaResponseComputerToolUseBlock`), one variant per member. The union of the input models +is the generated `anthropic.types.beta.BetaComputerMemberInput`, and the member names are the generated +`anthropic.types.beta.BetaComputerMemberName`. +""" + +from __future__ import annotations + +from typing import cast + +from ._base import members_from_union +from ....types.beta import ( + BetaComputerMemberName, + BetaComputerMemberInput, + BetaComputerScreenshotInput, + BetaComputerLeftMouseUpInput, + BetaComputerLeftMouseDownInput, + BetaComputerCursorPositionInput, + BetaResponseComputerToolUseBlock, +) + +__all__ = [ + "COMPUTER_MEMBER_NAMES", + "COMPUTER_INPUT_TYPES", + "BetaComputerMemberName", + "BetaComputerMemberInput", + "BetaComputerCursorPositionInput", + "BetaComputerLeftMouseDownInput", + "BetaComputerLeftMouseUpInput", + "BetaComputerScreenshotInput", +] + + +DERIVED = cast( + "list[tuple[BetaComputerMemberName, type[BetaComputerMemberInput]]]", + members_from_union(BetaResponseComputerToolUseBlock, {}), +) + +COMPUTER_INPUT_TYPES: dict[str, type[BetaComputerMemberInput]] = dict(DERIVED) +"""Member name → the model its `tool_use.input` is parsed into, derived from the generated union.""" + +COMPUTER_MEMBER_NAMES: list[BetaComputerMemberName] = [name for name, _ in DERIVED] +"""Member names, in the generated union's order.""" diff --git a/src/anthropic/lib/tools/_toolsets/_computer_registry.py b/src/anthropic/lib/tools/_toolsets/_computer_registry.py new file mode 100644 index 000000000..d705bc6d7 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_computer_registry.py @@ -0,0 +1,72 @@ +"""The computer member registry: one row per member, from which every other per-member table derives. + +Member names and input models come from the generated `tool_use` union (`_computer_inputs`); what is +kept by hand here is what the generated types do not carry — each member's result kind and the +confirmation text the model reads after a pure action. Every member is on by default. +""" + +from __future__ import annotations + +from typing import Mapping +from typing_extensions import Literal, TypeAlias + +from ._base import Member, Registry, ResultKind +from ._computer_inputs import ( + COMPUTER_INPUT_TYPES, + COMPUTER_MEMBER_NAMES, + BetaComputerMemberName, + BetaComputerMemberInput, +) + +__all__ = ["FAMILY", "TOOLSET_TYPE", "CONFIRM_REQUIRED", "ComputerMember", "COMPUTER", "COMPUTER_MEMBERS"] + +FAMILY: Literal["computer"] = "computer" +TOOLSET_TYPE: Literal["computer_toolset_20260801"] = "computer_toolset_20260801" +# The API data marks no computer member as needing `confirm`, so unlike the browser's, this list is the SDK's own. +CONFIRM_REQUIRED: frozenset[str] = frozenset({"type", "key", "hold_key"}) +"""Members that need a `confirm` callable: keyboard input goes to whichever window has focus, and what the screen +shows can steer what the model types.""" + +ComputerMember: TypeAlias = Member[BetaComputerMemberName, BetaComputerMemberInput] + +ROWS: dict[str, "tuple[ResultKind, str | None]"] = { + "key": ("none", "Pressed {text}."), + "hold_key": ("none", "Held {text} for {duration}s."), + "type": ("none", "Typed."), + "cursor_position": ("point", None), + "mouse_move": ("none", "Moved the mouse."), + "left_mouse_down": ("none", "Left mouse button pressed."), + "left_mouse_up": ("none", "Left mouse button released."), + "left_click": ("none", "Clicked."), + "left_click_drag": ("none", "Dragged."), + "right_click": ("none", "Right-clicked."), + "middle_click": ("none", "Middle-clicked."), + "double_click": ("none", "Double-clicked."), + "triple_click": ("none", "Triple-clicked."), + "scroll": ("none", "Scrolled {scroll_direction}."), + "wait": ("none", "Waited {duration}s."), + "screenshot": ("screenshot", None), + "zoom": ("screenshot", None), +} + + +def build_registry() -> dict[str, ComputerMember]: + names: "set[str]" = set(COMPUTER_MEMBER_NAMES) + missing = names - set(ROWS) + extra = set(ROWS) - names + if missing or extra: # pragma: no cover - an import-time guard, exercised whenever codegen changes the set + raise AssertionError(f"computer member registry drift: missing={sorted(missing)} extra={sorted(extra)}") + table: dict[str, ComputerMember] = {} + for name in COMPUTER_MEMBER_NAMES: + result, text = ROWS[name] + table[name] = ComputerMember(name=name, input=COMPUTER_INPUT_TYPES[name], result=result, text=text) + return table + + +COMPUTER: Registry[BetaComputerMemberName, BetaComputerMemberInput] = Registry( + family=FAMILY, helper_tag="computer-toolset", members=build_registry() +) +"""The computer family: its members in the generated union's order, and what it is called on the wire.""" + +COMPUTER_MEMBERS: Mapping[str, ComputerMember] = COMPUTER.members +"""Every computer member by name, in the generated union's order; a name the model made up is simply absent.""" diff --git a/src/anthropic/lib/tools/_toolsets/_computer_results.py b/src/anthropic/lib/tools/_toolsets/_computer_results.py new file mode 100644 index 000000000..5b947e1b6 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_computer_results.py @@ -0,0 +1,46 @@ +"""What a computer toolset member returns, and the types of its `confirm` hook. + +A computer member returns an image, a cursor position, a line of text, or nothing; the two result models are declared +here, with the types of the `confirm` hook. +""" + +from __future__ import annotations + +from typing import Union, Callable, Awaitable +from typing_extensions import TypeAlias + +from ._base import BetaScreenshotResult +from ._runnable import BetaToolsetCallContext +from ...._models import BaseModel +from ._computer_inputs import BetaComputerMemberName, BetaComputerMemberInput + +__all__ = [ + "BetaComputerCursorPositionResult", + "BetaComputerMemberResult", + "BetaComputerConfirmContext", + "BetaComputerConfirmCallable", + "BetaAsyncComputerConfirmCallable", +] + + +class BetaComputerCursorPositionResult(BaseModel): + """Where the cursor is, in screenshot pixels; rendered as `X={x},Y={y}`.""" + + x: int + y: int + + +BetaComputerMemberResult: TypeAlias = Union[BetaScreenshotResult, BetaComputerCursorPositionResult, str, None] +"""Anything a computer member may return: `execute`'s un-narrowed result type.""" + + +class BetaComputerConfirmContext(BetaToolsetCallContext): + """What the `confirm` callable is told about the call awaiting approval: the member and its parsed input, and + the `tool_use` block being answered.""" + + member: BetaComputerMemberName + input: BetaComputerMemberInput + + +BetaComputerConfirmCallable: TypeAlias = Callable[[BetaComputerConfirmContext], bool] +BetaAsyncComputerConfirmCallable: TypeAlias = Callable[[BetaComputerConfirmContext], Union[bool, Awaitable[bool]]] diff --git a/src/anthropic/lib/tools/_toolsets/_errors.py b/src/anthropic/lib/tools/_toolsets/_errors.py new file mode 100644 index 000000000..1cca0bfc3 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_errors.py @@ -0,0 +1,168 @@ +"""Errors the toolset helpers raise at your code rather than report to the model, and the SDK's own refusals. + +`ToolsetUsageError` is the only exception type the tool runner lets propagate out of +a member call: it means your code used the SDK's own interface incorrectly, so the run stops. +Every other exception a member raises is reported to the model as an `is_error` result and the +loop continues. The refusals are `ToolError` subclasses, each with the SDK's own text: the model reads them as +any other `is_error` result, and your code (an `execute` override, a test) catches the subclass to tell them apart +without comparing text. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing_extensions import Literal + +from ._sanitize import log_safe, quoted_name +from ...._exceptions import AnthropicError +from .._beta_functions import ToolError + +if TYPE_CHECKING: + from ._runnable import ToolsetFamily # _runnable imports this module + +__all__ = [ + "ToolsetUsageError", + "ToolsetConfigError", + "ToolsetContractError", + "ToolsetsFixedError", + "ToolsetClosedError", + "UnknownMemberError", + "DisabledMemberError", + "UnavailableMemberError", + "InvalidMemberInputError", + "URLRefusedError", + "TabMissingError", + "ConfirmDeclinedError", + "ConfirmFailedError", + "UploadRefusedError", + "UploadNoRootsError", + "UploadOutsideRootsError", +] + + +class ToolsetUsageError(AnthropicError): + """Raised when your code uses a toolset's interface incorrectly. + + The only exception the tool runner propagates from a member call. Anything else a member + raises becomes an `is_error` result the model can react to.""" + + +class ToolsetConfigError(ToolsetUsageError, ValueError): + """A construction-time mistake: an option combination the toolset cannot honour.""" + + +class ToolsetContractError(ToolsetUsageError, TypeError): + """Misuse of the SDK's own interface detected at call time (for example a `url_policy` that + returns a value).""" + + +class ToolsetsFixedError(ToolsetContractError): + """Raised when `add_tools()` or `remove_tools()` is given a toolset, a raw definition of one of the runner's + toolsets, or (in `remove_tools()`) the name of one. A runner's toolsets are fixed when it is created.""" + + def __init__(self, action: Literal["add", "replace", "remove"], toolset_name: str) -> None: + method = "remove_tools" if action == "remove" else "add_tools" + instead = ( + "build a new runner without it" if action == "remove" else "build a new runner to use a different toolset" + ) + super().__init__( + f"{method}() can't {action} the '{quoted_name(toolset_name)}' toolset: a tool runner's toolsets are fixed " + f"when it is created; {instead}" + ) + + +class ToolsetClosedError(ToolsetUsageError, RuntimeError): + """A member call or a `with` block on a toolset you already closed.""" + + +# --- the SDK's own refusals ------------------------------------------------------------------------------------------- + + +class UnknownMemberError(ToolError): + """The refusal the model reads for a member name the toolset does not have.""" + + def __init__(self, name: str, *, family: ToolsetFamily) -> None: + super().__init__(f"Error: unknown {family} toolset member '{quoted_name(name)}'") + + +class DisabledMemberError(ToolError): + """The refusal the model reads for a member your application's `configs` disabled.""" + + def __init__(self, name: str) -> None: + super().__init__( + f"The '{name}' action is not permitted by this application's permissions and cannot be used in this session." + ) + + +class UnavailableMemberError(ToolError): + """The refusal the model reads for a member the driver does not implement.""" + + def __init__(self, name: str, *, family: ToolsetFamily) -> None: + super().__init__(f"The {family} toolset member '{name}' is not available in this environment.") + + +class InvalidMemberInputError(ToolError): + """The refusal the model reads when its `tool_use.input` does not fit the member. `problem` is the field and what + is wrong with it.""" + + def __init__(self, name: str, problem: str, *, family: ToolsetFamily) -> None: + super().__init__(f"invalid input for {family} member '{name}': {problem}") + + +class URLRefusedError(ToolError): + """The refusal the model reads when your `url_policy` raised something other than a `ToolError` (a `ToolError` it + raises reaches the model as raised). `reason` is the line it reads.""" + + def __init__(self, reason: str) -> None: + # one bounded line: a reason may echo a model-written address of any length + super().__init__(log_safe(reason)) + + +class TabMissingError(ToolError): + """Not raised by the SDK, which leaves a call that names a tab to the driver. Kept for code that imports it.""" + + def __init__(self) -> None: + super().__init__( + "The tab this call was addressed to is not open; nothing was returned. Use list_tabs to see what is open." + ) + + +class ConfirmDeclinedError(ToolError): + """The refusal the model reads when your `confirm` callable answered `False` for the member `name`.""" + + def __init__(self, name: str) -> None: + super().__init__( + f"The user did not grant permission to run '{name}'. Do not retry it unless the user asks you to." + ) + + +class ConfirmFailedError(ToolError): + """The refusal the model reads when your `confirm` callable raised, so no answer could be obtained for `name`.""" + + def __init__(self, name: str) -> None: + super().__init__( + f"Permission to run '{name}' could not be obtained (the confirmation prompt failed). " + "Do not retry it unless the user asks you to." + ) + + +class UploadRefusedError(ToolError): + """The refusal the model reads for a `file_upload` the file policy, or its absence, does not admit. `reason` is + the line it reads.""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + + +class UploadNoRootsError(UploadRefusedError): + """The refusal the model reads for a `file_upload` that names paths when no upload roots are configured.""" + + def __init__(self) -> None: + super().__init__("file_upload has no configured upload roots") + + +class UploadOutsideRootsError(UploadRefusedError): + """The refusal the model reads for a `file_upload` path that does not lie under a configured upload root.""" + + def __init__(self) -> None: + super().__init__("file_upload path is outside the configured upload roots") diff --git a/src/anthropic/lib/tools/_toolsets/_hooks.py b/src/anthropic/lib/tools/_toolsets/_hooks.py new file mode 100644 index 000000000..de5c54cbc --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_hooks.py @@ -0,0 +1,92 @@ +"""The types of the browser toolset's constructor hooks: the URL policy, the file policy and the confirmation +callable. `BetaToolConfigs` is every family's and is re-exported here.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence, Awaitable +from typing_extensions import Protocol, TypeAlias, runtime_checkable + +from ._base import BetaToolConfigs +from ._inputs import BetaBrowserMemberName +from ._runnable import BetaToolsetCallContext +from ...._models import BaseModel +from ....types.beta import BetaBrowserMemberInput + +__all__ = [ + "BetaURLContext", + "BetaURLPolicy", + "BetaAsyncURLPolicy", + "BetaFilePolicy", + "BetaConfirmContext", + "BetaConfirmCallable", + "BetaAsyncConfirmCallable", + "BetaToolConfigs", +] + + +class BetaURLContext(BaseModel): + """What a hook receives about the call it serves: the first argument of `url_policy(context, url)` and of the + file policy's `resolve_upload_paths(context, paths)` / `resolve_upload_documents(context, document_ids)`.""" + + member: BetaBrowserMemberName | None = None + """The member being called: `navigate` for the URL policy, `file_upload` for the file policy.""" + + tab_id: str | None = None + """The tab the call named, if any.""" + + tool_use_id: str | None = None + """The model call being served, or `None` when a hook is invoked outside one.""" + + +BetaURLPolicy: TypeAlias = Callable[[BetaURLContext, str], None] +"""The synchronous toolset's URL policy: `(context, url)`, called once for each `navigate` that has a URL, with +`url` exactly as the model wrote it, before the driver receives it (`back`, `forward` and `reload` are not checked). +Return `None` to allow, raise `ToolError` to refuse.""" + +BetaAsyncURLPolicy: TypeAlias = Callable[[BetaURLContext, str], Awaitable[None] | None] +"""The async toolset's URL policy: a coroutine function or a plain one. The result is awaited when it is awaitable, +like the other async hooks. Same arguments and contract as `BetaURLPolicy`.""" + + +@runtime_checkable +class BetaFilePolicy(Protocol): + """Controls which upload paths and document ids a `file_upload` may use and which download paths + the model may see. + + With no file policy configured, an upload input that has a path or a document id is refused + and download paths are never shown to the model. + """ + + def resolve_upload_paths(self, context: BetaURLContext, paths: Sequence[str]) -> list[str]: + """Resolve each path the model asked to upload, or raise `ToolError` to refuse. The driver + receives the returned paths in `input.paths`.""" + ... + + def resolve_upload_documents(self, context: BetaURLContext, document_ids: Sequence[str]) -> list[str]: + """Vet each document id (a file staged with the Files API) the model asked to upload, or raise + `ToolError` to refuse. The driver receives the returned ids in `input.document_ids`.""" + ... + + def is_path_visible(self, path: str) -> bool: + """Whether a `download_completed` path may reach the model. Only `True` shows it; any other answer forwards the + event without it.""" + ... + + +class BetaConfirmContext(BetaToolsetCallContext): + """What `confirm` receives about the call awaiting approval: the member, its parsed input, and, through + `tab_id` and `tab_url`, the tab the call targets (the active tab when it names none) as of the last + `browser_state` report the SDK collected, with no page read; that is the report the model saw unless the + call before failed. Both are `None` when that report has no such tab (anything before the first). `tab_url` + is the URL as the model read it: the driver's string, folded to one line and held to 4,096 characters. An + approval covers that report; the page is not read again after it. `input` holds a `file_upload`'s paths and + document ids as the file policy returned them, and a `navigate` history word in lower case.""" + + member: BetaBrowserMemberName + input: BetaBrowserMemberInput + tab_url: str | None = None + tab_id: str | None = None + + +BetaConfirmCallable: TypeAlias = Callable[[BetaConfirmContext], bool] +BetaAsyncConfirmCallable: TypeAlias = Callable[[BetaConfirmContext], bool | Awaitable[bool]] diff --git a/src/anthropic/lib/tools/_toolsets/_inputs.py b/src/anthropic/lib/tools/_toolsets/_inputs.py new file mode 100644 index 000000000..970ea81be --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_inputs.py @@ -0,0 +1,36 @@ +"""The member input models: what the model sends a browser toolset member. + +Member names and input models are read off the generated `tool_use` union +(`anthropic.types.beta.BetaResponseBrowserToolUseBlock`), one variant per member. The union +of the input models is the generated `anthropic.types.beta.BetaBrowserMemberInput`. + +Importing this module raises if the generated `anthropic.types.beta.BetaBrowserMemberName` and the generated union +list different names. +""" + +from __future__ import annotations + +from typing import cast +from typing_extensions import get_args + +from ._base import members_from_union +from ....types.beta import BetaBrowserMemberName, BetaBrowserMemberInput, BetaResponseBrowserToolUseBlock + +__all__ = ["BROWSER_MEMBER_NAMES", "BROWSER_INPUT_TYPES"] + + +DERIVED = cast( + "list[tuple[BetaBrowserMemberName, type[BetaBrowserMemberInput]]]", + members_from_union(BetaResponseBrowserToolUseBlock, {}), +) +"""Every browser member has a generated input model, so no stand-in is needed.""" + +BROWSER_INPUT_TYPES: dict[str, type[BetaBrowserMemberInput]] = dict(DERIVED) +"""Member name → the model its `tool_use.input` is parsed into, derived from the generated union.""" + + +BROWSER_MEMBER_NAMES: list[BetaBrowserMemberName] = [name for name, _ in DERIVED] +"""Member names, in the generated union's order.""" + +if set(BROWSER_MEMBER_NAMES) != set(get_args(BetaBrowserMemberName)): # pragma: no cover + raise AssertionError("BetaBrowserMemberName is out of step with the generated browser tool_use union") diff --git a/src/anthropic/lib/tools/_toolsets/_pipeline.py b/src/anthropic/lib/tools/_toolsets/_pipeline.py new file mode 100644 index 000000000..ebb067437 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_pipeline.py @@ -0,0 +1,394 @@ +"""The stages of a browser member call that do not depend on whether the toolset is sync or async. + +The stages every family runs (resolving, parsing, the confirm verdict) are in `_base`. +""" + +from __future__ import annotations + +import copy +from typing import NamedTuple +from collections.abc import Mapping, Sequence + +from ._base import bounded_error, error_content +from ._hooks import BetaFilePolicy, BetaURLContext, BetaConfirmContext +from ._errors import ( + URLRefusedError, + ToolsetUsageError, + UploadNoRootsError, + UploadRefusedError, + ToolsetContractError, +) +from ._render import ( + NAVIGATION_REFUSED_LINE, + text_block, + action_line, + dialog_lines, + render_result, + is_download_change, + render_browser_state, +) +from ._results import StateChange, BetaBrowserState, BetaDialogDismissed, BetaNavigationRefused, BetaBrowserMemberResult +from ._registry import STATE_ONLY_MEMBERS, BrowserMember +from ._runnable import BetaToolsetContent, BetaToolsetCallContext, unvalidated, hook_refusal +from ._sanitize import FIELD_MAX, folded, bounded_tab_url +from ...._compat import get_model_fields +from ....types.beta import ( + BetaBrowserMemberInput, + BetaBrowserNavigateInput, + BetaBrowserFileUploadInput, + BetaBrowserStateChangeParam, + BetaBrowserStateTabEntryParam, +) +from .._beta_functions import ToolError + +__all__ = ["CallRecord", "Report", "PipelineState", "with_bounded_tab_urls"] + + +class CallRecord: + """One member call in flight: what each stage produced, for the stages after it.""" + + def __init__(self, *, context: BetaToolsetCallContext, name: str, raw_input: Mapping[str, object]) -> None: + self.context = context + self.name = name + self.raw_input = raw_input + + self.member: BrowserMember | None = None + self.input: BetaBrowserMemberInput | None = None + self.result: BetaBrowserMemberResult = None + self.error: ToolError | None = None + + @property + def tab_id(self) -> str | None: + """The tab the call names, or None when it names none (an absent or empty `tab_id` both mean + the active tab, which is how drivers resolve them). Only a member whose input declares `tab_id` + names a tab: on any other member a `tab_id` key is an extra field and does not count.""" + if self.member is None or "tab_id" not in get_model_fields(self.member.input): + return None + + tab_id: str | None = getattr(self.input, "tab_id", None) + return tab_id or None + + @property + def tool_use_id(self) -> str | None: + return self.context.tool_use.id if self.context.tool_use is not None else None + + @property + def opened_tab_id(self) -> str | None: + """The `tab_id` of the tab a `new_tab` call opened, from the tab entry it returned, or `None` for any other call + or result.""" + if self.member is None or self.member.name != "new_tab" or not isinstance(self.result, dict): + return None + return self.result.get("tab_id") + + +class Report(NamedTuple): + """The driver's report as the pipeline reads it, built once: the tabs and the wire changes with each page-supplied + URL bounded, and the SDK's own change kinds set apart, since they reach the model as text.""" + + tabs: list[BetaBrowserStateTabEntryParam] + changes: list[BetaBrowserStateChangeParam] + navigation_refused: bool + dialogs: list[BetaDialogDismissed] + + +def report_of(state: BetaBrowserState) -> Report: + """The checked `BetaBrowserState` as the `Report` that `finish` renders: its tabs and wire changes as they stand, the + SDK's own change kinds set apart.""" + changes: list[BetaBrowserStateChangeParam] = [] + dialogs: list[BetaDialogDismissed] = [] + navigation_refused = False + for change in state.state_changes: + if isinstance(change, BetaNavigationRefused): + navigation_refused = True + elif isinstance(change, BetaDialogDismissed): + dialogs.append(change) + else: + changes.append(change) + return Report(tabs=list(state.tabs), changes=changes, navigation_refused=navigation_refused, dialogs=dialogs) + + +def with_bounded_tab_urls(state: BetaBrowserState) -> BetaBrowserState: + """The report with each tab's and each download's page-supplied URL as the model will read it, folded to one line + and held to the API's limit (see `bounded_tab_url`), before anything else reads or forwards it.""" + tabs: list[BetaBrowserStateTabEntryParam] = [] + touched = False + for tab in state.tabs: + bounded = bounded_tab_url(tab["url"]) + if bounded != tab["url"]: + touched = True + tab = copy.copy(tab) # the driver's dict is left as it was + tab["url"] = bounded + tabs.append(tab) + + changes: list[StateChange] = [] + for change in state.state_changes: + if is_download_change(change): + bounded = bounded_tab_url(change["url"]) + if bounded != change["url"]: + touched = True + change = copy.copy(change) + change["url"] = bounded + changes.append(change) + + # Rebuilt without validation: the report is the driver's, forwarded for the API to judge. + return unvalidated(BetaBrowserState, tabs=tabs, state_changes=changes) if touched else state + + +def target_tab( + tabs: Sequence[BetaBrowserStateTabEntryParam], tab_id: str | None +) -> BetaBrowserStateTabEntryParam | None: + """Which tab a call targets: the tab it names by `tab_id`, else the active tab. `None` when the + named tab is not in the report (or nothing is active).""" + for tab in tabs: + if tab_id is not None: + if tab.get("tab_id") == tab_id: + return tab + elif tab.get("active") is True: + return tab + return None + + +class PipelineState: + """Per-toolset bookkeeping that outlives a single call.""" + + def __init__(self) -> None: + self.pending_changes: list[BetaBrowserStateChangeParam] = [] + """State changes not sent yet: those drained by a failed call, and a popup's `tab_opened` held back from a + successful `new_tab`. They go out with the next `browser_state` block.""" + + self.refusal_pending = False + """A refused-navigation line that could not be attached yet (the result was state-only).""" + + self.dialogs_pending: list[BetaDialogDismissed] = [] + """Dismissed dialogs whose lines could not be attached yet, for the same reason.""" + + self.last_tabs: list[BetaBrowserStateTabEntryParam] = [] + """The tab inventory of the most recent report: what `BetaConfirmContext` is filled from.""" + + def finish( + self, call: CallRecord, state: BetaBrowserState, file_policy: BetaFilePolicy | None + ) -> tuple[BetaToolsetContent, bool]: + """Render the call's outcome with the browser state the driver just reported. + + On success the content is the member's rendering plus the `browser_state` block (with + any changes held back earlier). On failure the changes are held back for the next block, + because an error result cannot include one, and the content is the error text, plus the + refused-navigation line when the driver reported one, since that is often why the member + failed. Returns `(content, is_error)`; a report the SDK cannot read raises `ToolsetContractError` instead.""" + try: + return self._render(call, state, file_policy) + except ToolsetUsageError: + raise + except (KeyError, TypeError, AttributeError) as exc: + raise ToolsetContractError( + f"browser_state returned a report the SDK cannot read ({type(exc).__name__}: {exc}); " + "report each tab and state change with its documented fields" + ) from exc + + def _render( + self, call: CallRecord, state: BetaBrowserState, file_policy: BetaFilePolicy | None + ) -> tuple[BetaToolsetContent, bool]: + report = report_of(state) + self.last_tabs = report.tabs + refused = report.navigation_refused or self.refusal_pending + dialogs = [*self.dialogs_pending, *report.dialogs] + changes = merge_pending_changes(self.pending_changes, report) + + # Held first: should rendering stop the run (a ToolsetUsageError out of the file policy), the + # drained changes are still on hand rather than lost with this call. + self.pending_changes = changes + opened = call.opened_tab_id if call.error is None else None + if opened is not None and [tab["tab_id"] for tab in report.tabs if tab.get("active") is True] != [opened]: + # The API rejects a new_tab result unless the opened tab is the only active one, and that ends the run. + # A tab opened in the background, or a popup that takes focus first, breaks the rule, so the call fails. + call.error = ToolError(NEW_TAB_NOT_ACTIVE) + + if call.error is None: + assert call.member is not None and call.input is not None + content = render_result(call.member, call.input, call.result) + changes, held = new_tab_changes(opened, changes) + block = render_browser_state(report.tabs, changes, file_policy) + self.pending_changes = held + + if call.member.name in STATE_ONLY_MEMBERS: + # A block-only result has no text, so the lines wait for the next result that can include them. + self.refusal_pending = refused + self.dialogs_pending = dialogs + return [block], False + return [*content, *self._take_notices(refused, dialogs), block], False + + return [*error_content(bounded_error(call.error)), *self._take_notices(refused, dialogs)], True + + def _take_notices(self, refused: bool, dialogs: Sequence[BetaDialogDismissed]) -> BetaToolsetContent: + """The refused-navigation line and the dialog lines as text blocks, now that a result can include them.""" + self.refusal_pending = False + self.dialogs_pending = [] + lines = [NAVIGATION_REFUSED_LINE] if refused else [] + return [text_block(line) for line in [*lines, *dialog_lines(dialogs)]] + + +def merge_pending_changes( + pending: Sequence[BetaBrowserStateChangeParam], report: Report +) -> list[BetaBrowserStateChangeParam]: + """State changes held back from earlier calls (a failed call's, or a popup's `tab_opened` after `new_tab`), + followed by this report's own, coalesced into what one block may contain: of the changes for one `download_id` + only the latest, where it stands; of the `tab_opened` entries for one tab only the first, and only while that tab + is in the report's `tabs`. A driver's list need not be deduplicated, and a held entry cannot make a valid report + invalid.""" + changes = [*pending, *report.changes] + live_tabs = {tab["tab_id"] for tab in report.tabs} + + # the last position of each download's changes (pydantic v1 lets a missing key through, hence `.get`) + latest = {change.get("download_id"): index for index, change in enumerate(changes) if is_download_change(change)} + + kept: list[BetaBrowserStateChangeParam] = [] + opened: set[str] = set() + for index, change in enumerate(changes): + if change["type"] == "tab_opened": + if change["tab_id"] not in live_tabs or change["tab_id"] in opened: + continue + opened.add(change["tab_id"]) + elif is_download_change(change) and latest[change.get("download_id")] != index: + continue + kept.append(change) + return kept + + +NEW_TAB_NOT_ACTIVE = ( + "new_tab opened a tab, but the browser does not report it as the only active tab. Call list_tabs to see the tabs." +) + + +def new_tab_changes( + opened: str | None, changes: list[BetaBrowserStateChangeParam] +) -> tuple[list[BetaBrowserStateChangeParam], list[BetaBrowserStateChangeParam]]: + """The changes for this block and the changes to hold, as `(keep, held)`. On a `new_tab` result (`opened` is the + tab it opened) the block carries exactly one `tab_opened`, for `opened`, added when the driver reported none. A + `tab_opened` for any other tab, such as a popup the page opened meanwhile, waits for the next block rather than + making this one invalid. For any other call (`opened` is `None`) the changes pass through.""" + if opened is None: + return changes, [] + + keep: list[BetaBrowserStateChangeParam] = [] + held: list[BetaBrowserStateChangeParam] = [] + + for change in changes: + if change["type"] == "tab_opened" and change["tab_id"] != opened: + held.append(change) + else: + keep.append(change) + if not any(change["type"] == "tab_opened" for change in keep): + keep.append({"type": "tab_opened", "tab_id": opened}) + return keep, held + + +# --- URL and file policy enforcement ---------------------------------------------------------------- + + +def bounded_result(member: BrowserMember, result: BetaBrowserMemberResult) -> BetaBrowserMemberResult: + """`result` as the record keeps it: the line a pure action returned is folded to one line and cut. Anything else + is kept as returned.""" + line = action_line(member, result) + return result if line is None else folded(line)[:FIELD_MAX] + + +POLICY_REFUSED = "refused by the URL policy" +"""The refusal for a `url_policy` that raised something other than a ToolError: its own message may +contain the URL it refused, so the model reads this instead and the traceback goes to the log.""" +NO_DOCUMENT_POLICY = "file_upload has no configured document allowlist" +UPLOAD_REFUSED = "the file policy could not vet the upload" + + +def url_context(record: CallRecord) -> BetaURLContext: + assert record.member is not None + return unvalidated(BetaURLContext, member=record.member.name, tab_id=record.tab_id, tool_use_id=record.tool_use_id) + + +HISTORY_NAVIGATION = frozenset(("back", "forward", "reload")) + + +def navigate_url(record: CallRecord) -> str | None: + """The `url` of a `navigate` call exactly as the model wrote it, for the URL policy. `None` for the history + words `back` / `forward` / `reload` (the driver receives the canonical lower-case word) and for other members.""" + if not isinstance(record.input, BetaBrowserNavigateInput): + return None + + url = record.input.url + word = url.strip().lower() + + if word in HISTORY_NAVIGATION: + if word != url: + object.__setattr__(record.input, "url", word) + return None + return url + + +def policy_outcome(returned: object, raised: Exception | None) -> None: + """Turn what the `url_policy` callable did into the pipeline's decision: return to proceed, or raise the refusal + the model reads. A `ToolError` it raised is relayed as-is. Any other exception fails closed with the SDK's own + text (a `ToolsetUsageError` propagates). A non-`None` return is a contract error.""" + if raised is not None: + raise hook_refusal(raised, URLRefusedError(POLICY_REFUSED)) + if returned is not None: + # "return None to allow, raise to refuse": a policy mistakenly written as a predicate + # returns False to block, and reading that as a pass would fail open silently. + raise ToolsetContractError( + f"url_policy must return None to allow or raise ToolError to refuse; got {type(returned).__name__}" + ) + + +def resolve_upload_paths(record: CallRecord, file_policy: BetaFilePolicy | None) -> None: + """For `file_upload`: run `input.paths` and `input.document_ids` through the file policy before + dispatch, so the driver receives what the policy returned and a refused path or document never + reaches it. Without a file policy, an upload that names either is refused.""" + upload = record.input + if not isinstance(upload, BetaBrowserFileUploadInput): + return + + if file_policy is None: + if upload.paths: + raise UploadNoRootsError() + if upload.document_ids: + raise UploadRefusedError(NO_DOCUMENT_POLICY) + return + + ctx = url_context(record) + if upload.paths: + paths = vetted_upload_entries(file_policy, "resolve_upload_paths", ctx, upload.paths) + object.__setattr__(upload, "paths", paths) + + if upload.document_ids: + ids = vetted_upload_entries(file_policy, "resolve_upload_documents", ctx, upload.document_ids) + object.__setattr__(upload, "document_ids", ids) + + +def vetted_upload_entries( + file_policy: BetaFilePolicy, hook: str, ctx: BetaURLContext, given: Sequence[str] +) -> list[str]: + """One `file_upload` field run through the file policy's `hook`: the entries the driver receives, read once (a + generator is spent after one pass).""" + try: + return list(getattr(file_policy, hook)(ctx, given)) # a generator does its work here, inside the guard + except Exception as exc: + # hook_refusal logs it, and the upload never reaches the driver + raise hook_refusal(exc, UploadRefusedError(UPLOAD_REFUSED)) from None + + +# --- confirmation --------------------------------------------------------------------------------- + + +def confirm_context(record: CallRecord, pipeline: PipelineState) -> BetaConfirmContext: + """What the `confirm` callable receives: the member and its parsed input, and the tab the call + targets (named, else active) with its URL, as of the last report and with no page read.""" + assert record.member is not None and record.input is not None + # The URL exactly as the model read it in the last block: folded and bounded when the report was collected. + tab = target_tab(pipeline.last_tabs, record.tab_id) + # Built from values the pipeline already holds, so nothing here is user input to validate. + return unvalidated( + BetaConfirmContext, + tool_use=record.context.tool_use, + member=record.member.name, + input=record.input, + tab_id=record.tab_id if tab is None else tab["tab_id"], + tab_url=None if tab is None else tab["url"], + ) diff --git a/src/anthropic/lib/tools/_toolsets/_registry.py b/src/anthropic/lib/tools/_toolsets/_registry.py new file mode 100644 index 000000000..5d0808011 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_registry.py @@ -0,0 +1,110 @@ +"""The browser member registry: one row per member, from which every other per-member table derives. + +Member names and input models come from the generated `tool_use` union (`_inputs`). What is +kept by hand here is what the generated types lack: each member's result kind, the +confirmation text the model reads after a pure action, and which members the API disables by +default. A test diffs the member set and the default-disabled set against the published toolset +definition so the two cannot drift silently. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing_extensions import Literal, TypeAlias + +from ._base import Member, Registry, ResultKind +from ._inputs import BROWSER_INPUT_TYPES, BROWSER_MEMBER_NAMES, BetaBrowserMemberName +from ....types.beta import BetaBrowserMemberInput + +__all__ = [ + "CONFIRM_REQUIRED", + "ResultKind", + "Member", + "BrowserMember", + "BROWSER", + "BROWSER_MEMBERS", + "BROWSER_DEFAULT_DISABLED_MEMBERS", + "STATE_ONLY_MEMBERS", + "member_enabled", +] + +FAMILY: Literal["browser"] = "browser" +CONFIRM_REQUIRED: frozenset[str] = frozenset({"javascript_exec", "file_upload"}) +"""Members whose calls wait for a person unless the application's `confirm` approves them unattended: page +content can steer what the model asks them to do, and what they do reaches outside the page.""" +TOOLSET_TYPE: Literal["browser_toolset_20260801"] = "browser_toolset_20260801" + +BrowserMember: TypeAlias = Member[BetaBrowserMemberName, BetaBrowserMemberInput] + + +# Hand-kept: result kinds, confirmation templates and default-disabled flags. Everything else about +# a member is generated. +ROWS: dict[str, tuple[ResultKind, str | None, bool]] = { + "navigate": ("navigate", None, True), + "screenshot": ("screenshot", None, True), + "zoom": ("screenshot", None, True), + "left_click": ("none", "Clicked.", True), + "right_click": ("none", "Right-clicked.", True), + "middle_click": ("none", "Middle-clicked.", True), + "double_click": ("none", "Double-clicked.", True), + "triple_click": ("none", "Triple-clicked.", True), + "hover": ("none", "Hovered.", True), + "left_click_drag": ("none", "Dragged.", True), + "left_mouse_down": ("none", "Mouse button pressed.", True), + "left_mouse_up": ("none", "Mouse button released.", True), + "mouse_move": ("none", "Moved the mouse.", True), + "scroll": ("none", "Scrolled {scroll_direction}.", True), + "scroll_to": ("none", "Scrolled to {ref}.", True), + "type": ("none", "Typed.", True), + "key": ("none", "Pressed {text}.", True), + "hold_key": ("none", "Held {text} for {duration}s.", True), + "form_input": ("none", "Set the value of {ref}.", True), + "read_page": ("text", None, True), + "find": ("text", None, True), + "get_page_text": ("text", None, True), + "wait": ("none", "Waited {duration}s.", True), + "file_upload": ("none", "Uploaded.", False), + "read_console": ("text", None, False), + "read_network": ("text", None, False), + "javascript_exec": ("text", None, False), + "new_tab": ("tab", None, True), + "list_tabs": ("tabs", None, True), + "switch_tab": ("tab", None, True), + "close_tab": ("none", None, True), +} + + +def build_registry() -> dict[str, BrowserMember]: + names: set[str] = set(BROWSER_MEMBER_NAMES) + missing = names - set(ROWS) + extra = set(ROWS) - names + if missing or extra: # pragma: no cover - an import-time guard, exercised whenever codegen changes the set + raise AssertionError(f"browser member registry drift: missing={sorted(missing)} extra={sorted(extra)}") + table: dict[str, BrowserMember] = {} + for name in BROWSER_MEMBER_NAMES: + result, text, enabled = ROWS[name] + table[name] = BrowserMember( + name=name, input=BROWSER_INPUT_TYPES[name], result=result, text=text, enabled_by_default=enabled + ) + return table + + +BROWSER: Registry[BetaBrowserMemberName, BetaBrowserMemberInput] = Registry( + family=FAMILY, helper_tag="browser-toolset", members=build_registry() +) +"""The browser family: its members in the generated union's order, and what it is called on the wire.""" + +BROWSER_MEMBERS: Mapping[str, BrowserMember] = BROWSER.members +"""Every browser member by name, in the generated union's order. A name the model made up is absent.""" + +BROWSER_DEFAULT_DISABLED_MEMBERS: frozenset[str] = BROWSER.default_disabled +"""Members the API withholds unless `configs` enables them.""" + +STATE_ONLY_MEMBERS: frozenset[str] = frozenset(("new_tab", "list_tabs", "switch_tab", "close_tab")) +"""The tab members, which render no content of their own, so their non-error result is exactly one `browser_state` +block, and a line meant for the model (a refused navigation) waits for the next result that can hold it. Listed by +name rather than derived from `BROWSER_MEMBERS`, so a tab member that gains a confirmation line stays in the set.""" + + +member_enabled = BROWSER.member_enabled +"""Whether `configs` leave a browser member enabled. See `Registry.member_enabled`.""" diff --git a/src/anthropic/lib/tools/_toolsets/_render.py b/src/anthropic/lib/tools/_toolsets/_render.py new file mode 100644 index 000000000..a2248ca9c --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_render.py @@ -0,0 +1,251 @@ +"""Turns what a member returned into `tool_result` content, and a `BetaBrowserState` into the +`browser_state` block. + +The registry row records what kind of result a member returns. The renderer reads the fields it +needs off that result. The tab-management members render no content of their own: the API accepts +the `browser_state` block as their whole result and produces the text the model reads from it. +""" + +from __future__ import annotations + +import re +import copy +import json +import inspect +from typing import Literal, cast +from collections.abc import Sequence +from typing_extensions import TypeAlias, TypeGuard, assert_never + +from ._base import Member, BetaScreenshotResult +from ._hooks import BetaFilePolicy +from ._results import ( + StateChange, + BetaDialogDismissed, + BetaBrowserMemberResult, + BetaBrowserNavigateResult, +) +from ._runnable import BetaToolsetContent, hook_refusal +from ._sanitize import one_line, well_formed, bounded_tab_url +from ...._models import BaseModel +from ....types.beta import ( + BetaImageBlockParam, + BetaBrowserStateBlockParam, + BetaBrowserStateChangeParam, + BetaBrowserStateTabEntryParam, + BetaBrowserStateChangeDownloadFailedParam, + BetaBrowserStateChangeDownloadStartedParam, + BetaBrowserStateChangeDownloadCompletedParam, +) +from .._beta_functions import ToolError +from ._computer_results import BetaScreenshotResult, BetaComputerMemberResult, BetaComputerCursorPositionResult +from ....types.beta.beta_tool_result_block_param import Content + +__all__ = [ + "MemberResult", + "text_block", + "image_block", + "NAVIGATION_REFUSED_LINE", + "action_line", + "render_result", + "render_browser_state", + "path_visible", + "dialog_lines", + "is_download_change", +] + +MemberResult = BetaBrowserMemberResult | BetaComputerMemberResult +"""What any member of either family may return.""" + +NAVIGATION_REFUSED_LINE = "A navigation was refused." +"""The single line the model reads for any number of `BetaNavigationRefused` entries in a report.""" + + +def input_field(input: BaseModel, path: str) -> str: + value: object = input + for part in path.split("."): + if value is None: + return "" + value = getattr(value, part) + if value is None: + return "" + if isinstance(value, float) and value.is_integer(): + value = int(value) # a whole number as JSON spells it, so the model reads `Waited 2s.` + return one_line(str(value)) + + +TEMPLATE_FIELDS = { + "scroll_direction": "scroll_direction", + "text": "text", + "duration": "duration", + "ref": "target.ref", +} +TEMPLATE_TOKEN_RE = re.compile("|".join(re.escape("{" + key + "}") for key in TEMPLATE_FIELDS)) + + +def confirmation_text(template: str, input: BaseModel) -> str: + """Fill a pure action's template from its input in a single pass, so a model-supplied value + such as `"{ref}"` stays data and is never re-read as a placeholder.""" + return TEMPLATE_TOKEN_RE.sub(lambda m: input_field(input, TEMPLATE_FIELDS[m.group(0)[1:-1]]), template) + + +def text_block(text: str) -> Content: + return {"type": "text", "text": text} + + +def image_block(data: str, media_type: Literal["image/png", "image/jpeg", "image/gif", "image/webp"]) -> Content: + """One image block around the base64 `data` a screenshot or zoom member returned.""" + image: BetaImageBlockParam = {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}} + return image + + +def action_line(member: Member[str, BaseModel], result: object) -> str | None: + """The line of text a pure action returned, as written: `render_result` folds it to one line (control, + line-separator and bidi characters a space) and bounds it, and the browser pipeline checks it as error text first. + `None` when it returned nothing, or anything but a non-empty string, and for every other member.""" + if member.result != "none" or member.text is None or not isinstance(result, str): + return None + return result or None + + +def render_result(member: Member[str, BaseModel], input: BaseModel, result: MemberResult) -> BetaToolsetContent: + """The content blocks for a member's result, by the registry's result kind. A pure action renders as its fixed + acknowledgment, then, in a text block of its own, the line it returned, if any (already bounded when + it arrives here).""" + kind = member.result + + if kind == "none": + if not member.text: + return [] + lines = [confirmation_text(member.text, input)] + line = action_line(member, result) + if line is not None: + lines.append(one_line(line)) + return [text_block(text) for text in lines] + + if kind == "tab" or kind == "tabs": + # new_tab / switch_tab / list_tabs: the browser_state block is the whole result. + return [] + + if kind == "navigate": + nav = result + if not isinstance(nav, BetaBrowserNavigateResult): + # Not a TypeError: the browser_state check turns one into ToolsetContractError, which stops the run. A + # ValueError reaches the model as an error result. + raise ValueError(f"navigate returned {type(nav).__name__}, not its result model") + + # the final address is page-chosen through redirects: folded to one line and held to the bound, as a tab's is + line = f"Navigated to {bounded_tab_url(nav.url)}" + if nav.title is not None: + line += f" — {one_line(nav.title)}" + if nav.status is not None: + line += f" (HTTP {nav.status})" + return [text_block(line)] + + if kind == "screenshot": + shot = result + if not isinstance(shot, BetaScreenshotResult): + raise ValueError(f"screenshot returned {type(shot).__name__}, not its result model") + return [image_block(shot.data, shot.media_type)] + + if kind == "point": + point = result + if not isinstance(point, BetaComputerCursorPositionResult): + raise ValueError(f"cursor_position returned {type(point).__name__}, not its result model") + return [text_block(f"X={point.x},Y={point.y}")] + + if kind == "text": + text = well_formed(str(result)) # page text: a lone surrogate in it could not be sent + return [text_block(text if text else "(empty)")] + + assert_never(kind) + + +# the API's limit for these fields, as for a URL +def bounded_tab(tab: BetaBrowserStateTabEntryParam) -> BetaBrowserStateTabEntryParam: + bounded = copy.copy(tab) + bounded["url"], bounded["title"] = bounded_tab_url(tab["url"]), one_line(tab["title"]) + return bounded + + +DIALOG_MESSAGE_MAX = 200 +DIALOG_KIND_MAX = 20 # `kind` is driver-supplied and goes into the line unquoted, and a real one is a single word +DIALOG_LINES_MAX = 3 + + +def dialog_lines(dialogs: Sequence[BetaDialogDismissed]) -> list[str]: + """One line per dismissed dialog the driver reported (`A confirm dialog "…" was dismissed.`), the message capped + and at most a few lines, then a count of the rest, because a page can open dialogs in a loop.""" + lines: list[str] = [] + for dialog in dialogs[:DIALOG_LINES_MAX]: + kind = one_line(dialog.kind)[:DIALOG_KIND_MAX] or "dialog" + article = "An" if kind[:1].lower() in "aeiou" else "A" + noun = kind if kind == "dialog" else f"{kind} dialog" + message = one_line(dialog.message) + if len(message) > DIALOG_MESSAGE_MAX: + message = message[:DIALOG_MESSAGE_MAX] + "…" + quoted = f" {json.dumps(message, ensure_ascii=False)}" if message else "" + lines.append(f"{article} {noun}{quoted} was dismissed.") + if len(dialogs) > DIALOG_LINES_MAX: + lines.append(f"{len(dialogs) - DIALOG_LINES_MAX} more dialogs were dismissed.") + return lines + + +DownloadChangeParam: TypeAlias = ( + BetaBrowserStateChangeDownloadStartedParam + | BetaBrowserStateChangeDownloadCompletedParam + | BetaBrowserStateChangeDownloadFailedParam +) +"""The wire change kinds that have a page-supplied `url` (and possibly a local `path` or an `error`).""" +DownloadChangeType: TypeAlias = Literal["download_started", "download_completed", "download_failed"] +DOWNLOAD_TYPES: tuple[DownloadChangeType, ...] = ("download_started", "download_completed", "download_failed") + + +def is_download_change(change: StateChange) -> TypeGuard[DownloadChangeParam]: + return isinstance(change, dict) and change["type"] in DOWNLOAD_TYPES + + +def path_visible(file_policy: BetaFilePolicy | None, path: str) -> bool: + """The file policy's verdict on one download path, hidden without a policy. Only `True` shows it. A + `ToolsetUsageError` propagates. Any other exception from the predicate fails closed: the path stays hidden and the + report still goes out.""" + if file_policy is None: + return False + + try: + verdict: object = file_policy.is_path_visible(path) + except Exception as exc: + hook_refusal(exc, ToolError("path hidden")) # a ToolsetUsageError propagates; anything else is logged + return False + + if inspect.iscoroutine(verdict): + verdict.close() # from an `async` predicate: hidden, with no "never awaited" warning left behind + return verdict is True + + +def render_browser_state( + tabs: Sequence[BetaBrowserStateTabEntryParam], + changes: Sequence[BetaBrowserStateChangeParam], + file_policy: BetaFilePolicy | None, +) -> BetaBrowserStateBlockParam: + """The `browser_state` block: the driver's tabs as reported with each URL and title folded to one line and held to + the API's length limit, and `changes` with a download's `url` and a failed download's `error` bounded the same + way, and its `path` removed from every download change, kept only on a `download_completed` whose path the file + policy exposes.""" + wire_changes: list[BetaBrowserStateChangeParam] = [] + for change in changes: + if is_download_change(change): + change = copy.copy(change) # the driver's dict is left as it was + change["url"] = bounded_tab_url(change["url"]) + # taken off whichever kind has it, declared or not: a local path never goes out unchecked + path = cast("dict[str, str | None]", change).pop("path", None) + if change["type"] == "download_completed" and path is not None and path_visible(file_policy, path): + # a page-chosen name that folding or cutting would change could name another file + if one_line(path) == path: + change["path"] = path + if change["type"] == "download_failed" and (error := change.get("error")) is not None: + change["error"] = one_line(error) + wire_changes.append(change) + block: BetaBrowserStateBlockParam = {"type": "browser_state", "tabs": [bounded_tab(tab) for tab in tabs]} + if wire_changes: # "nothing to report" is an absent field, never an empty list + block["state_changes"] = wire_changes + return block diff --git a/src/anthropic/lib/tools/_toolsets/_results.py b/src/anthropic/lib/tools/_toolsets/_results.py new file mode 100644 index 000000000..b4e86d4f8 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_results.py @@ -0,0 +1,107 @@ +"""What a browser toolset member returns. + +Tabs and state changes are the generated wire types (`BetaBrowserStateTabEntryParam`, +`BetaBrowserStateChange*Param`) returned as they are. Only the shapes with no wire twin are +declared here. +""" + +from __future__ import annotations + +from typing_extensions import Literal, Annotated, TypeAlias + +from pydantic import Field + +from ._base import BetaScreenshotResult +from ...._models import BaseModel, UnionDiscriminator +from ....types.beta import BetaBrowserStateChangeParam, BetaBrowserStateTabEntryParam + +__all__ = [ + "BetaBrowserNavigateResult", + "BetaNavigationRefused", + "BetaDialogDismissed", + "StateChange", + "BetaBrowserState", + "BetaBrowserMemberResult", +] + + +def no_changes() -> list[StateChange]: + return [] + + +class BetaBrowserNavigateResult(BaseModel): + """Where a navigation ended up. Rendered as `Navigated to {url} — {title} (HTTP {status})`, each value made safe + for one line of text (control, line-separator and bidi characters replaced, length bounded).""" + + url: str + """The final URL after redirects.""" + + status: int | None = None + + title: str | None = None + + +class BetaNavigationRefused(BaseModel): + """A navigation the driver refused, for example a page-started navigation its request interception stopped. + + Its `type` is the SDK's own and is never sent: the SDK reports it to the model as one fixed line of text rather + than in the `browser_state` block. + """ + + type: Literal["navigation_refused"] = "navigation_refused" + + +class BetaDialogDismissed(BaseModel): + """A native dialog (`alert`, `confirm`, `prompt`) the page opened and the driver dismissed. + + Its `type` is the SDK's own and is never sent: the SDK reports it to the model as a line of text with the next + block (`A confirm dialog "Delete everything?" was dismissed.`), which tells the model the page asked and what the + answer was. `message` is page-supplied text, capped when rendered. + """ + + type: Literal["dialog_dismissed"] = "dialog_dismissed" + + kind: str + """`"alert"`, `"confirm"`, `"prompt"` (or whatever the browser called it).""" + + message: str = "" + + +StateChange: TypeAlias = Annotated[ + BetaBrowserStateChangeParam | BetaNavigationRefused | BetaDialogDismissed, + Field(discriminator="type"), + UnionDiscriminator("type"), +] +"""One `state_changes` entry, told apart by `type`: a generated `BetaBrowserStateChange*Param`, or one of the +SDK's own kinds (`BetaNavigationRefused`, `BetaDialogDismissed`) that reach the model as text.""" + + +class BetaBrowserState(BaseModel): + """The browser after a call: what `_browser_state` returns. Forwarded to the API with download paths the file + policy does not expose removed and each reported URL, title and error folded to one bounded line. + + `tabs` is the full inventory (the generated `BetaBrowserStateTabEntryParam`, with the active + tab marked by its own `active` flag), `state_changes` what changed since the last report. The API's + rules apply unchanged: + + - `tab_id` values are unique; + - exactly one tab is `active` when any tab is open; + - at most 100 tabs. + + Of the state changes, the SDK keeps the latest per `download_id` and one `tab_opened` per tab still open, so + the list need not be deduplicated. + """ + + tabs: list[BetaBrowserStateTabEntryParam] + state_changes: list[StateChange] = Field(default_factory=no_changes) + + +BetaBrowserMemberResult: TypeAlias = ( + BetaBrowserNavigateResult + | BetaScreenshotResult + | BetaBrowserStateTabEntryParam + | list[BetaBrowserStateTabEntryParam] + | str + | None +) +"""Anything a browser member may return: `execute`'s un-narrowed result type.""" diff --git a/src/anthropic/lib/tools/_toolsets/_runnable.py b/src/anthropic/lib/tools/_toolsets/_runnable.py new file mode 100644 index 000000000..e22c6f745 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_runnable.py @@ -0,0 +1,284 @@ +"""Runnable-toolset contract for the toolset families (`browser_toolset_20260801`, `computer_toolset_20260801`). + +A toolset is one nameless `tools[]` entry declaring a family of member tools. Each member call +is its own `tool_use` whose `name` is the member and whose `toolset_name` is the family +(`"browser"`, `"computer"`), and the `tool_result` echoes `toolset_name`. Member names are not reserved (a +custom tool may be called `navigate`, and the two families share names such as `screenshot`), so dispatch keys on +`(toolset_name, name)`, never on `name` alone. +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from types import TracebackType, MappingProxyType +from typing import Any, Literal, TypeVar, ClassVar, TypeAlias, cast +from collections.abc import Mapping, Sequence +from typing_extensions import Self + +import pydantic + +from ._errors import ToolsetUsageError, ToolsetClosedError, ToolsetContractError +from ._sanitize import log_safe, error_text, well_formed, error_text_content +from ...._compat import PYDANTIC_V1 +from ...._models import BaseModel +from ....types.beta import ( + BetaToolUseBlock, + BetaToolResultBlockParam, + BetaBrowserToolset20260801Param, + BetaComputerToolset20260801Param, +) +from .._beta_functions import ToolError +from ....types.beta.beta_tool_result_block_param import Content + +__all__ = [ + "ToolsetFamily", + "BetaToolsetParam", + "BetaToolsetContent", + "BetaToolsetCallContext", + "unvalidated", + "BetaRunnableToolset", + "BetaAsyncRunnableToolset", + "BetaAnyRunnableToolset", + "TOOLSET_CLASSES", + "toolset_result_block", + "classify", + "hook_refusal", + "member_error_result", + "not_executed_result", + "NOT_EXECUTED", + "HALT_TEXT", +] + +ToolsetFamily: TypeAlias = Literal["browser", "computer"] + +BetaToolsetParam: TypeAlias = BetaBrowserToolset20260801Param | BetaComputerToolset20260801Param + +BetaToolsetContent: TypeAlias = list[Content] +"""The `tool_result` content a member call renders to.""" + +log = logging.getLogger(__name__) + + +_ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) + + +def unvalidated(cls: type[_ModelT], **values: Any) -> _ModelT: + """Build one of the SDK's own context or report models around objects that already exist (a parsed `tool_use` + block, a member's input model, a driver's report) without validating: pydantic's raw constructor, so nothing is + copied, coerced into a sibling union member, or stripped of keys the SDK does not declare. The SDK base class's + `construct` still coerces union and typed-dict fields, which is why it is not used here.""" + if PYDANTIC_V1: + return cast("_ModelT", pydantic.BaseModel.construct.__func__(cls, **values)) # type: ignore[attr-defined] + return cast("_ModelT", pydantic.BaseModel.model_construct.__func__(cls, **values)) # type: ignore[attr-defined] + + +class BetaToolsetCallContext(BaseModel): + """What a member receives alongside its input: the `tool_use` block the call answers + (`context.tool_use.id`). `None` only when a member is invoked outside any model call.""" + + tool_use: BetaToolUseBlock | None = None + + +class BaseRunnableToolset(ABC): + toolset_name: ClassVar[ToolsetFamily] + """The family this toolset answers for, matched against `tool_use.toolset_name`.""" + + _toolset_closed: bool = False + """Set once `close()` ran (directly or through a `with` block). A member call on a closed toolset raises + `ToolsetClosedError` at your code instead of answering the model.""" + + def _toolset_refuse_if_closed(self) -> None: + if self._toolset_closed: + raise ToolsetClosedError(f"this {self.toolset_name!r} toolset is closed") + + @abstractmethod + def to_dict(self) -> BetaToolsetParam: + """The `tools[]` entry to send. SDK-side behaviour options never appear here.""" + ... + + def _toolset_check_tool_use(self, tool_use: BetaToolUseBlock) -> None: + # A block without `toolset_name` is a plain tool (possibly a custom tool sharing a member + # name) and must never be routed here. + if tool_use.toolset_name != self.toolset_name: + raise ToolsetContractError( + f"tool_use {tool_use.id!r} (toolset_name={tool_use.toolset_name!r}, name={tool_use.name!r}) " + f"is not a member call of the {self.toolset_name!r} toolset" + ) + + +class BetaRunnableToolset(BaseRunnableToolset): + @abstractmethod + def call(self, context: BetaToolsetCallContext, name: str, input: Mapping[str, object]) -> str | BetaToolsetContent: + """Execute member `name` with the raw `tool_use.input` and return the tool-result + content. Raise `anthropic.lib.tools.ToolError` for a model-visible failure.""" + ... + + def close(self) -> None: + """Release any resource the toolset holds (a browser, a socket, a subprocess) when you are done with it. + The tool runner never closes a toolset, so one instance can serve several runs. Marks the toolset closed (a + member call afterwards raises `ToolsetClosedError`), and toolsets built on the SDK's abstract classes also + wait for queued and in-flight member calls to settle. Override it in a toolset that owns a resource and call + `super().close()` first. `with toolset:` calls it for you.""" + self._toolset_closed = True + + def __enter__(self) -> Self: + self._toolset_refuse_if_closed() + return self + + def __exit__( + self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None + ) -> None: + if self._toolset_closed: + return # closed inside the block already, so close only once + try: + self.close() + finally: + self._toolset_closed = True + + def tool_result(self, tool_use: BetaToolUseBlock) -> BetaToolResultBlockParam: + """Run one member `tool_use` and build its `tool_result` block, for callers driving + `messages.create` themselves. + + Follows the tool runner's rule: a `ToolsetUsageError` propagates, a + `ToolError` or any other exception becomes an `is_error` result. + """ + self._toolset_check_tool_use(tool_use) + self._toolset_refuse_if_closed() + try: + content = self.call(unvalidated(BetaToolsetCallContext, tool_use=tool_use), tool_use.name, tool_use.input) + except Exception as exc: + return member_error_result(tool_use, exc) + return toolset_result_block(tool_use, content) + + +class BetaAsyncRunnableToolset(BaseRunnableToolset): + @abstractmethod + async def call( + self, context: BetaToolsetCallContext, name: str, input: Mapping[str, object] + ) -> str | BetaToolsetContent: ... + + async def close(self) -> None: + """Release any resource the toolset holds when you are done with it. The tool runner never closes a + toolset. The default only marks the toolset closed. Override it in a toolset that owns a resource and + `await super().close()`. `async with toolset:` awaits it for you.""" + self._toolset_closed = True + + async def __aenter__(self) -> Self: + self._toolset_refuse_if_closed() + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None + ) -> None: + if self._toolset_closed: + return + try: + await self.close() + finally: + self._toolset_closed = True + + async def tool_result(self, tool_use: BetaToolUseBlock) -> BetaToolResultBlockParam: + self._toolset_check_tool_use(tool_use) + self._toolset_refuse_if_closed() + try: + content = await self.call( + unvalidated(BetaToolsetCallContext, tool_use=tool_use), tool_use.name, tool_use.input + ) + except Exception as exc: + # Cancellation is a BaseException and propagates untouched, so the call aborts rather + # than being answered. + return member_error_result(tool_use, exc) + return toolset_result_block(tool_use, content) + + +BetaAnyRunnableToolset: TypeAlias = BetaRunnableToolset | BetaAsyncRunnableToolset +TOOLSET_CLASSES = (BetaRunnableToolset, BetaAsyncRunnableToolset) +"""The classes that identify a toolset entry in an `isinstance` check, sync or async.""" + + +NOT_EXECUTED = "Not executed: an earlier action in this turn failed." +"""What the runner answers for a toolset member it did not run because an earlier member of the same toolset failed +in the same assistant turn: the model planned those actions as a sequence, and a later one rarely makes sense once +an earlier one did not happen.""" + +HALT_TEXT: Mapping[str, str] = MappingProxyType( + {"browser": NOT_EXECUTED, "computer": "Not executed: an earlier computer action in this turn failed."} +) +"""Each family's wording of `NOT_EXECUTED`, by `toolset_name`. The wording is the API's and differs per family. +`NOT_EXECUTED` is the browser's, and the fallback for a `toolset_name` not in `HALT_TEXT`.""" + + +def not_executed_result(tool_use: BetaToolUseBlock) -> BetaToolResultBlockParam: + return toolset_result_block(tool_use, HALT_TEXT.get(tool_use.toolset_name or "", NOT_EXECUTED), is_error=True) + + +def classify(exc: Exception) -> ToolError: + """The rule for an exception out of a member call, at the `execute` boundary and in the runner alike: a + `ToolsetUsageError` propagates, a `ToolError` is the member's own refusal, and anything else a driver raises, + its `TypeError` included, becomes a refusal the model reads, so the loop continues. The text is returned whole, + so it can be checked before it is cut. Cancellation is a `BaseException` and never reaches here.""" + if isinstance(exc, ToolsetUsageError): + raise exc + if isinstance(exc, ToolError): + return exc + # A browser toolset member's error reads as the type and the message, not `repr`: `repr` writes a line break as + # `\n`, which hides the break from the path check, and shows constructor arguments that the message leaves out. + name = type(exc).__name__ + try: + message = str(exc) + except Exception: + message = "" # a broken `__str__` still gives the model the type + return ToolError(well_formed(f"{name}: {message}" if message else name)) + + +def hook_refusal(exc: Exception, fallback: ToolError) -> ToolError: + """The rule for an exception out of a developer hook (`confirm`, `url_policy`, the file policy): a + `ToolsetUsageError` stops the run, a `ToolError` is the hook's own refusal and is returned as raised, and + anything else fails closed as `fallback`: its own message may contain the address or path being withheld, + so the model reads the fallback's text instead and the traceback goes to the log.""" + error = classify(exc) + if error is not exc: + log.exception("A toolset hook raised; failing closed with %r", str(fallback), exc_info=exc) + error = fallback + return error + + +def member_error_result(tool_use: BetaToolUseBlock, exc: Exception) -> BetaToolResultBlockParam: + """`classify` for the runner: the refusal as an `is_error` result, an unexpected exception logged first.""" + error = classify(exc) + content = error.content + if error is not exc: + # The names are model output and the exception text may contain page content: the log line gets the names with + # control characters folded (no forged log records), the model gets the text bounded. + log.exception( + "Error occurred while calling toolset member: %s/%s", + log_safe(tool_use.toolset_name), + log_safe(tool_use.name), + exc_info=exc, + ) + content = error_text(exc) + return toolset_result_block(tool_use, error_text_content(content), is_error=True) + + +def toolset_result_block( + tool_use: BetaToolUseBlock, + content: str | Sequence[Content], + *, + is_error: bool = False, +) -> BetaToolResultBlockParam: + """The `tool_result` answering a member `tool_use`. `toolset_name` is required on a member + result (the API rejects a mismatch), so it is echoed on error paths too.""" + if is_error and not content: + # The API rejects an is_error result with empty content, ending the loop instead of showing + # the model the error. An empty ToolError (str(TimeoutError()) is "") reaches here. + content = "The tool call failed with an empty error message." + block: BetaToolResultBlockParam = { + "type": "tool_result", + "tool_use_id": tool_use.id, + "toolset_name": tool_use.toolset_name, + "content": content, + } + if is_error: + block["is_error"] = True + return block diff --git a/src/anthropic/lib/tools/_toolsets/_sanitize.py b/src/anthropic/lib/tools/_toolsets/_sanitize.py new file mode 100644 index 000000000..6936f1818 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_sanitize.py @@ -0,0 +1,103 @@ +"""Every place a page- or model-supplied string is cleaned or cut before it reaches the model, a log, or an +SDK-written line, in one module so what each does and how they differ can be read side by side. + +- `one_line`: any page- or model-supplied value that goes into one line of text (a title, a download error, a + field of a confirmation line): line breaks, controls and bidi characters folded to a space, a lone surrogate to + U+FFFD, trimmed, cut to the API's field limit. `log_safe` and `quoted_name` are the same, cut short, for a log + record or a name echoed inside quotes. +- `error_text` / `error_text_content`: an exception's text as an `is_error` result reports it (as a function + tool's does), and the text-only content such a result may hold. +- `bounded_tab_url`: a tab's or download's reported URL folded to one line and held to the field limit. +""" + +from __future__ import annotations + +import re +import copy + +from ....types.beta import BetaTextBlockParam +from .._beta_functions import BetaFunctionToolResultType + +__all__ = [ + "FIELD_MAX", + "well_formed", + "folded", + "one_line", + "log_safe", + "quoted_name", + "error_text", + "error_text_content", + "bounded_tab_url", +] + +# anything that would break or reorder the one line a page-supplied value goes into +LINE_UNSAFE = "\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069" +LINE_UNSAFE_RE = re.compile(f"[{LINE_UNSAFE}]+") +# An unpaired surrogate cannot be encoded as UTF-8: in a page-chosen string it would fail every request for as long +# as the driver keeps reporting it. Folded to U+FFFD wherever page text is cleaned. +LONE_SURROGATE_RE = re.compile("[\ud800-\udfff]") + + +def well_formed(text: str) -> str: + """`text` with each unpaired surrogate replaced by U+FFFD, so it can be sent.""" + return LONE_SURROGATE_RE.sub("\ufffd", text) + + +FIELD_MAX = 4096 +"""The API's limit for a text field of the block, for a URL, and the most of an exception's text an `is_error` +result includes.""" +LOG_MAX = 200 + + +def folded(text: str) -> str: + """`text` on one line, not yet cut: line breaks, control and bidi characters folded to spaces, a lone surrogate to + U+FFFD, the ends trimmed. For a value that is checked before it is bounded (`one_line` is this, then the cut).""" + return LINE_UNSAFE_RE.sub(" ", well_formed(text)).strip() + + +def one_line(text: str, limit: int = FIELD_MAX) -> str: + """A page- or model-supplied text on one line the model or an operator reads: a tab's title, a download's error, a + field of a member's confirmation line. `folded`, then cut to `limit`, since nothing else bounds it.""" + return folded(text)[:limit] + + +def log_safe(name: str | None) -> str: + """A model- or page-supplied name for a log record or an SDK-written error text: `one_line`, cut short.""" + return one_line(str(name), LOG_MAX) + + +def quoted_name(name: str | None) -> str: + """A model-chosen name as it is echoed inside single quotes in a text the model reads: one line, cut short, and + without the quotes and runs of spaces that would let it pose as the text around it.""" + return re.sub("[' ]+", " ", log_safe(name)).strip() + + +def error_text(exc: BaseException) -> str: + """`repr(exc)` as the `is_error` result the model reads, well formed and cut to the field limit: a + driver's exception can embed a page-sized payload (a request URL, a script dump).""" + return well_formed(repr(exc))[:FIELD_MAX] + + +def error_text_content(content: BetaFunctionToolResultType) -> str | list[BetaTextBlockParam]: + """The text blocks of a `ToolError`'s content. Deliberate: the API accepts only text on an `is_error` result and + answers anything else with a 400 that ends the run, so an image a member attached to its refusal is dropped here + rather than ending the run, and the refusal's text still reaches the model.""" + if isinstance(content, str): + return well_formed(content) + + # The API rejects an `is_error` result whose content is empty or holds a non-text block. Empty text blocks are + # dropped too, so an error with no text left gets the placeholder from `toolset_result_block`. + kept: list[BetaTextBlockParam] = [] + for block in content: + if block["type"] == "text" and block["text"]: + block = copy.copy(block) + block["text"] = well_formed(block["text"]) + kept.append(block) + return kept + + +def bounded_tab_url(url: str) -> str: + """A tab's or download's URL as the model reads it: folded to one line, not parsed, held to `FIELD_MAX`. A longer + one becomes `…` plus its first `FIELD_MAX - 1` characters, so a page padding its URL leaves no host to parse.""" + line = folded(url) + return line if len(line) <= FIELD_MAX else "\u2026" + line[: FIELD_MAX - 1] diff --git a/src/anthropic/lib/tools/_toolsets/_security.py b/src/anthropic/lib/tools/_toolsets/_security.py new file mode 100644 index 000000000..f64a9fd74 --- /dev/null +++ b/src/anthropic/lib/tools/_toolsets/_security.py @@ -0,0 +1,252 @@ +"""Filesystem checks for browser toolsets. + +`beta_check_upload_path` is default-deny: no roots means every path is refused. `BetaLocalFilePolicy` +is the file policy the SDK ships: upload roots, a download directory, and whether download paths +reach the model. +""" + +from __future__ import annotations + +import os +import re +import unicodedata +from pathlib import Path, PurePath +from collections.abc import Iterable, Sequence + +from ._hooks import BetaURLContext +from ._errors import ToolsetConfigError, UploadNoRootsError, UploadRefusedError, UploadOutsideRootsError +from .._beta_functions import ToolError + +__all__ = ["beta_check_upload_path", "BetaLocalFilePolicy"] + +# beta_check_upload_path resolves a not-yet-existing path one component at a time (O(n) per component, +# O(n^2) overall), so a crafted path with tens of thousands of components stalls the tool for seconds. +# No real upload path is anywhere near this, so refuse an over-long one before the walk. +MAX_UPLOAD_PATH_LENGTH = 4096 +ON_WINDOWS = os.name == "nt" +# Two leading separators of either kind: Windows reads "/\\host" and "\\/?" as UNC and device paths too. +UNC_OR_DEVICE_PATH_RE = re.compile(r"^[\\/]{2}") +DOS_DEVICE_NAME_RE = re.compile( + r"^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[0-9\u00b9\u00b2\u00b3]|LPT[0-9\u00b9\u00b2\u00b3])(?:[.:].*)?$", + re.IGNORECASE, +) + + +def realpath_lenient(path: str) -> Path: + """`path` made absolute with every existing symlink resolved and `..` collapsed. Components that do not exist + yet (a download directory to be created, a file under it) stay as written after the resolved prefix.""" + return Path(os.path.realpath(path)) + + +def names_network_or_device(candidate: str) -> bool: + """Whether, on Windows, `candidate` is a UNC / device-namespace path or has a component naming a DOS device. + Elsewhere these are ordinary names (`aux/notes.txt`, `con.pdf`) and this is `False`: like the rest of this + module's path checks, the answer is for the host this process runs on.""" + if not ON_WINDOWS: + return False + return bool(UNC_OR_DEVICE_PATH_RE.match(candidate)) or any( + # Win32 resolves a device name from a path component's stem: the text up to the first "." or + # ":", with trailing spaces and dots dropped. So "CON", "CON.txt", "CON .log" and "COM1 " all + # name a device. Match that stem, and split components on ":" as well so a drive-relative + # ("C:CON") or alternate-data-stream ("f.txt:CON") spelling is caught. + DOS_DEVICE_NAME_RE.match(re.split(r"[.:]", part, maxsplit=1)[0].rstrip(" .")) + for part in re.split(r"[\\/:]", candidate) + ) + + +def beta_check_upload_path( + path: str | os.PathLike[str], + roots: Sequence[str | os.PathLike[str]], + *, + strict_roots: bool = True, +) -> str: + """Return the resolved path of `path` if it lies under one of `roots` (symlinks + resolved on both sides, whole-component comparison), else raise `UploadRefusedError` (a `ToolError`). + + The refusal never lists the roots. Empty `roots` refuses everything. With `strict_roots` + (the default), a root that does not resolve on disk grants nothing. Pass `strict_roots=False` + for a lenient containment check that tolerates a not-yet-existing root. + """ + if isinstance(roots, (str, bytes, os.PathLike)): + # A bare str satisfies `Sequence[str]`, so beta_check_upload_path(p, "/srv/uploads") type-checks + # and then iterates characters: the first, "/", resolves to a parent of every absolute + # path and approves everything. Fail closed and loudly on the shape instead. + raise ToolsetConfigError("roots must be a sequence of paths, not a single path") + if not roots: + raise UploadNoRootsError() + + candidate = os.fspath(path) + if not candidate or "\x00" in candidate: + raise UploadRefusedError("file_upload path is empty or contains a NUL byte") + if len(candidate) > MAX_UPLOAD_PATH_LENGTH: + raise UploadRefusedError("file_upload path is too long") + + if names_network_or_device(candidate): + # Refused before anything touches the filesystem, on Windows: there, resolving a UNC path makes + # the redirector connect to the named host with the user's credentials (a forced-auth + # leak) even though the path is then refused, and a DOS device name (CON, COM1, NUL.txt) + # is kept by `realpath` as an unresolved tail under the root, and then opens the device + # rather than a file. + raise UploadOutsideRootsError() + + usable_roots = [text for text in (os.fspath(r) for r in roots) if text.strip() and "\x00" not in text] + if not usable_roots: + # A list whose entries are all blank (an empty or stray-comma upload-roots setting) configures + # no usable root, so report that rather than reporting the path as outside the roots. + raise UploadNoRootsError() + + if strict_roots and any(part == ".." for part in re.split(r"[\\/]", candidate)): + # An upload path is model output (the deployer supplies the roots, the model the path), so a + # ".." component is never legitimate, and it is where a lexical ".." collapse can disagree + # with symlink resolution. Refuse it outright. Scoped to strict_roots (uploads): the + # download-path check refuses a '..' path itself, before it calls this. + raise UploadRefusedError("file_upload path must not contain a '..' component") + + if strict_roots and not any(lexically_under(os.path.abspath(candidate), root) for root in usable_roots): + # Decided on the text first: a path that does not even claim to be under an upload root is refused without + # touching the filesystem, so the model cannot make the SDK stat or resolve a path of its choosing (an + # existence probe, an automounted share). A path that does claim to be under a root is still resolved and + # compared below, so a symlink inside the root cannot lead out of it. + raise UploadOutsideRootsError() + + resolved = realpath_lenient(candidate) + if names_network_or_device(str(resolved)): + # On Windows, resolving a link to a share has already connected to its host with the user's credentials. + # Screening the resolved path refuses the upload, but it cannot undo that connection. + raise UploadOutsideRootsError() + + for root_text in usable_roots: + try: + resolved_root = Path(os.path.realpath(root_text, strict=strict_roots)) + except OSError: + # strict_roots (upload roots): a root that does not exist grants + # nothing. Accepting a not-yet-existing root leniently would admit a path under it now and + # let the root be satisfied later by creating it as a symlink to somewhere outside the + # deployment. Refusing here closes that. Never raised when strict_roots is False: the + # download-path check tolerates a download_dir that does not exist yet. + continue + if resolved == resolved_root or PurePath(resolved_root) in PurePath(resolved).parents: + return str(resolved) + + raise UploadOutsideRootsError() + + +def lexically_under(candidate: str, directory: str) -> bool: + """Whether `candidate`, normalised as text, is `directory` (as written or as it resolves) or below it.""" + text = os.path.normcase(os.path.normpath(candidate)) + for base in {directory, str(realpath_lenient(directory))}: + base = os.path.normcase(os.path.normpath(base)) + if text == base or text.startswith(base.rstrip("/\\") + os.sep): + return True + return False + + +def download_dir_conflicts(download_dir: str, upload_roots: Sequence[str]) -> bool: + """Whether `download_dir` is inside (or contains) an upload root, which opens the download-then-upload + loop: a page-triggered download written into an upload root is readable back through + `file_upload`. Compared on the same resolved paths the upload check compares (symlinks + followed as far as the path exists), so a `download_dir` that is a symlink into an upload + root, or the other way round, is caught as well as the literal nesting.""" + literal = PurePath(os.path.normcase(download_dir)) + resolved = realpath_lenient(download_dir) + folded = PurePath(os.path.normcase(str(resolved))) + for root in upload_roots: + resolved_root = realpath_lenient(root) + pairs = ((literal, PurePath(os.path.normcase(root))), (folded, PurePath(os.path.normcase(str(resolved_root))))) + for d, r in pairs: + # a macOS disk usually ignores letter case and the Unicode form of a name + d, r = (PurePath(unicodedata.normalize("NFC", str(p).casefold())) for p in (d, r)) + if d == r or r in d.parents or d in r.parents: + return True + return False + + +class BetaLocalFilePolicy: + """The file policy the SDK ships: `file_upload` may read paths under `upload_roots` (symlinks + resolved, whole components compared, a `..` component refused outright) and may name the document + ids listed in `upload_document_ids` (none by default), and a `download_completed` path reaches + the model only with `expose_download_paths=True` and only when it lies inside `download_dir`. + + Keep the roots to one dedicated directory holding only the task's files, and put + `download_dir` outside every upload root and outside anything another tool can reach. The + driver chooses where downloads are written. This object only controls what the model may see. + """ + + def __init__( + self, + *, + upload_roots: Sequence[str | os.PathLike[str]] = (), + upload_document_ids: Iterable[str] = (), + download_dir: str | os.PathLike[str] | None = None, + expose_download_paths: bool = False, + ) -> None: + if isinstance(upload_roots, (str, bytes, os.PathLike)): + raise ToolsetConfigError("upload_roots must be a sequence of paths, not a single path") + if isinstance(upload_document_ids, (str, bytes)): + raise ToolsetConfigError("upload_document_ids must be a sequence of ids, not a single id") + + self._upload_document_ids = frozenset(upload_document_ids) # read once: a generator is spent after one pass + + roots = [os.fspath(root) for root in upload_roots] + if any(not root.strip() for root in roots): + # Anchoring an empty entry would silently make it the working directory. + raise ToolsetConfigError("upload_roots entries must not be empty") + + directory = None if download_dir is None else os.fspath(download_dir) + if directory is not None and not directory.strip(): + raise ToolsetConfigError("download_dir must not be empty") + + # Anchored once, here: re-resolving a relative root against the working directory at check + # time would move the containment boundary. + self._upload_roots: list[str] = [os.path.abspath(root) for root in roots] + self._download_dir: str | None = None if directory is None else os.path.abspath(directory) + self._expose = expose_download_paths is True # a truthy non-bool that got past the types exposes nothing + + if self._download_dir is not None and download_dir_conflicts(self._download_dir, self._upload_roots): + raise ToolsetConfigError( + f"download_dir {self._download_dir!r} must be outside every upload root, otherwise a page-triggered " + "download landing in an upload root can be read back through file_upload" + ) + + @property + def upload_roots(self) -> Sequence[str]: + return tuple(self._upload_roots) + + @property + def upload_document_ids(self) -> frozenset[str]: + return self._upload_document_ids + + @property + def download_dir(self) -> str | None: + return self._download_dir + + @property + def expose_download_paths(self) -> bool: + return self._expose + + def resolve_upload_paths(self, context: BetaURLContext, paths: Sequence[str]) -> list[str]: # noqa: ARG002 + return [beta_check_upload_path(path, self._upload_roots) for path in paths] + + def resolve_upload_documents(self, context: BetaURLContext, document_ids: Sequence[str]) -> list[str]: # noqa: ARG002 + for doc in document_ids: + if doc not in self._upload_document_ids: + # Named by neither id nor count: the refusal must not tell a steering page which ids exist. + raise UploadRefusedError("document not in the upload allowlist") + return list(document_ids) + + def is_path_visible(self, path: str) -> bool: + if not self._expose or self._download_dir is None: + return False + if not lexically_under(path, self._download_dir) or ".." in re.split(r"[\\/]", path): + # Decided on the text alone: a path that does not even claim to be under the download directory, or + # that has a ".." component, is never resolved, so a page cannot make the SDK stat a path of its choosing + # (an automounted share, an existence probe) by getting it into a download event or an error message. + return False + + try: + beta_check_upload_path(path, [self._download_dir], strict_roots=False) + except ToolError: + # Outside the quarantine directory (a symlink that leads out of it): the event still reaches the + # model, without the path. + return False + return True diff --git a/src/anthropic/resources/beta/agents/agents.py b/src/anthropic/resources/beta/agents/agents.py index f54f11698..a9faaa251 100644 --- a/src/anthropic/resources/beta/agents/agents.py +++ b/src/anthropic/resources/beta/agents/agents.py @@ -107,14 +107,13 @@ def create( metadata: Arbitrary key-value metadata. Maximum 16 pairs, keys up to 64 chars, values up to 512 chars. - multiagent: Multiagent orchestration configuration. Currently supports the `coordinator` - topology with a roster of 1-20 agents. + multiagent: Multiagent orchestration configuration. skills: Skills available to the agent. system: System prompt for the agent. - tools: Tool configurations available to the agent. Maximum of 128 tools across all + tools: Tool configurations available to the agent. Maximum of 256 tools across all toolsets allowed. betas: Optional header to specify the beta version(s) you want to use. @@ -290,7 +289,7 @@ def update( system: System prompt. Omit to preserve; send empty string or null to clear. tools: Tool configurations available to the agent. Full replacement. Omit to preserve; - send empty array or null to clear. Maximum of 128 tools across all toolsets + send empty array or null to clear. Maximum of 256 tools across all toolsets allowed. version: The agent's current version, used to prevent concurrent overwrites. Obtain this @@ -560,14 +559,13 @@ async def create( metadata: Arbitrary key-value metadata. Maximum 16 pairs, keys up to 64 chars, values up to 512 chars. - multiagent: Multiagent orchestration configuration. Currently supports the `coordinator` - topology with a roster of 1-20 agents. + multiagent: Multiagent orchestration configuration. skills: Skills available to the agent. system: System prompt for the agent. - tools: Tool configurations available to the agent. Maximum of 128 tools across all + tools: Tool configurations available to the agent. Maximum of 256 tools across all toolsets allowed. betas: Optional header to specify the beta version(s) you want to use. @@ -743,7 +741,7 @@ async def update( system: System prompt. Omit to preserve; send empty string or null to clear. tools: Tool configurations available to the agent. Full replacement. Omit to preserve; - send empty array or null to clear. Maximum of 128 tools across all toolsets + send empty array or null to clear. Maximum of 256 tools across all toolsets allowed. version: The agent's current version, used to prevent concurrent overwrites. Obtain this diff --git a/src/anthropic/resources/beta/messages/messages.py b/src/anthropic/resources/beta/messages/messages.py index cbe565753..4e6fb7baa 100644 --- a/src/anthropic/resources/beta/messages/messages.py +++ b/src/anthropic/resources/beta/messages/messages.py @@ -50,6 +50,7 @@ from ....lib.streaming import BetaMessageStreamManager, BetaAsyncMessageStreamManager from ...messages.messages import DEPRECATED_MODELS, MODELS_TO_WARN_WITH_THINKING_ENABLED from ....types.model_param import ModelParam +from ....lib.tools._toolsets import BetaRunnableToolset, BetaAsyncRunnableToolset from ....lib._parse._response import ResponseFormatT, parse_beta_response from ....lib._parse._transform import transform_schema from ....lib._stainless_helpers import ( @@ -61,13 +62,10 @@ ) from ....lib.tools._tool_params import BetaToolLike, to_tool_params as _to_tool_params from ....types.beta.beta_message import BetaMessage +from ....lib.tools._tool_dispatch import partition_sync_tools, partition_async_tools from ....lib.tools._beta_functions import ( - BetaFunctionTool, BetaRunnableTool, - BetaAsyncFunctionTool, BetaAsyncRunnableTool, - BetaBuiltinFunctionTool, - BetaAsyncBuiltinFunctionTool, ) from ....types.anthropic_beta_param import AnthropicBetaParam from ....types.beta.beta_message_param import BetaMessageParam @@ -91,6 +89,7 @@ if TYPE_CHECKING: from ...._client import Anthropic, AsyncAnthropic + __all__ = ["Messages", "AsyncMessages"] @@ -1383,7 +1382,7 @@ def tool_runner( max_tokens: int, messages: Iterable[BetaMessageParam], model: ModelParam, - tools: Iterable[BetaRunnableTool | BetaToolUnionParam], + tools: Iterable[BetaRunnableTool | BetaRunnableToolset | BetaToolUnionParam], cache_control: Optional[BetaCacheControlEphemeralParam] | Omit = omit, container: Optional[message_create_params.Container] | Omit = omit, context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, @@ -1421,7 +1420,7 @@ def tool_runner( max_tokens: int, messages: Iterable[BetaMessageParam], model: ModelParam, - tools: Iterable[BetaRunnableTool | BetaToolUnionParam], + tools: Iterable[BetaRunnableTool | BetaRunnableToolset | BetaToolUnionParam], cache_control: Optional[BetaCacheControlEphemeralParam] | Omit = omit, stream: Literal[True], max_iterations: int | Omit = omit, @@ -1460,7 +1459,7 @@ def tool_runner( max_tokens: int, messages: Iterable[BetaMessageParam], model: ModelParam, - tools: Iterable[BetaRunnableTool | BetaToolUnionParam], + tools: Iterable[BetaRunnableTool | BetaRunnableToolset | BetaToolUnionParam], stream: bool, max_iterations: int | Omit = omit, run_tools_eagerly: bool = False, @@ -1498,7 +1497,7 @@ def tool_runner( max_tokens: int, messages: Iterable[BetaMessageParam], model: ModelParam, - tools: Iterable[BetaRunnableTool | BetaToolUnionParam], + tools: Iterable[BetaRunnableTool | BetaRunnableToolset | BetaToolUnionParam], max_iterations: int | Omit = omit, run_tools_eagerly: bool = False, cache_control: Optional[BetaCacheControlEphemeralParam] | Omit = omit, @@ -1536,7 +1535,8 @@ def tool_runner( once you are done with the reply. This is optimistic: if the reply is interrupted or changes course, the tool may have already run, so use `runner.defer_tool_call()` to hold the calls that aren't safe to run twice. The calls run one at a time, and reading the stream waits while one - runs. Requires `stream=True`. + runs. A toolset call, such as a browser or computer action, runs early too, and its toolset's + `confirm` is still asked before it runs. Requires `stream=True`. This will be the default in a future version. """ @@ -1575,18 +1575,10 @@ def tool_runner( "anthropic-workspace-id": workspace_id, } ), - _stainless_helper_header(tools, messages), extra_headers or {}, ) - runnable_tools: list[BetaRunnableTool] = [] - raw_tools: list[BetaToolUnionParam] = [] - - for tool in tools: - if isinstance(tool, (BetaFunctionTool, BetaBuiltinFunctionTool)): - runnable_tools.append(tool) - else: - raw_tools.append(tool) + runnable_tools, raw_tools = partition_sync_tools(tools) params = cast( message_create_params.ParseMessageCreateParamsBase[ResponseFormatT], @@ -3353,7 +3345,7 @@ def tool_runner( max_tokens: int, messages: Iterable[BetaMessageParam], model: ModelParam, - tools: Iterable[BetaAsyncRunnableTool | BetaToolUnionParam], + tools: Iterable[BetaAsyncRunnableTool | BetaAsyncRunnableToolset | BetaToolUnionParam], cache_control: Optional[BetaCacheControlEphemeralParam] | Omit = omit, max_iterations: int | Omit = omit, container: Optional[message_create_params.Container] | Omit = omit, @@ -3391,7 +3383,7 @@ def tool_runner( max_tokens: int, messages: Iterable[BetaMessageParam], model: ModelParam, - tools: Iterable[BetaAsyncRunnableTool | BetaToolUnionParam], + tools: Iterable[BetaAsyncRunnableTool | BetaAsyncRunnableToolset | BetaToolUnionParam], stream: Literal[True], max_iterations: int | Omit = omit, run_tools_eagerly: bool = False, @@ -3430,7 +3422,7 @@ def tool_runner( max_tokens: int, messages: Iterable[BetaMessageParam], model: ModelParam, - tools: Iterable[BetaAsyncRunnableTool | BetaToolUnionParam], + tools: Iterable[BetaAsyncRunnableTool | BetaAsyncRunnableToolset | BetaToolUnionParam], stream: bool, max_iterations: int | Omit = omit, run_tools_eagerly: bool = False, @@ -3468,7 +3460,7 @@ def tool_runner( max_tokens: int, messages: Iterable[BetaMessageParam], model: ModelParam, - tools: Iterable[BetaAsyncRunnableTool | BetaToolUnionParam], + tools: Iterable[BetaAsyncRunnableTool | BetaAsyncRunnableToolset | BetaToolUnionParam], max_iterations: int | Omit = omit, run_tools_eagerly: bool = False, cache_control: Optional[BetaCacheControlEphemeralParam] | Omit = omit, @@ -3506,7 +3498,8 @@ def tool_runner( once you are done with the reply. This is optimistic: if the reply is interrupted or changes course, the tool may have already run, so use `runner.defer_tool_call()` to hold the calls that aren't safe to run twice. The calls run one at a time, and reading the stream waits while one - runs. Requires `stream=True`. + runs. A toolset call, such as a browser or computer action, runs early too, and its toolset's + `confirm` is still asked before it runs. Requires `stream=True`. This will be the default in a future version. """ @@ -3538,18 +3531,10 @@ def tool_runner( "anthropic-workspace-id": workspace_id, } ), - _stainless_helper_header(tools, messages), extra_headers or {}, ) - runnable_tools: list[BetaAsyncRunnableTool] = [] - raw_tools: list[BetaToolUnionParam] = [] - - for tool in tools: - if isinstance(tool, (BetaAsyncFunctionTool, BetaAsyncBuiltinFunctionTool)): - runnable_tools.append(tool) - else: - raw_tools.append(tool) + runnable_tools, raw_tools = partition_async_tools(tools) params = cast( message_create_params.ParseMessageCreateParamsBase[ResponseFormatT], diff --git a/src/anthropic/resources/beta/models.py b/src/anthropic/resources/beta/models.py index b033c9882..59fa0dfee 100644 --- a/src/anthropic/resources/beta/models.py +++ b/src/anthropic/resources/beta/models.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import List +from typing_extensions import Literal import httpx2 @@ -108,6 +109,7 @@ def list( *, after_id: str | Omit = omit, before_id: str | Omit = omit, + lifecycle: List[Literal["active", "deprecated", "retired"]] | Omit = omit, limit: int | Omit = omit, betas: List[AnthropicBetaParam] | Omit = omit, workspace_id: str | Omit = omit, @@ -131,6 +133,11 @@ def list( before_id: ID of the object to use as a cursor for pagination. When provided, returns the page of results immediately before this object. + lifecycle: Filter the list to models in any of the given lifecycle stages (`active`, + `deprecated`, or `retired`). Up to 3 values. When omitted, the list contains the + `active` and `deprecated` models; `retired` models appear only when `retired` is + requested explicitly. + limit: Number of items to return per page. Defaults to `20`. Ranges from `1` to `1000`. @@ -172,6 +179,7 @@ def list( query={ "after_id": after_id, "before_id": before_id, + "lifecycle": lifecycle, "limit": limit, }, ), @@ -265,6 +273,7 @@ def list( *, after_id: str | Omit = omit, before_id: str | Omit = omit, + lifecycle: List[Literal["active", "deprecated", "retired"]] | Omit = omit, limit: int | Omit = omit, betas: List[AnthropicBetaParam] | Omit = omit, workspace_id: str | Omit = omit, @@ -288,6 +297,11 @@ def list( before_id: ID of the object to use as a cursor for pagination. When provided, returns the page of results immediately before this object. + lifecycle: Filter the list to models in any of the given lifecycle stages (`active`, + `deprecated`, or `retired`). Up to 3 values. When omitted, the list contains the + `active` and `deprecated` models; `retired` models appear only when `retired` is + requested explicitly. + limit: Number of items to return per page. Defaults to `20`. Ranges from `1` to `1000`. @@ -329,6 +343,7 @@ def list( query={ "after_id": after_id, "before_id": before_id, + "lifecycle": lifecycle, "limit": limit, }, ), diff --git a/src/anthropic/resources/beta/organization/analytics/summaries.py b/src/anthropic/resources/beta/organization/analytics/summaries.py index 3f7ec0195..5c9570047 100644 --- a/src/anthropic/resources/beta/organization/analytics/summaries.py +++ b/src/anthropic/resources/beta/organization/analytics/summaries.py @@ -64,13 +64,12 @@ def list( Returns one entry per day from `starting_date` (inclusive) to `ending_date` (exclusive) in `data`, the same `data` / `next_page` envelope as the other analytics list endpoints; the series is currently returned in full, so - `next_page` is always null (`summaries` is a deprecated alias of `data`). Data - is typically available with a 1-day lag and may be revised by a few percent over - the following days: when `ending_date` is omitted it defaults to the most recent - available day + 1, so the last entry covers the most recent available day. The - series can be scoped to an RBAC group via `filter[]=rbac_group_id:{id}`. - Available to organizations on a Claude Enterprise plan. Requires an API key with - the `read:analytics` scope. + `next_page` is always null. Data is typically available with a 1-day lag and may + be revised by a few percent over the following days: when `ending_date` is + omitted it defaults to the most recent available day + 1, so the last entry + covers the most recent available day. The series can be scoped to an RBAC group + via `filter[]=rbac_group_id:{id}`. Available to organizations on a Claude + Enterprise plan. Requires an API key with the `read:analytics` scope. Args: starting_date: UTC date in YYYY-MM-DD format. Start of the date range (inclusive). Data is @@ -169,13 +168,12 @@ def list( Returns one entry per day from `starting_date` (inclusive) to `ending_date` (exclusive) in `data`, the same `data` / `next_page` envelope as the other analytics list endpoints; the series is currently returned in full, so - `next_page` is always null (`summaries` is a deprecated alias of `data`). Data - is typically available with a 1-day lag and may be revised by a few percent over - the following days: when `ending_date` is omitted it defaults to the most recent - available day + 1, so the last entry covers the most recent available day. The - series can be scoped to an RBAC group via `filter[]=rbac_group_id:{id}`. - Available to organizations on a Claude Enterprise plan. Requires an API key with - the `read:analytics` scope. + `next_page` is always null. Data is typically available with a 1-day lag and may + be revised by a few percent over the following days: when `ending_date` is + omitted it defaults to the most recent available day + 1, so the last entry + covers the most recent available day. The series can be scoped to an RBAC group + via `filter[]=rbac_group_id:{id}`. Available to organizations on a Claude + Enterprise plan. Requires an API key with the `read:analytics` scope. Args: starting_date: UTC date in YYYY-MM-DD format. Start of the date range (inclusive). Data is diff --git a/src/anthropic/resources/beta/organization/rate_limits.py b/src/anthropic/resources/beta/organization/rate_limits.py index c2a406cf4..9a574a21d 100644 --- a/src/anthropic/resources/beta/organization/rate_limits.py +++ b/src/anthropic/resources/beta/organization/rate_limits.py @@ -60,8 +60,8 @@ def list( List Messages API rate limits for your organization. Each entry corresponds to one rate-limit group (either a model family or an - API-surface category such as the Files API or Message Batches) and contains the - set of limiter values that apply to it. + API-surface category such as the Message Batches API or the web search tool) and + contains the set of limiter values that apply to it. When `limit` is omitted, every matching entry is returned in a single page; when `limit` truncates the result, follow `next_page` to fetch the remaining entries. @@ -146,8 +146,8 @@ def list( List Messages API rate limits for your organization. Each entry corresponds to one rate-limit group (either a model family or an - API-surface category such as the Files API or Message Batches) and contains the - set of limiter values that apply to it. + API-surface category such as the Message Batches API or the web search tool) and + contains the set of limiter values that apply to it. When `limit` is omitted, every matching entry is returned in a single page; when `limit` truncates the result, follow `next_page` to fetch the remaining entries. diff --git a/src/anthropic/resources/beta/organization/spend_limits/spend_limits.py b/src/anthropic/resources/beta/organization/spend_limits/spend_limits.py index b74fc1677..1ce8852f5 100644 --- a/src/anthropic/resources/beta/organization/spend_limits/spend_limits.py +++ b/src/anthropic/resources/beta/organization/spend_limits/spend_limits.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import List, Optional +from itertools import chain from typing_extensions import Literal import httpx2 @@ -155,9 +156,16 @@ def list( timeout: Override the client-level default timeout for this request, in seconds """ extra_headers = { - **strip_not_given({"anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else not_given}), + **strip_not_given( + { + "anthropic-beta": ",".join(chain((str(e) for e in betas), ["spend-limit-reads-2026-09-26"])) + if is_given(betas) + else not_given + } + ), **(extra_headers or {}), } + extra_headers = {"anthropic-beta": "spend-limit-reads-2026-09-26", **(extra_headers or {})} return self._get_api_list( "/v1/organizations/spend_limits?beta=true", page=SyncPageCursor[BetaSpendLimit], @@ -225,6 +233,7 @@ def set( amount: Optional[str], scope: spend_limit_set_params.Scope, period: BetaSpendLimitPeriod | Omit = omit, + betas: List[AnthropicBetaParam] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -254,6 +263,8 @@ def set( through the API is in an early access preview. To request access, contact your Anthropic account team. + betas: Optional header to specify the beta version(s) you want to use. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -262,6 +273,10 @@ def set( timeout: Override the client-level default timeout for this request, in seconds """ + extra_headers = { + **strip_not_given({"anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else not_given}), + **(extra_headers or {}), + } return self._post( "/v1/organizations/spend_limits?beta=true", body={ @@ -392,9 +407,16 @@ def list( timeout: Override the client-level default timeout for this request, in seconds """ extra_headers = { - **strip_not_given({"anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else not_given}), + **strip_not_given( + { + "anthropic-beta": ",".join(chain((str(e) for e in betas), ["spend-limit-reads-2026-09-26"])) + if is_given(betas) + else not_given + } + ), **(extra_headers or {}), } + extra_headers = {"anthropic-beta": "spend-limit-reads-2026-09-26", **(extra_headers or {})} return self._get_api_list( "/v1/organizations/spend_limits?beta=true", page=AsyncPageCursor[BetaSpendLimit], @@ -462,6 +484,7 @@ async def set( amount: Optional[str], scope: spend_limit_set_params.Scope, period: BetaSpendLimitPeriod | Omit = omit, + betas: List[AnthropicBetaParam] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -491,6 +514,8 @@ async def set( through the API is in an early access preview. To request access, contact your Anthropic account team. + betas: Optional header to specify the beta version(s) you want to use. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -499,6 +524,10 @@ async def set( timeout: Override the client-level default timeout for this request, in seconds """ + extra_headers = { + **strip_not_given({"anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else not_given}), + **(extra_headers or {}), + } return await self._post( "/v1/organizations/spend_limits?beta=true", body={ diff --git a/src/anthropic/resources/beta/organization/workspaces/workspaces.py b/src/anthropic/resources/beta/organization/workspaces/workspaces.py index 73b954f2e..d7d878096 100644 --- a/src/anthropic/resources/beta/organization/workspaces/workspaces.py +++ b/src/anthropic/resources/beta/organization/workspaces/workspaces.py @@ -268,6 +268,7 @@ def list( after_id: str | Omit = omit, before_id: str | Omit = omit, include_archived: bool | Omit = omit, + include_default: bool | Omit = omit, limit: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -288,6 +289,8 @@ def list( include_archived: Whether to include Workspaces that have been archived in the response + include_default: Whether to include the organization's default Workspace in the response + limit: Number of items to return per page. Defaults to `20`. Ranges from `1` to `1000`. @@ -312,6 +315,7 @@ def list( "after_id": after_id, "before_id": before_id, "include_archived": include_archived, + "include_default": include_default, "limit": limit, }, ), @@ -575,6 +579,7 @@ def list( after_id: str | Omit = omit, before_id: str | Omit = omit, include_archived: bool | Omit = omit, + include_default: bool | Omit = omit, limit: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -595,6 +600,8 @@ def list( include_archived: Whether to include Workspaces that have been archived in the response + include_default: Whether to include the organization's default Workspace in the response + limit: Number of items to return per page. Defaults to `20`. Ranges from `1` to `1000`. @@ -619,6 +626,7 @@ def list( "after_id": after_id, "before_id": before_id, "include_archived": include_archived, + "include_default": include_default, "limit": limit, }, ), diff --git a/src/anthropic/resources/models.py b/src/anthropic/resources/models.py index 8f7126a3e..4e46c6cf9 100644 --- a/src/anthropic/resources/models.py +++ b/src/anthropic/resources/models.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import List +from typing_extensions import Literal import httpx2 @@ -108,6 +109,7 @@ def list( *, after_id: str | Omit = omit, before_id: str | Omit = omit, + lifecycle: List[Literal["active", "deprecated", "retired"]] | Omit = omit, limit: int | Omit = omit, betas: List[AnthropicBetaParam] | Omit = omit, workspace_id: str | Omit = omit, @@ -131,6 +133,11 @@ def list( before_id: ID of the object to use as a cursor for pagination. When provided, returns the page of results immediately before this object. + lifecycle: Filter the list to models in any of the given lifecycle stages (`active`, + `deprecated`, or `retired`). Up to 3 values. When omitted, the list contains the + `active` and `deprecated` models; `retired` models appear only when `retired` is + requested explicitly. + limit: Number of items to return per page. Defaults to `20`. Ranges from `1` to `1000`. @@ -172,6 +179,7 @@ def list( query={ "after_id": after_id, "before_id": before_id, + "lifecycle": lifecycle, "limit": limit, }, ), @@ -265,6 +273,7 @@ def list( *, after_id: str | Omit = omit, before_id: str | Omit = omit, + lifecycle: List[Literal["active", "deprecated", "retired"]] | Omit = omit, limit: int | Omit = omit, betas: List[AnthropicBetaParam] | Omit = omit, workspace_id: str | Omit = omit, @@ -288,6 +297,11 @@ def list( before_id: ID of the object to use as a cursor for pagination. When provided, returns the page of results immediately before this object. + lifecycle: Filter the list to models in any of the given lifecycle stages (`active`, + `deprecated`, or `retired`). Up to 3 values. When omitted, the list contains the + `active` and `deprecated` models; `retired` models appear only when `retired` is + requested explicitly. + limit: Number of items to return per page. Defaults to `20`. Ranges from `1` to `1000`. @@ -329,6 +343,7 @@ def list( query={ "after_id": after_id, "before_id": before_id, + "lifecycle": lifecycle, "limit": limit, }, ), diff --git a/src/anthropic/resources/organization/rate_limits.py b/src/anthropic/resources/organization/rate_limits.py index aef3dceb6..b15c58f04 100644 --- a/src/anthropic/resources/organization/rate_limits.py +++ b/src/anthropic/resources/organization/rate_limits.py @@ -60,8 +60,8 @@ def list( List Messages API rate limits for your organization. Each entry corresponds to one rate-limit group (either a model family or an - API-surface category such as the Files API or Message Batches) and contains the - set of limiter values that apply to it. + API-surface category such as the Message Batches API or the web search tool) and + contains the set of limiter values that apply to it. When `limit` is omitted, every matching entry is returned in a single page; when `limit` truncates the result, follow `next_page` to fetch the remaining entries. @@ -146,8 +146,8 @@ def list( List Messages API rate limits for your organization. Each entry corresponds to one rate-limit group (either a model family or an - API-surface category such as the Files API or Message Batches) and contains the - set of limiter values that apply to it. + API-surface category such as the Message Batches API or the web search tool) and + contains the set of limiter values that apply to it. When `limit` is omitted, every matching entry is returned in a single page; when `limit` truncates the result, follow `next_page` to fetch the remaining entries. diff --git a/src/anthropic/resources/organization/workspaces/workspaces.py b/src/anthropic/resources/organization/workspaces/workspaces.py index 85782089c..d0d960ebc 100644 --- a/src/anthropic/resources/organization/workspaces/workspaces.py +++ b/src/anthropic/resources/organization/workspaces/workspaces.py @@ -260,6 +260,7 @@ def list( after_id: str | Omit = omit, before_id: str | Omit = omit, include_archived: bool | Omit = omit, + include_default: bool | Omit = omit, limit: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -280,6 +281,8 @@ def list( include_archived: Whether to include Workspaces that have been archived in the response + include_default: Whether to include the organization's default Workspace in the response + limit: Number of items to return per page. Defaults to `20`. Ranges from `1` to `1000`. @@ -304,6 +307,7 @@ def list( "after_id": after_id, "before_id": before_id, "include_archived": include_archived, + "include_default": include_default, "limit": limit, }, ), @@ -560,6 +564,7 @@ def list( after_id: str | Omit = omit, before_id: str | Omit = omit, include_archived: bool | Omit = omit, + include_default: bool | Omit = omit, limit: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -580,6 +585,8 @@ def list( include_archived: Whether to include Workspaces that have been archived in the response + include_default: Whether to include the organization's default Workspace in the response + limit: Number of items to return per page. Defaults to `20`. Ranges from `1` to `1000`. @@ -604,6 +611,7 @@ def list( "after_id": after_id, "before_id": before_id, "include_archived": include_archived, + "include_default": include_default, "limit": limit, }, ), diff --git a/src/anthropic/tools/__init__.py b/src/anthropic/tools/__init__.py index 9ad79d8d3..894350ecd 100644 --- a/src/anthropic/tools/__init__.py +++ b/src/anthropic/tools/__init__.py @@ -1 +1,48 @@ from .memory import * +from .browser import ( + BetaURLPolicy as BetaURLPolicy, + BetaFilePolicy as BetaFilePolicy, + BetaURLContext as BetaURLContext, + BetaToolConfigs as BetaToolConfigs, + TabMissingError as TabMissingError, + URLRefusedError as URLRefusedError, + BetaBrowserState as BetaBrowserState, + BetaAsyncURLPolicy as BetaAsyncURLPolicy, + BetaConfirmContext as BetaConfirmContext, + BetaToolsetContent as BetaToolsetContent, + UploadRefusedError as UploadRefusedError, + BetaConfirmCallable as BetaConfirmCallable, + BetaDialogDismissed as BetaDialogDismissed, + BetaLocalFilePolicy as BetaLocalFilePolicy, + BetaScreenshotResult as BetaScreenshotResult, + BetaNavigationRefused as BetaNavigationRefused, + BetaToolsetCallContext as BetaToolsetCallContext, + BetaBrowserMemberResult as BetaBrowserMemberResult, + BetaAsyncConfirmCallable as BetaAsyncConfirmCallable, + BetaBrowserNavigateResult as BetaBrowserNavigateResult, + BetaAbstractBrowserToolset20260801 as BetaAbstractBrowserToolset20260801, + BetaAsyncAbstractBrowserToolset20260801 as BetaAsyncAbstractBrowserToolset20260801, + beta_check_upload_path as beta_check_upload_path, +) +from .computer import ( + BetaComputerMemberResult as BetaComputerMemberResult, + BetaComputerConfirmContext as BetaComputerConfirmContext, + BetaComputerConfirmCallable as BetaComputerConfirmCallable, + BetaAsyncComputerConfirmCallable as BetaAsyncComputerConfirmCallable, + BetaComputerCursorPositionResult as BetaComputerCursorPositionResult, + BetaAbstractComputerToolset20260801 as BetaAbstractComputerToolset20260801, + BetaAsyncAbstractComputerToolset20260801 as BetaAsyncAbstractComputerToolset20260801, +) +from ..lib.tools._beta_functions import ToolError as ToolError +from ..lib.tools._toolsets._errors import ( + ToolsetUsageError as ToolsetUsageError, + ConfirmFailedError as ConfirmFailedError, + ToolsetClosedError as ToolsetClosedError, + ToolsetConfigError as ToolsetConfigError, + UnknownMemberError as UnknownMemberError, + DisabledMemberError as DisabledMemberError, + ConfirmDeclinedError as ConfirmDeclinedError, + ToolsetContractError as ToolsetContractError, + UnavailableMemberError as UnavailableMemberError, + InvalidMemberInputError as InvalidMemberInputError, +) diff --git a/src/anthropic/tools/browser.py b/src/anthropic/tools/browser.py new file mode 100644 index 000000000..1097fe54d --- /dev/null +++ b/src/anthropic/tools/browser.py @@ -0,0 +1,75 @@ +"""Browser toolset helpers for `browser_toolset_20260801`: the classes a driver subclasses, their +constructor option, context and result types, and the errors that belong to the browser toolset alone. `ToolError` +and the errors both toolsets raise are exported from `anthropic.tools`. The generated member inputs +(`BetaBrowserNavigateInput`, ... and their union `BetaBrowserMemberInput`) and wire types +(`BetaBrowserToolset20260801Param`, the `browser_state` block and its entries) are in `anthropic.types.beta`. +Documentation: +https://platform.claude.com/docs/en/agents-and-tools/tool-use/browser-use-tool (the tool) and +`browser-toolset.md` (this SDK's guide).""" + +from ..lib.tools._toolsets._base import BetaScreenshotResult as BetaScreenshotResult +from ..lib.tools._toolsets._hooks import ( + BetaURLPolicy as BetaURLPolicy, + BetaFilePolicy as BetaFilePolicy, + BetaURLContext as BetaURLContext, + BetaToolConfigs as BetaToolConfigs, + BetaAsyncURLPolicy as BetaAsyncURLPolicy, + BetaConfirmContext as BetaConfirmContext, + BetaConfirmCallable as BetaConfirmCallable, + BetaAsyncConfirmCallable as BetaAsyncConfirmCallable, +) +from ..lib.tools._toolsets._errors import ( + TabMissingError as TabMissingError, + URLRefusedError as URLRefusedError, + UploadRefusedError as UploadRefusedError, +) +from ..lib.tools._toolsets._browser import ( + BetaAbstractBrowserToolset20260801 as BetaAbstractBrowserToolset20260801, + BetaAsyncAbstractBrowserToolset20260801 as BetaAsyncAbstractBrowserToolset20260801, +) +from ..lib.tools._toolsets._results import ( + BetaBrowserState as BetaBrowserState, + BetaDialogDismissed as BetaDialogDismissed, + BetaNavigationRefused as BetaNavigationRefused, + BetaBrowserMemberResult as BetaBrowserMemberResult, + BetaBrowserNavigateResult as BetaBrowserNavigateResult, +) +from ..lib.tools._toolsets._runnable import ( + BetaToolsetContent as BetaToolsetContent, + BetaToolsetCallContext as BetaToolsetCallContext, +) +from ..lib.tools._toolsets._security import ( + BetaLocalFilePolicy as BetaLocalFilePolicy, + beta_check_upload_path as beta_check_upload_path, +) + +__all__ = [ + # the classes a driver subclasses + "BetaAbstractBrowserToolset20260801", + "BetaAsyncAbstractBrowserToolset20260801", + # constructor options and hooks + "BetaConfirmContext", + "BetaToolConfigs", + "BetaConfirmCallable", + "BetaAsyncConfirmCallable", + "BetaURLContext", + "BetaURLPolicy", + "BetaAsyncURLPolicy", + "BetaFilePolicy", + "BetaLocalFilePolicy", + "beta_check_upload_path", + # call context and content + "BetaToolsetCallContext", + "BetaToolsetContent", + # results and browser state + "BetaBrowserNavigateResult", + "BetaScreenshotResult", + "BetaNavigationRefused", + "BetaDialogDismissed", + "BetaBrowserState", + "BetaBrowserMemberResult", + # errors that belong to the browser toolset alone + "URLRefusedError", + "TabMissingError", + "UploadRefusedError", +] diff --git a/src/anthropic/tools/computer.py b/src/anthropic/tools/computer.py new file mode 100644 index 000000000..7f5d9e032 --- /dev/null +++ b/src/anthropic/tools/computer.py @@ -0,0 +1,44 @@ +"""Computer toolset helpers for `computer_toolset_20260801`: the classes a driver subclasses, their +constructor options, and the context and result types. `ToolError` and the errors a toolset raises are exported +from `anthropic.tools`. + +Documentation: https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool (the tool) and +`computer-toolset.md` (this SDK's guide).""" + +from ..lib.tools._toolsets._base import ( + BetaToolConfigs as BetaToolConfigs, + BetaScreenshotResult as BetaScreenshotResult, +) +from ..lib.tools._toolsets._computer import ( + BetaAbstractComputerToolset20260801 as BetaAbstractComputerToolset20260801, + BetaAsyncAbstractComputerToolset20260801 as BetaAsyncAbstractComputerToolset20260801, +) +from ..lib.tools._toolsets._runnable import ( + BetaToolsetContent as BetaToolsetContent, + BetaToolsetCallContext as BetaToolsetCallContext, +) +from ..lib.tools._toolsets._computer_results import ( + BetaComputerMemberResult as BetaComputerMemberResult, + BetaComputerConfirmContext as BetaComputerConfirmContext, + BetaComputerConfirmCallable as BetaComputerConfirmCallable, + BetaAsyncComputerConfirmCallable as BetaAsyncComputerConfirmCallable, + BetaComputerCursorPositionResult as BetaComputerCursorPositionResult, +) + +__all__ = [ + # the classes a driver subclasses + "BetaAbstractComputerToolset20260801", + "BetaAsyncAbstractComputerToolset20260801", + # constructor options and hooks + "BetaComputerConfirmContext", + "BetaToolConfigs", + "BetaComputerConfirmCallable", + "BetaAsyncComputerConfirmCallable", + # call context and content + "BetaToolsetCallContext", + "BetaToolsetContent", + # results + "BetaScreenshotResult", + "BetaComputerCursorPositionResult", + "BetaComputerMemberResult", +] diff --git a/src/anthropic/types/__init__.py b/src/anthropic/types/__init__.py index c6ef05f2d..36ef5dced 100644 --- a/src/anthropic/types/__init__.py +++ b/src/anthropic/types/__init__.py @@ -21,6 +21,7 @@ from .container import Container as Container from .beta_error import BetaError as BetaError from .model_info import ModelInfo as ModelInfo +from .model_line import ModelLine as ModelLine from .text_block import TextBlock as TextBlock from .text_delta import TextDelta as TextDelta from .tool_param import ToolParam as ToolParam @@ -53,6 +54,7 @@ from .citations_delta import CitationsDelta as CitationsDelta from .container_skill import ContainerSkill as ContainerSkill from .signature_delta import SignatureDelta as SignatureDelta +from .tool_use_caller import ToolUseCaller as ToolUseCaller from .web_fetch_block import WebFetchBlock as WebFetchBlock from .citations_config import CitationsConfig as CitationsConfig from .container_params import ContainerParams as ContainerParams @@ -61,6 +63,7 @@ from .text_block_param import TextBlockParam as TextBlockParam from .tool_union_param import ToolUnionParam as ToolUnionParam from .base64_pdf_source import Base64PDFSource as Base64PDFSource +from .browser_key_input import BrowserKeyInput as BrowserKeyInput from .cache_miss_reason import CacheMissReason as CacheMissReason from .diagnostics_param import DiagnosticsParam as DiagnosticsParam from .effort_capability import EffortCapability as EffortCapability @@ -73,12 +76,26 @@ from .skill_list_params import SkillListParams as SkillListParams from .tool_choice_param import ToolChoiceParam as ToolChoiceParam from .beta_billing_error import BetaBillingError as BetaBillingError +from .browser_find_input import BrowserFindInput as BrowserFindInput +from .browser_ref_target import BrowserRefTarget as BrowserRefTarget +from .browser_type_input import BrowserTypeInput as BrowserTypeInput +from .browser_wait_input import BrowserWaitInput as BrowserWaitInput +from .browser_zoom_input import BrowserZoomInput as BrowserZoomInput from .capability_support import CapabilitySupport as CapabilitySupport +from .computer_key_input import ComputerKeyInput as ComputerKeyInput from .file_upload_params import FileUploadParams as FileUploadParams from .message_stop_event import MessageStopEvent as MessageStopEvent from .model_capabilities import ModelCapabilities as ModelCapabilities from .server_tool_caller import ServerToolCaller as ServerToolCaller from .beta_error_response import BetaErrorResponse as BetaErrorResponse +from .browser_hover_input import BrowserHoverInput as BrowserHoverInput +from .browser_member_name import ( + BROWSER_MEMBER_NAME_VALUES as BROWSER_MEMBER_NAME_VALUES, + BrowserMemberName as BrowserMemberName, +) +from .computer_type_input import ComputerTypeInput as ComputerTypeInput +from .computer_wait_input import ComputerWaitInput as ComputerWaitInput +from .computer_zoom_input import ComputerZoomInput as ComputerZoomInput from .content_block_param import ContentBlockParam as ContentBlockParam from .direct_caller_param import DirectCallerParam as DirectCallerParam from .message_delta_event import MessageDeltaEvent as MessageDeltaEvent @@ -92,6 +109,13 @@ from .anthropic_beta_param import AnthropicBetaParam as AnthropicBetaParam from .beta_monetary_amount import BetaMonetaryAmount as BetaMonetaryAmount from .beta_not_found_error import BetaNotFoundError as BetaNotFoundError +from .browser_click_target import BrowserClickTarget as BrowserClickTarget +from .browser_member_input import BrowserMemberInput as BrowserMemberInput +from .browser_scroll_input import BrowserScrollInput as BrowserScrollInput +from .computer_member_name import ( + COMPUTER_MEMBER_NAME_VALUES as COMPUTER_MEMBER_NAME_VALUES, + ComputerMemberName as ComputerMemberName, +) from .document_block_param import DocumentBlockParam as DocumentBlockParam from .message_stream_event import MessageStreamEvent as MessageStreamEvent from .message_tokens_count import MessageTokensCount as MessageTokensCount @@ -103,12 +127,18 @@ from .beta_overloaded_error import BetaOverloadedError as BetaOverloadedError from .beta_permission_error import BetaPermissionError as BetaPermissionError from .beta_rate_limit_error import BetaRateLimitError as BetaRateLimitError +from .browser_new_tab_input import BrowserNewTabInput as BrowserNewTabInput +from .computer_member_input import ComputerMemberInput as ComputerMemberInput +from .computer_scroll_input import ComputerScrollInput as ComputerScrollInput from .message_create_params import MessageCreateParams as MessageCreateParams from .output_tokens_details import OutputTokensDetails as OutputTokensDetails from .server_tool_use_block import ServerToolUseBlock as ServerToolUseBlock from .thinking_config_param import ThinkingConfigParam as ThinkingConfigParam from .tool_choice_any_param import ToolChoiceAnyParam as ToolChoiceAnyParam from .web_fetch_block_param import WebFetchBlockParam as WebFetchBlockParam +from .browser_hold_key_input import BrowserHoldKeyInput as BrowserHoldKeyInput +from .browser_navigate_input import BrowserNavigateInput as BrowserNavigateInput +from .browser_tool_use_block import BrowserToolUseBlock as BrowserToolUseBlock from .cache_miss_unavailable import CacheMissUnavailable as CacheMissUnavailable from .citation_char_location import CitationCharLocation as CitationCharLocation from .citation_page_location import CitationPageLocation as CitationPageLocation @@ -118,17 +148,33 @@ from .tool_choice_auto_param import ToolChoiceAutoParam as ToolChoiceAutoParam from .tool_choice_none_param import ToolChoiceNoneParam as ToolChoiceNoneParam from .tool_choice_tool_param import ToolChoiceToolParam as ToolChoiceToolParam +from .toolset_tool_use_block import ToolsetToolUseBlock as ToolsetToolUseBlock from .url_image_source_param import URLImageSourceParam as URLImageSourceParam from .base64_pdf_source_param import Base64PDFSourceParam as Base64PDFSourceParam +from .browser_close_tab_input import BrowserCloseTabInput as BrowserCloseTabInput +from .browser_list_tabs_input import BrowserListTabsInput as BrowserListTabsInput +from .browser_read_page_input import BrowserReadPageInput as BrowserReadPageInput +from .browser_scroll_to_input import BrowserScrollToInput as BrowserScrollToInput +from .computer_hold_key_input import ComputerHoldKeyInput as ComputerHoldKeyInput +from .computer_tool_use_block import ComputerToolUseBlock as ComputerToolUseBlock from .file_image_source_param import FileImageSourceParam as FileImageSourceParam from .plain_text_source_param import PlainTextSourceParam as PlainTextSourceParam from .raw_content_block_delta import RawContentBlockDelta as RawContentBlockDelta from .raw_message_delta_event import RawMessageDeltaEvent as RawMessageDeltaEvent from .raw_message_start_event import RawMessageStartEvent as RawMessageStartEvent from .redacted_thinking_block import RedactedThinkingBlock as RedactedThinkingBlock +from .server_tools_capability import ServerToolsCapability as ServerToolsCapability from .tool_result_block_param import ToolResultBlockParam as ToolResultBlockParam from .web_search_result_block import WebSearchResultBlock as WebSearchResultBlock +from .browser_form_input_input import BrowserFormInputInput as BrowserFormInputInput +from .browser_form_input_value import BrowserFormInputValue as BrowserFormInputValue from .browser_key_config_param import BrowserKeyConfigParam as BrowserKeyConfigParam +from .browser_left_click_input import BrowserLeftClickInput as BrowserLeftClickInput +from .browser_mouse_move_input import BrowserMouseMoveInput as BrowserMouseMoveInput +from .browser_read_page_filter import BrowserReadPageFilter as BrowserReadPageFilter +from .browser_screenshot_input import BrowserScreenshotInput as BrowserScreenshotInput +from .browser_scroll_direction import BrowserScrollDirection as BrowserScrollDirection +from .browser_switch_tab_input import BrowserSwitchTabInput as BrowserSwitchTabInput from .cache_miss_model_changed import CacheMissModelChanged as CacheMissModelChanged from .cache_miss_tools_changed import CacheMissToolsChanged as CacheMissToolsChanged from .content_block_stop_event import ContentBlockStopEvent as ContentBlockStopEvent @@ -138,21 +184,35 @@ from .tool_bash_20250124_param import ToolBash20250124Param as ToolBash20250124Param from .base64_image_source_param import Base64ImageSourceParam as Base64ImageSourceParam from .beta_authentication_error import BetaAuthenticationError as BetaAuthenticationError +from .browser_coordinate_target import BrowserCoordinateTarget as BrowserCoordinateTarget +from .browser_file_upload_input import BrowserFileUploadInput as BrowserFileUploadInput from .browser_find_config_param import BrowserFindConfigParam as BrowserFindConfigParam +from .browser_right_click_input import BrowserRightClickInput as BrowserRightClickInput from .browser_state_block_param import BrowserStateBlockParam as BrowserStateBlockParam from .browser_type_config_param import BrowserTypeConfigParam as BrowserTypeConfigParam from .browser_wait_config_param import BrowserWaitConfigParam as BrowserWaitConfigParam from .browser_zoom_config_param import BrowserZoomConfigParam as BrowserZoomConfigParam from .cache_miss_system_changed import CacheMissSystemChanged as CacheMissSystemChanged from .computer_key_config_param import ComputerKeyConfigParam as ComputerKeyConfigParam +from .computer_left_click_input import ComputerLeftClickInput as ComputerLeftClickInput +from .computer_mouse_move_input import ComputerMouseMoveInput as ComputerMouseMoveInput +from .computer_screenshot_input import ComputerScreenshotInput as ComputerScreenshotInput +from .computer_scroll_direction import ComputerScrollDirection as ComputerScrollDirection from .content_block_delta_event import ContentBlockDeltaEvent as ContentBlockDeltaEvent from .content_block_start_event import ContentBlockStartEvent as ContentBlockStartEvent from .search_result_block_param import SearchResultBlockParam as SearchResultBlockParam from .beta_gateway_timeout_error import BetaGatewayTimeoutError as BetaGatewayTimeoutError from .beta_invalid_request_error import BetaInvalidRequestError as BetaInvalidRequestError from .beta_monetary_amount_param import BetaMonetaryAmountParam as BetaMonetaryAmountParam +from .browser_double_click_input import BrowserDoubleClickInput as BrowserDoubleClickInput from .browser_hover_config_param import BrowserHoverConfigParam as BrowserHoverConfigParam +from .browser_key_tool_use_block import BrowserKeyToolUseBlock as BrowserKeyToolUseBlock +from .browser_middle_click_input import BrowserMiddleClickInput as BrowserMiddleClickInput +from .browser_read_console_input import BrowserReadConsoleInput as BrowserReadConsoleInput +from .browser_read_network_input import BrowserReadNetworkInput as BrowserReadNetworkInput from .browser_state_change_param import BrowserStateChangeParam as BrowserStateChangeParam +from .browser_triple_click_input import BrowserTripleClickInput as BrowserTripleClickInput +from .computer_right_click_input import ComputerRightClickInput as ComputerRightClickInput from .computer_type_config_param import ComputerTypeConfigParam as ComputerTypeConfigParam from .computer_wait_config_param import ComputerWaitConfigParam as ComputerWaitConfigParam from .computer_zoom_config_param import ComputerZoomConfigParam as ComputerZoomConfigParam @@ -160,26 +220,45 @@ from .file_document_source_param import FileDocumentSourceParam as FileDocumentSourceParam from .memory_tool_20250818_param import MemoryTool20250818Param as MemoryTool20250818Param from .tool_reference_block_param import ToolReferenceBlockParam as ToolReferenceBlockParam +from .browser_find_tool_use_block import BrowserFindToolUseBlock as BrowserFindToolUseBlock +from .browser_get_page_text_input import BrowserGetPageTextInput as BrowserGetPageTextInput +from .browser_left_mouse_up_input import BrowserLeftMouseUpInput as BrowserLeftMouseUpInput from .browser_scroll_config_param import BrowserScrollConfigParam as BrowserScrollConfigParam +from .browser_type_tool_use_block import BrowserTypeToolUseBlock as BrowserTypeToolUseBlock +from .browser_wait_tool_use_block import BrowserWaitToolUseBlock as BrowserWaitToolUseBlock +from .browser_zoom_tool_use_block import BrowserZoomToolUseBlock as BrowserZoomToolUseBlock from .cache_miss_messages_changed import CacheMissMessagesChanged as CacheMissMessagesChanged from .code_execution_output_block import CodeExecutionOutputBlock as CodeExecutionOutputBlock from .code_execution_result_block import CodeExecutionResultBlock as CodeExecutionResultBlock +from .computer_double_click_input import ComputerDoubleClickInput as ComputerDoubleClickInput +from .computer_key_tool_use_block import ComputerKeyToolUseBlock as ComputerKeyToolUseBlock +from .computer_middle_click_input import ComputerMiddleClickInput as ComputerMiddleClickInput +from .computer_triple_click_input import ComputerTripleClickInput as ComputerTripleClickInput from .image_transformations_param import ImageTransformationsParam as ImageTransformationsParam from .message_count_tokens_params import MessageCountTokensParams as MessageCountTokensParams from .server_tool_caller_20260120 import ServerToolCaller20260120 as ServerToolCaller20260120 from .server_tool_use_block_param import ServerToolUseBlockParam as ServerToolUseBlockParam from .web_fetch_tool_result_block import WebFetchToolResultBlock as WebFetchToolResultBlock from .web_fetch_url_sources_param import WebFetchURLSourcesParam as WebFetchURLSourcesParam +from .browser_hover_tool_use_block import BrowserHoverToolUseBlock as BrowserHoverToolUseBlock from .browser_new_tab_config_param import BrowserNewTabConfigParam as BrowserNewTabConfigParam from .citation_char_location_param import CitationCharLocationParam as CitationCharLocationParam from .citation_page_location_param import CitationPageLocationParam as CitationPageLocationParam +from .computer_left_mouse_up_input import ComputerLeftMouseUpInput as ComputerLeftMouseUpInput from .computer_scroll_config_param import ComputerScrollConfigParam as ComputerScrollConfigParam +from .computer_type_tool_use_block import ComputerTypeToolUseBlock as ComputerTypeToolUseBlock +from .computer_wait_tool_use_block import ComputerWaitToolUseBlock as ComputerWaitToolUseBlock +from .computer_zoom_tool_use_block import ComputerZoomToolUseBlock as ComputerZoomToolUseBlock from .container_upload_block_param import ContainerUploadBlockParam as ContainerUploadBlockParam from .raw_content_block_stop_event import RawContentBlockStopEvent as RawContentBlockStopEvent from .web_search_tool_result_block import WebSearchToolResultBlock as WebSearchToolResultBlock from .web_search_tool_result_error import WebSearchToolResultError as WebSearchToolResultError from .browser_hold_key_config_param import BrowserHoldKeyConfigParam as BrowserHoldKeyConfigParam +from .browser_javascript_exec_input import BrowserJavascriptExecInput as BrowserJavascriptExecInput +from .browser_left_click_drag_input import BrowserLeftClickDragInput as BrowserLeftClickDragInput +from .browser_left_mouse_down_input import BrowserLeftMouseDownInput as BrowserLeftMouseDownInput from .browser_navigate_config_param import BrowserNavigateConfigParam as BrowserNavigateConfigParam +from .browser_scroll_tool_use_block import BrowserScrollToolUseBlock as BrowserScrollToolUseBlock from .browser_state_tab_entry_param import BrowserStateTabEntryParam as BrowserStateTabEntryParam from .browser_toolset_configs_param import BrowserToolsetConfigsParam as BrowserToolsetConfigsParam from .cache_control_ephemeral_param import CacheControlEphemeralParam as CacheControlEphemeralParam @@ -187,6 +266,7 @@ from .raw_content_block_delta_event import RawContentBlockDeltaEvent as RawContentBlockDeltaEvent from .raw_content_block_start_event import RawContentBlockStartEvent as RawContentBlockStartEvent from .redacted_thinking_block_param import RedactedThinkingBlockParam as RedactedThinkingBlockParam +from .response_tool_use_block_union import ResponseToolUseBlockUnion as ResponseToolUseBlockUnion from .thinking_config_enabled_param import ThinkingConfigEnabledParam as ThinkingConfigEnabledParam from .tool_search_tool_result_block import ToolSearchToolResultBlock as ToolSearchToolResultBlock from .tool_search_tool_result_error import ToolSearchToolResultError as ToolSearchToolResultError @@ -197,10 +277,15 @@ from .web_search_result_block_param import WebSearchResultBlockParam as WebSearchResultBlockParam from .browser_close_tab_config_param import BrowserCloseTabConfigParam as BrowserCloseTabConfigParam from .browser_list_tabs_config_param import BrowserListTabsConfigParam as BrowserListTabsConfigParam +from .browser_new_tab_tool_use_block import BrowserNewTabToolUseBlock as BrowserNewTabToolUseBlock from .browser_read_page_config_param import BrowserReadPageConfigParam as BrowserReadPageConfigParam from .browser_scroll_to_config_param import BrowserScrollToConfigParam as BrowserScrollToConfigParam from .browser_toolset_20260801_param import BrowserToolset20260801Param as BrowserToolset20260801Param +from .computer_cursor_position_input import ComputerCursorPositionInput as ComputerCursorPositionInput from .computer_hold_key_config_param import ComputerHoldKeyConfigParam as ComputerHoldKeyConfigParam +from .computer_left_click_drag_input import ComputerLeftClickDragInput as ComputerLeftClickDragInput +from .computer_left_mouse_down_input import ComputerLeftMouseDownInput as ComputerLeftMouseDownInput +from .computer_scroll_tool_use_block import ComputerScrollToolUseBlock as ComputerScrollToolUseBlock from .computer_toolset_configs_param import ComputerToolsetConfigsParam as ComputerToolsetConfigsParam from .thinking_config_adaptive_param import ThinkingConfigAdaptiveParam as ThinkingConfigAdaptiveParam from .thinking_config_disabled_param import ThinkingConfigDisabledParam as ThinkingConfigDisabledParam @@ -209,13 +294,16 @@ from .web_search_tool_20260209_param import WebSearchTool20260209Param as WebSearchTool20260209Param from .web_search_tool_20260318_param import WebSearchTool20260318Param as WebSearchTool20260318Param from .browser_form_input_config_param import BrowserFormInputConfigParam as BrowserFormInputConfigParam +from .browser_hold_key_tool_use_block import BrowserHoldKeyToolUseBlock as BrowserHoldKeyToolUseBlock from .browser_left_click_config_param import BrowserLeftClickConfigParam as BrowserLeftClickConfigParam from .browser_mouse_move_config_param import BrowserMouseMoveConfigParam as BrowserMouseMoveConfigParam +from .browser_navigate_tool_use_block import BrowserNavigateToolUseBlock as BrowserNavigateToolUseBlock from .browser_screenshot_config_param import BrowserScreenshotConfigParam as BrowserScreenshotConfigParam from .browser_switch_tab_config_param import BrowserSwitchTabConfigParam as BrowserSwitchTabConfigParam from .citation_content_block_location import CitationContentBlockLocation as CitationContentBlockLocation from .computer_toolset_20260801_param import ComputerToolset20260801Param as ComputerToolset20260801Param from .message_count_tokens_tool_param import MessageCountTokensToolParam as MessageCountTokensToolParam +from .response_browser_tool_use_block import ResponseBrowserToolUseBlock as ResponseBrowserToolUseBlock from .tool_text_editor_20250124_param import ToolTextEditor20250124Param as ToolTextEditor20250124Param from .tool_text_editor_20250429_param import ToolTextEditor20250429Param as ToolTextEditor20250429Param from .tool_text_editor_20250728_param import ToolTextEditor20250728Param as ToolTextEditor20250728Param @@ -223,19 +311,30 @@ from .web_fetch_url_source_only_param import WebFetchURLSourceOnlyParam as WebFetchURLSourceOnlyParam from .bash_code_execution_output_block import BashCodeExecutionOutputBlock as BashCodeExecutionOutputBlock from .bash_code_execution_result_block import BashCodeExecutionResultBlock as BashCodeExecutionResultBlock +from .browser_close_tab_tool_use_block import BrowserCloseTabToolUseBlock as BrowserCloseTabToolUseBlock from .browser_file_upload_config_param import BrowserFileUploadConfigParam as BrowserFileUploadConfigParam +from .browser_list_tabs_tool_use_block import BrowserListTabsToolUseBlock as BrowserListTabsToolUseBlock +from .browser_read_page_tool_use_block import BrowserReadPageToolUseBlock as BrowserReadPageToolUseBlock from .browser_right_click_config_param import BrowserRightClickConfigParam as BrowserRightClickConfigParam +from .browser_scroll_to_tool_use_block import BrowserScrollToToolUseBlock as BrowserScrollToToolUseBlock from .citations_search_result_location import CitationsSearchResultLocation as CitationsSearchResultLocation from .code_execution_tool_result_block import CodeExecutionToolResultBlock as CodeExecutionToolResultBlock from .code_execution_tool_result_error import CodeExecutionToolResultError as CodeExecutionToolResultError +from .computer_hold_key_tool_use_block import ComputerHoldKeyToolUseBlock as ComputerHoldKeyToolUseBlock from .computer_left_click_config_param import ComputerLeftClickConfigParam as ComputerLeftClickConfigParam from .computer_mouse_move_config_param import ComputerMouseMoveConfigParam as ComputerMouseMoveConfigParam from .computer_screenshot_config_param import ComputerScreenshotConfigParam as ComputerScreenshotConfigParam +from .response_computer_tool_use_block import ResponseComputerToolUseBlock as ResponseComputerToolUseBlock from .web_fetch_tool_result_error_code import WebFetchToolResultErrorCode as WebFetchToolResultErrorCode from .browser_double_click_config_param import BrowserDoubleClickConfigParam as BrowserDoubleClickConfigParam +from .browser_form_input_tool_use_block import BrowserFormInputToolUseBlock as BrowserFormInputToolUseBlock +from .browser_left_click_tool_use_block import BrowserLeftClickToolUseBlock as BrowserLeftClickToolUseBlock from .browser_middle_click_config_param import BrowserMiddleClickConfigParam as BrowserMiddleClickConfigParam +from .browser_mouse_move_tool_use_block import BrowserMouseMoveToolUseBlock as BrowserMouseMoveToolUseBlock from .browser_read_console_config_param import BrowserReadConsoleConfigParam as BrowserReadConsoleConfigParam from .browser_read_network_config_param import BrowserReadNetworkConfigParam as BrowserReadNetworkConfigParam +from .browser_screenshot_tool_use_block import BrowserScreenshotToolUseBlock as BrowserScreenshotToolUseBlock +from .browser_switch_tab_tool_use_block import BrowserSwitchTabToolUseBlock as BrowserSwitchTabToolUseBlock from .browser_triple_click_config_param import BrowserTripleClickConfigParam as BrowserTripleClickConfigParam from .code_execution_output_block_param import CodeExecutionOutputBlockParam as CodeExecutionOutputBlockParam from .code_execution_result_block_param import CodeExecutionResultBlockParam as CodeExecutionResultBlockParam @@ -245,27 +344,49 @@ from .web_fetch_tool_result_error_block import WebFetchToolResultErrorBlock as WebFetchToolResultErrorBlock from .web_fetch_url_source_except_param import WebFetchURLSourceExceptParam as WebFetchURLSourceExceptParam from .web_search_tool_result_error_code import WebSearchToolResultErrorCode as WebSearchToolResultErrorCode +from .browser_file_upload_tool_use_block import BrowserFileUploadToolUseBlock as BrowserFileUploadToolUseBlock from .browser_get_page_text_config_param import BrowserGetPageTextConfigParam as BrowserGetPageTextConfigParam from .browser_left_mouse_up_config_param import BrowserLeftMouseUpConfigParam as BrowserLeftMouseUpConfigParam +from .browser_right_click_tool_use_block import BrowserRightClickToolUseBlock as BrowserRightClickToolUseBlock from .code_execution_tool_20250522_param import CodeExecutionTool20250522Param as CodeExecutionTool20250522Param from .code_execution_tool_20250825_param import CodeExecutionTool20250825Param as CodeExecutionTool20250825Param from .code_execution_tool_20260120_param import CodeExecutionTool20260120Param as CodeExecutionTool20260120Param from .code_execution_tool_20260521_param import CodeExecutionTool20260521Param as CodeExecutionTool20260521Param from .computer_double_click_config_param import ComputerDoubleClickConfigParam as ComputerDoubleClickConfigParam +from .computer_left_click_tool_use_block import ComputerLeftClickToolUseBlock as ComputerLeftClickToolUseBlock from .computer_middle_click_config_param import ComputerMiddleClickConfigParam as ComputerMiddleClickConfigParam +from .computer_mouse_move_tool_use_block import ComputerMouseMoveToolUseBlock as ComputerMouseMoveToolUseBlock +from .computer_screenshot_tool_use_block import ComputerScreenshotToolUseBlock as ComputerScreenshotToolUseBlock from .computer_triple_click_config_param import ComputerTripleClickConfigParam as ComputerTripleClickConfigParam from .content_block_source_content_param import ContentBlockSourceContentParam as ContentBlockSourceContentParam from .tool_search_tool_result_error_code import ToolSearchToolResultErrorCode as ToolSearchToolResultErrorCode from .web_search_tool_result_block_param import WebSearchToolResultBlockParam as WebSearchToolResultBlockParam +from .browser_double_click_tool_use_block import BrowserDoubleClickToolUseBlock as BrowserDoubleClickToolUseBlock +from .browser_middle_click_tool_use_block import BrowserMiddleClickToolUseBlock as BrowserMiddleClickToolUseBlock +from .browser_read_console_tool_use_block import BrowserReadConsoleToolUseBlock as BrowserReadConsoleToolUseBlock +from .browser_read_network_tool_use_block import BrowserReadNetworkToolUseBlock as BrowserReadNetworkToolUseBlock +from .browser_triple_click_tool_use_block import BrowserTripleClickToolUseBlock as BrowserTripleClickToolUseBlock from .computer_left_mouse_up_config_param import ComputerLeftMouseUpConfigParam as ComputerLeftMouseUpConfigParam +from .computer_right_click_tool_use_block import ComputerRightClickToolUseBlock as ComputerRightClickToolUseBlock +from .response_browser_key_tool_use_block import ResponseBrowserKeyToolUseBlock as ResponseBrowserKeyToolUseBlock from .thinking_config_between_tools_param import ThinkingConfigBetweenToolsParam as ThinkingConfigBetweenToolsParam from .tool_search_tool_result_block_param import ToolSearchToolResultBlockParam as ToolSearchToolResultBlockParam from .tool_search_tool_result_error_param import ToolSearchToolResultErrorParam as ToolSearchToolResultErrorParam from .web_search_tool_request_error_param import WebSearchToolRequestErrorParam as WebSearchToolRequestErrorParam +from .browser_get_page_text_tool_use_block import BrowserGetPageTextToolUseBlock as BrowserGetPageTextToolUseBlock from .browser_javascript_exec_config_param import BrowserJavascriptExecConfigParam as BrowserJavascriptExecConfigParam from .browser_left_click_drag_config_param import BrowserLeftClickDragConfigParam as BrowserLeftClickDragConfigParam from .browser_left_mouse_down_config_param import BrowserLeftMouseDownConfigParam as BrowserLeftMouseDownConfigParam +from .browser_left_mouse_up_tool_use_block import BrowserLeftMouseUpToolUseBlock as BrowserLeftMouseUpToolUseBlock from .citations_web_search_result_location import CitationsWebSearchResultLocation as CitationsWebSearchResultLocation +from .computer_double_click_tool_use_block import ComputerDoubleClickToolUseBlock as ComputerDoubleClickToolUseBlock +from .computer_middle_click_tool_use_block import ComputerMiddleClickToolUseBlock as ComputerMiddleClickToolUseBlock +from .computer_triple_click_tool_use_block import ComputerTripleClickToolUseBlock as ComputerTripleClickToolUseBlock +from .response_browser_find_tool_use_block import ResponseBrowserFindToolUseBlock as ResponseBrowserFindToolUseBlock +from .response_browser_type_tool_use_block import ResponseBrowserTypeToolUseBlock as ResponseBrowserTypeToolUseBlock +from .response_browser_wait_tool_use_block import ResponseBrowserWaitToolUseBlock as ResponseBrowserWaitToolUseBlock +from .response_browser_zoom_tool_use_block import ResponseBrowserZoomToolUseBlock as ResponseBrowserZoomToolUseBlock +from .response_computer_key_tool_use_block import ResponseComputerKeyToolUseBlock as ResponseComputerKeyToolUseBlock from .tool_search_tool_bm25_20251119_param import ToolSearchToolBm25_20251119Param as ToolSearchToolBm25_20251119Param from .tool_search_tool_search_result_block import ToolSearchToolSearchResultBlock as ToolSearchToolSearchResultBlock from .web_search_tool_result_block_content import WebSearchToolResultBlockContent as WebSearchToolResultBlockContent @@ -285,12 +406,17 @@ ) from .computer_left_click_drag_config_param import ComputerLeftClickDragConfigParam as ComputerLeftClickDragConfigParam from .computer_left_mouse_down_config_param import ComputerLeftMouseDownConfigParam as ComputerLeftMouseDownConfigParam +from .computer_left_mouse_up_tool_use_block import ComputerLeftMouseUpToolUseBlock as ComputerLeftMouseUpToolUseBlock from .encrypted_code_execution_result_block import ( EncryptedCodeExecutionResultBlock as EncryptedCodeExecutionResultBlock, ) from .message_create_params_container_param import ( MessageCreateParamsContainerParam as MessageCreateParamsContainerParam, ) +from .response_browser_hover_tool_use_block import ResponseBrowserHoverToolUseBlock as ResponseBrowserHoverToolUseBlock +from .response_computer_type_tool_use_block import ResponseComputerTypeToolUseBlock as ResponseComputerTypeToolUseBlock +from .response_computer_wait_tool_use_block import ResponseComputerWaitToolUseBlock as ResponseComputerWaitToolUseBlock +from .response_computer_zoom_tool_use_block import ResponseComputerZoomToolUseBlock as ResponseComputerZoomToolUseBlock from .tool_search_tool_regex_20251119_param import ToolSearchToolRegex20251119Param as ToolSearchToolRegex20251119Param from .bash_code_execution_output_block_param import ( BashCodeExecutionOutputBlockParam as BashCodeExecutionOutputBlockParam, @@ -298,21 +424,65 @@ from .bash_code_execution_result_block_param import ( BashCodeExecutionResultBlockParam as BashCodeExecutionResultBlockParam, ) +from .browser_javascript_exec_tool_use_block import ( + BrowserJavascriptExecToolUseBlock as BrowserJavascriptExecToolUseBlock, +) +from .browser_left_click_drag_tool_use_block import BrowserLeftClickDragToolUseBlock as BrowserLeftClickDragToolUseBlock +from .browser_left_mouse_down_tool_use_block import BrowserLeftMouseDownToolUseBlock as BrowserLeftMouseDownToolUseBlock from .code_execution_tool_result_block_param import ( CodeExecutionToolResultBlockParam as CodeExecutionToolResultBlockParam, ) from .code_execution_tool_result_error_param import ( CodeExecutionToolResultErrorParam as CodeExecutionToolResultErrorParam, ) +from .response_browser_scroll_tool_use_block import ( + ResponseBrowserScrollToolUseBlock as ResponseBrowserScrollToolUseBlock, +) +from .computer_cursor_position_tool_use_block import ( + ComputerCursorPositionToolUseBlock as ComputerCursorPositionToolUseBlock, +) +from .computer_left_click_drag_tool_use_block import ( + ComputerLeftClickDragToolUseBlock as ComputerLeftClickDragToolUseBlock, +) +from .computer_left_mouse_down_tool_use_block import ( + ComputerLeftMouseDownToolUseBlock as ComputerLeftMouseDownToolUseBlock, +) +from .response_browser_new_tab_tool_use_block import ( + ResponseBrowserNewTabToolUseBlock as ResponseBrowserNewTabToolUseBlock, +) +from .response_computer_scroll_tool_use_block import ( + ResponseComputerScrollToolUseBlock as ResponseComputerScrollToolUseBlock, +) from .web_fetch_tool_result_error_block_param import ( WebFetchToolResultErrorBlockParam as WebFetchToolResultErrorBlockParam, ) from .code_execution_tool_result_block_content import ( CodeExecutionToolResultBlockContent as CodeExecutionToolResultBlockContent, ) +from .response_browser_hold_key_tool_use_block import ( + ResponseBrowserHoldKeyToolUseBlock as ResponseBrowserHoldKeyToolUseBlock, +) +from .response_browser_navigate_tool_use_block import ( + ResponseBrowserNavigateToolUseBlock as ResponseBrowserNavigateToolUseBlock, +) from .citation_web_search_result_location_param import ( CitationWebSearchResultLocationParam as CitationWebSearchResultLocationParam, ) +from .response_browser_close_tab_tool_use_block import ( + ResponseBrowserCloseTabToolUseBlock as ResponseBrowserCloseTabToolUseBlock, +) +from .response_browser_list_tabs_tool_use_block import ( + ResponseBrowserListTabsToolUseBlock as ResponseBrowserListTabsToolUseBlock, +) +from .response_browser_read_page_tool_use_block import ( + ResponseBrowserReadPageToolUseBlock as ResponseBrowserReadPageToolUseBlock, +) +from .response_browser_scroll_to_tool_use_block import ( + ResponseBrowserScrollToToolUseBlock as ResponseBrowserScrollToToolUseBlock, +) +from .response_computer_hold_key_tool_use_block import ( + ResponseComputerHoldKeyToolUseBlock as ResponseComputerHoldKeyToolUseBlock, +) from .web_fetch_url_source_tool_reference_param import ( WebFetchURLSourceToolReferenceParam as WebFetchURLSourceToolReferenceParam, ) @@ -322,6 +492,21 @@ from .browser_state_change_download_failed_param import ( BrowserStateChangeDownloadFailedParam as BrowserStateChangeDownloadFailedParam, ) +from .response_browser_form_input_tool_use_block import ( + ResponseBrowserFormInputToolUseBlock as ResponseBrowserFormInputToolUseBlock, +) +from .response_browser_left_click_tool_use_block import ( + ResponseBrowserLeftClickToolUseBlock as ResponseBrowserLeftClickToolUseBlock, +) +from .response_browser_mouse_move_tool_use_block import ( + ResponseBrowserMouseMoveToolUseBlock as ResponseBrowserMouseMoveToolUseBlock, +) +from .response_browser_screenshot_tool_use_block import ( + ResponseBrowserScreenshotToolUseBlock as ResponseBrowserScreenshotToolUseBlock, +) +from .response_browser_switch_tab_tool_use_block import ( + ResponseBrowserSwitchTabToolUseBlock as ResponseBrowserSwitchTabToolUseBlock, +) from .tool_search_tool_search_result_block_param import ( ToolSearchToolSearchResultBlockParam as ToolSearchToolSearchResultBlockParam, ) @@ -337,6 +522,39 @@ from .encrypted_code_execution_result_block_param import ( EncryptedCodeExecutionResultBlockParam as EncryptedCodeExecutionResultBlockParam, ) +from .response_browser_file_upload_tool_use_block import ( + ResponseBrowserFileUploadToolUseBlock as ResponseBrowserFileUploadToolUseBlock, +) +from .response_browser_right_click_tool_use_block import ( + ResponseBrowserRightClickToolUseBlock as ResponseBrowserRightClickToolUseBlock, +) +from .response_computer_left_click_tool_use_block import ( + ResponseComputerLeftClickToolUseBlock as ResponseComputerLeftClickToolUseBlock, +) +from .response_computer_mouse_move_tool_use_block import ( + ResponseComputerMouseMoveToolUseBlock as ResponseComputerMouseMoveToolUseBlock, +) +from .response_computer_screenshot_tool_use_block import ( + ResponseComputerScreenshotToolUseBlock as ResponseComputerScreenshotToolUseBlock, +) +from .response_browser_double_click_tool_use_block import ( + ResponseBrowserDoubleClickToolUseBlock as ResponseBrowserDoubleClickToolUseBlock, +) +from .response_browser_middle_click_tool_use_block import ( + ResponseBrowserMiddleClickToolUseBlock as ResponseBrowserMiddleClickToolUseBlock, +) +from .response_browser_read_console_tool_use_block import ( + ResponseBrowserReadConsoleToolUseBlock as ResponseBrowserReadConsoleToolUseBlock, +) +from .response_browser_read_network_tool_use_block import ( + ResponseBrowserReadNetworkToolUseBlock as ResponseBrowserReadNetworkToolUseBlock, +) +from .response_browser_triple_click_tool_use_block import ( + ResponseBrowserTripleClickToolUseBlock as ResponseBrowserTripleClickToolUseBlock, +) +from .response_computer_right_click_tool_use_block import ( + ResponseComputerRightClickToolUseBlock as ResponseComputerRightClickToolUseBlock, +) from .text_editor_code_execution_tool_result_block import ( TextEditorCodeExecutionToolResultBlock as TextEditorCodeExecutionToolResultBlock, ) @@ -349,9 +567,45 @@ from .browser_state_change_download_completed_param import ( BrowserStateChangeDownloadCompletedParam as BrowserStateChangeDownloadCompletedParam, ) +from .response_browser_get_page_text_tool_use_block import ( + ResponseBrowserGetPageTextToolUseBlock as ResponseBrowserGetPageTextToolUseBlock, +) +from .response_browser_left_mouse_up_tool_use_block import ( + ResponseBrowserLeftMouseUpToolUseBlock as ResponseBrowserLeftMouseUpToolUseBlock, +) +from .response_computer_double_click_tool_use_block import ( + ResponseComputerDoubleClickToolUseBlock as ResponseComputerDoubleClickToolUseBlock, +) +from .response_computer_middle_click_tool_use_block import ( + ResponseComputerMiddleClickToolUseBlock as ResponseComputerMiddleClickToolUseBlock, +) +from .response_computer_triple_click_tool_use_block import ( + ResponseComputerTripleClickToolUseBlock as ResponseComputerTripleClickToolUseBlock, +) +from .response_computer_left_mouse_up_tool_use_block import ( + ResponseComputerLeftMouseUpToolUseBlock as ResponseComputerLeftMouseUpToolUseBlock, +) from .text_editor_code_execution_create_result_block import ( TextEditorCodeExecutionCreateResultBlock as TextEditorCodeExecutionCreateResultBlock, ) +from .response_browser_javascript_exec_tool_use_block import ( + ResponseBrowserJavascriptExecToolUseBlock as ResponseBrowserJavascriptExecToolUseBlock, +) +from .response_browser_left_click_drag_tool_use_block import ( + ResponseBrowserLeftClickDragToolUseBlock as ResponseBrowserLeftClickDragToolUseBlock, +) +from .response_browser_left_mouse_down_tool_use_block import ( + ResponseBrowserLeftMouseDownToolUseBlock as ResponseBrowserLeftMouseDownToolUseBlock, +) +from .response_computer_cursor_position_tool_use_block import ( + ResponseComputerCursorPositionToolUseBlock as ResponseComputerCursorPositionToolUseBlock, +) +from .response_computer_left_click_drag_tool_use_block import ( + ResponseComputerLeftClickDragToolUseBlock as ResponseComputerLeftClickDragToolUseBlock, +) +from .response_computer_left_mouse_down_tool_use_block import ( + ResponseComputerLeftMouseDownToolUseBlock as ResponseComputerLeftMouseDownToolUseBlock, +) from .web_search_tool_result_block_param_content_param import ( WebSearchToolResultBlockParamContentParam as WebSearchToolResultBlockParamContentParam, ) diff --git a/src/anthropic/types/beta/__init__.py b/src/anthropic/types/beta/__init__.py index 24633de7a..0191a08ae 100644 --- a/src/anthropic/types/beta/__init__.py +++ b/src/anthropic/types/beta/__init__.py @@ -10,6 +10,7 @@ from .beta_container import BetaContainer as BetaContainer from .beta_file_scope import BetaFileScope as BetaFileScope from .beta_model_info import BetaModelInfo as BetaModelInfo +from .beta_model_line import BetaModelLine as BetaModelLine from .beta_text_block import BetaTextBlock as BetaTextBlock from .beta_text_delta import BetaTextDelta as BetaTextDelta from .beta_tool_param import BetaToolParam as BetaToolParam @@ -73,6 +74,7 @@ from .beta_output_behavior import BetaOutputBehavior as BetaOutputBehavior from .beta_packages_params import BetaPackagesParams as BetaPackagesParams from .beta_signature_delta import BetaSignatureDelta as BetaSignatureDelta +from .beta_tool_use_caller import BetaToolUseCaller as BetaToolUseCaller from .beta_web_fetch_block import BetaWebFetchBlock as BetaWebFetchBlock from .tunnel_create_params import TunnelCreateParams as TunnelCreateParams from .unwrap_webhook_event import UnwrapWebhookEvent as UnwrapWebhookEvent @@ -87,6 +89,7 @@ from .session_create_params import SessionCreateParams as SessionCreateParams from .session_update_params import SessionUpdateParams as SessionUpdateParams from .beta_base64_pdf_source import BetaBase64PDFSource as BetaBase64PDFSource +from .beta_browser_key_input import BetaBrowserKeyInput as BetaBrowserKeyInput from .beta_cache_miss_reason import BetaCacheMissReason as BetaCacheMissReason from .beta_diagnostics_param import BetaDiagnosticsParam as BetaDiagnosticsParam from .beta_dream_input_param import BetaDreamInputParam as BetaDreamInputParam @@ -98,8 +101,14 @@ from .beta_server_tool_usage import BetaServerToolUsage as BetaServerToolUsage from .beta_tool_choice_param import BetaToolChoiceParam as BetaToolChoiceParam from .deployment_list_params import DeploymentListParams as DeploymentListParams +from .beta_browser_find_input import BetaBrowserFindInput as BetaBrowserFindInput from .beta_browser_key_config import BetaBrowserKeyConfig as BetaBrowserKeyConfig +from .beta_browser_ref_target import BetaBrowserRefTarget as BetaBrowserRefTarget +from .beta_browser_type_input import BetaBrowserTypeInput as BetaBrowserTypeInput +from .beta_browser_wait_input import BetaBrowserWaitInput as BetaBrowserWaitInput +from .beta_browser_zoom_input import BetaBrowserZoomInput as BetaBrowserZoomInput from .beta_capability_support import BetaCapabilitySupport as BetaCapabilitySupport +from .beta_computer_key_input import BetaComputerKeyInput as BetaComputerKeyInput from .beta_dream_model_config import BetaDreamModelConfig as BetaDreamModelConfig from .beta_mcp_tool_use_block import BetaMCPToolUseBlock as BetaMCPToolUseBlock from .beta_model_capabilities import BetaModelCapabilities as BetaModelCapabilities @@ -110,11 +119,19 @@ from .beta_webhook_event_data import BetaWebhookEventData as BetaWebhookEventData from .environment_list_params import EnvironmentListParams as EnvironmentListParams from .beta_browser_find_config import BetaBrowserFindConfig as BetaBrowserFindConfig +from .beta_browser_hover_input import BetaBrowserHoverInput as BetaBrowserHoverInput +from .beta_browser_member_name import ( + BETA_BROWSER_MEMBER_NAME_VALUES as BETA_BROWSER_MEMBER_NAME_VALUES, + BetaBrowserMemberName as BetaBrowserMemberName, +) from .beta_browser_type_config import BetaBrowserTypeConfig as BetaBrowserTypeConfig from .beta_browser_wait_config import BetaBrowserWaitConfig as BetaBrowserWaitConfig from .beta_browser_zoom_config import BetaBrowserZoomConfig as BetaBrowserZoomConfig from .beta_cloud_config_params import BetaCloudConfigParams as BetaCloudConfigParams from .beta_computer_key_config import BetaComputerKeyConfig as BetaComputerKeyConfig +from .beta_computer_type_input import BetaComputerTypeInput as BetaComputerTypeInput +from .beta_computer_wait_input import BetaComputerWaitInput as BetaComputerWaitInput +from .beta_computer_zoom_input import BetaComputerZoomInput as BetaComputerZoomInput from .beta_content_block_param import BetaContentBlockParam as BetaContentBlockParam from .beta_direct_caller_param import BetaDirectCallerParam as BetaDirectCallerParam from .beta_fallback_info_param import BetaFallbackInfoParam as BetaFallbackInfoParam @@ -129,7 +146,14 @@ from .memory_store_list_params import MemoryStoreListParams as MemoryStoreListParams from .user_profile_list_params import UserProfileListParams as UserProfileListParams from .beta_advisor_result_block import BetaAdvisorResultBlock as BetaAdvisorResultBlock +from .beta_browser_click_target import BetaBrowserClickTarget as BetaBrowserClickTarget from .beta_browser_hover_config import BetaBrowserHoverConfig as BetaBrowserHoverConfig +from .beta_browser_member_input import BetaBrowserMemberInput as BetaBrowserMemberInput +from .beta_browser_scroll_input import BetaBrowserScrollInput as BetaBrowserScrollInput +from .beta_computer_member_name import ( + BETA_COMPUTER_MEMBER_NAME_VALUES as BETA_COMPUTER_MEMBER_NAME_VALUES, + BetaComputerMemberName as BetaComputerMemberName, +) from .beta_computer_type_config import BetaComputerTypeConfig as BetaComputerTypeConfig from .beta_computer_wait_config import BetaComputerWaitConfig as BetaComputerWaitConfig from .beta_computer_zoom_config import BetaComputerZoomConfig as BetaComputerZoomConfig @@ -153,8 +177,11 @@ from .environment_create_params import EnvironmentCreateParams as EnvironmentCreateParams from .environment_update_params import EnvironmentUpdateParams as EnvironmentUpdateParams from .beta_advisor_tool_20260301 import BetaAdvisorTool20260301 as BetaAdvisorTool20260301 +from .beta_browser_new_tab_input import BetaBrowserNewTabInput as BetaBrowserNewTabInput from .beta_browser_scroll_config import BetaBrowserScrollConfig as BetaBrowserScrollConfig from .beta_compaction_capability import BetaCompactionCapability as BetaCompactionCapability +from .beta_computer_member_input import BetaComputerMemberInput as BetaComputerMemberInput +from .beta_computer_scroll_input import BetaComputerScrollInput as BetaComputerScrollInput from .beta_fallback_credit_usage import BetaFallbackCreditUsage as BetaFallbackCreditUsage from .beta_mcp_tool_config_param import BetaMCPToolConfigParam as BetaMCPToolConfigParam from .beta_mcp_tool_result_block import BetaMCPToolResultBlock as BetaMCPToolResultBlock @@ -171,7 +198,10 @@ from .tunnel_rotate_token_params import TunnelRotateTokenParams as TunnelRotateTokenParams from .user_profile_create_params import UserProfileCreateParams as UserProfileCreateParams from .user_profile_update_params import UserProfileUpdateParams as UserProfileUpdateParams +from .beta_browser_hold_key_input import BetaBrowserHoldKeyInput as BetaBrowserHoldKeyInput +from .beta_browser_navigate_input import BetaBrowserNavigateInput as BetaBrowserNavigateInput from .beta_browser_new_tab_config import BetaBrowserNewTabConfig as BetaBrowserNewTabConfig +from .beta_browser_tool_use_block import BetaBrowserToolUseBlock as BetaBrowserToolUseBlock from .beta_cache_miss_unavailable import BetaCacheMissUnavailable as BetaCacheMissUnavailable from .beta_citation_char_location import BetaCitationCharLocation as BetaCitationCharLocation from .beta_citation_page_location import BetaCitationPageLocation as BetaCitationPageLocation @@ -187,14 +217,21 @@ from .beta_tool_choice_auto_param import BetaToolChoiceAutoParam as BetaToolChoiceAutoParam from .beta_tool_choice_none_param import BetaToolChoiceNoneParam as BetaToolChoiceNoneParam from .beta_tool_choice_tool_param import BetaToolChoiceToolParam as BetaToolChoiceToolParam +from .beta_toolset_tool_use_block import BetaToolsetToolUseBlock as BetaToolsetToolUseBlock from .beta_url_image_source_param import BetaURLImageSourceParam as BetaURLImageSourceParam from .message_count_tokens_params import MessageCountTokensParams as MessageCountTokensParams from .beta_base64_pdf_source_param import BetaBase64PDFSourceParam as BetaBase64PDFSourceParam +from .beta_browser_close_tab_input import BetaBrowserCloseTabInput as BetaBrowserCloseTabInput from .beta_browser_hold_key_config import BetaBrowserHoldKeyConfig as BetaBrowserHoldKeyConfig +from .beta_browser_list_tabs_input import BetaBrowserListTabsInput as BetaBrowserListTabsInput from .beta_browser_navigate_config import BetaBrowserNavigateConfig as BetaBrowserNavigateConfig +from .beta_browser_read_page_input import BetaBrowserReadPageInput as BetaBrowserReadPageInput +from .beta_browser_scroll_to_input import BetaBrowserScrollToInput as BetaBrowserScrollToInput from .beta_browser_toolset_configs import BetaBrowserToolsetConfigs as BetaBrowserToolsetConfigs from .beta_cache_control_ephemeral import BetaCacheControlEphemeral as BetaCacheControlEphemeral from .beta_compaction_config_param import BetaCompactionConfigParam as BetaCompactionConfigParam +from .beta_computer_hold_key_input import BetaComputerHoldKeyInput as BetaComputerHoldKeyInput +from .beta_computer_tool_use_block import BetaComputerToolUseBlock as BetaComputerToolUseBlock from .beta_file_image_source_param import BetaFileImageSourceParam as BetaFileImageSourceParam from .beta_managed_agents_schedule import BetaManagedAgentsSchedule as BetaManagedAgentsSchedule from .beta_mcp_tool_default_config import BetaMCPToolDefaultConfig as BetaMCPToolDefaultConfig @@ -204,6 +241,7 @@ from .beta_raw_message_delta_event import BetaRawMessageDeltaEvent as BetaRawMessageDeltaEvent from .beta_raw_message_start_event import BetaRawMessageStartEvent as BetaRawMessageStartEvent from .beta_redacted_thinking_block import BetaRedactedThinkingBlock as BetaRedactedThinkingBlock +from .beta_server_tools_capability import BetaServerToolsCapability as BetaServerToolsCapability from .beta_token_task_budget_param import BetaTokenTaskBudgetParam as BetaTokenTaskBudgetParam from .beta_tool_result_block_param import BetaToolResultBlockParam as BetaToolResultBlockParam from .beta_tool_uses_trigger_param import BetaToolUsesTriggerParam as BetaToolUsesTriggerParam @@ -214,10 +252,18 @@ from .beta_web_search_result_block import BetaWebSearchResultBlock as BetaWebSearchResultBlock from .beta_all_thinking_turns_param import BetaAllThinkingTurnsParam as BetaAllThinkingTurnsParam from .beta_browser_close_tab_config import BetaBrowserCloseTabConfig as BetaBrowserCloseTabConfig +from .beta_browser_form_input_input import BetaBrowserFormInputInput as BetaBrowserFormInputInput +from .beta_browser_form_input_value import BetaBrowserFormInputValue as BetaBrowserFormInputValue from .beta_browser_key_config_param import BetaBrowserKeyConfigParam as BetaBrowserKeyConfigParam +from .beta_browser_left_click_input import BetaBrowserLeftClickInput as BetaBrowserLeftClickInput from .beta_browser_list_tabs_config import BetaBrowserListTabsConfig as BetaBrowserListTabsConfig +from .beta_browser_mouse_move_input import BetaBrowserMouseMoveInput as BetaBrowserMouseMoveInput from .beta_browser_read_page_config import BetaBrowserReadPageConfig as BetaBrowserReadPageConfig +from .beta_browser_read_page_filter import BetaBrowserReadPageFilter as BetaBrowserReadPageFilter +from .beta_browser_screenshot_input import BetaBrowserScreenshotInput as BetaBrowserScreenshotInput +from .beta_browser_scroll_direction import BetaBrowserScrollDirection as BetaBrowserScrollDirection from .beta_browser_scroll_to_config import BetaBrowserScrollToConfig as BetaBrowserScrollToConfig +from .beta_browser_switch_tab_input import BetaBrowserSwitchTabInput as BetaBrowserSwitchTabInput from .beta_browser_toolset_20260801 import BetaBrowserToolset20260801 as BetaBrowserToolset20260801 from .beta_cache_miss_model_changed import BetaCacheMissModelChanged as BetaCacheMissModelChanged from .beta_cache_miss_tools_changed import BetaCacheMissToolsChanged as BetaCacheMissToolsChanged @@ -241,10 +287,13 @@ from .beta_advisor_tool_result_block import BetaAdvisorToolResultBlock as BetaAdvisorToolResultBlock from .beta_advisor_tool_result_error import BetaAdvisorToolResultError as BetaAdvisorToolResultError from .beta_base64_image_source_param import BetaBase64ImageSourceParam as BetaBase64ImageSourceParam +from .beta_browser_coordinate_target import BetaBrowserCoordinateTarget as BetaBrowserCoordinateTarget +from .beta_browser_file_upload_input import BetaBrowserFileUploadInput as BetaBrowserFileUploadInput from .beta_browser_find_config_param import BetaBrowserFindConfigParam as BetaBrowserFindConfigParam from .beta_browser_form_input_config import BetaBrowserFormInputConfig as BetaBrowserFormInputConfig from .beta_browser_left_click_config import BetaBrowserLeftClickConfig as BetaBrowserLeftClickConfig from .beta_browser_mouse_move_config import BetaBrowserMouseMoveConfig as BetaBrowserMouseMoveConfig +from .beta_browser_right_click_input import BetaBrowserRightClickInput as BetaBrowserRightClickInput from .beta_browser_screenshot_config import BetaBrowserScreenshotConfig as BetaBrowserScreenshotConfig from .beta_browser_state_block_param import BetaBrowserStateBlockParam as BetaBrowserStateBlockParam from .beta_browser_switch_tab_config import BetaBrowserSwitchTabConfig as BetaBrowserSwitchTabConfig @@ -253,6 +302,10 @@ from .beta_browser_zoom_config_param import BetaBrowserZoomConfigParam as BetaBrowserZoomConfigParam from .beta_cache_miss_system_changed import BetaCacheMissSystemChanged as BetaCacheMissSystemChanged from .beta_computer_key_config_param import BetaComputerKeyConfigParam as BetaComputerKeyConfigParam +from .beta_computer_left_click_input import BetaComputerLeftClickInput as BetaComputerLeftClickInput +from .beta_computer_mouse_move_input import BetaComputerMouseMoveInput as BetaComputerMouseMoveInput +from .beta_computer_screenshot_input import BetaComputerScreenshotInput as BetaComputerScreenshotInput +from .beta_computer_scroll_direction import BetaComputerScrollDirection as BetaComputerScrollDirection from .beta_computer_toolset_20260801 import BetaComputerToolset20260801 as BetaComputerToolset20260801 from .beta_managed_agents_delta_type import BetaManagedAgentsDeltaType as BetaManagedAgentsDeltaType from .beta_managed_agents_deployment import BetaManagedAgentsDeployment as BetaManagedAgentsDeployment @@ -268,13 +321,20 @@ from .beta_web_fetch_url_source_none import BetaWebFetchURLSourceNone as BetaWebFetchURLSourceNone from .beta_web_fetch_url_source_only import BetaWebFetchURLSourceOnly as BetaWebFetchURLSourceOnly from .beta_advisor_result_block_param import BetaAdvisorResultBlockParam as BetaAdvisorResultBlockParam +from .beta_browser_double_click_input import BetaBrowserDoubleClickInput as BetaBrowserDoubleClickInput from .beta_browser_file_upload_config import BetaBrowserFileUploadConfig as BetaBrowserFileUploadConfig from .beta_browser_hover_config_param import BetaBrowserHoverConfigParam as BetaBrowserHoverConfigParam +from .beta_browser_key_tool_use_block import BetaBrowserKeyToolUseBlock as BetaBrowserKeyToolUseBlock +from .beta_browser_middle_click_input import BetaBrowserMiddleClickInput as BetaBrowserMiddleClickInput +from .beta_browser_read_console_input import BetaBrowserReadConsoleInput as BetaBrowserReadConsoleInput +from .beta_browser_read_network_input import BetaBrowserReadNetworkInput as BetaBrowserReadNetworkInput from .beta_browser_right_click_config import BetaBrowserRightClickConfig as BetaBrowserRightClickConfig from .beta_browser_state_change_param import BetaBrowserStateChangeParam as BetaBrowserStateChangeParam +from .beta_browser_triple_click_input import BetaBrowserTripleClickInput as BetaBrowserTripleClickInput from .beta_compaction_iteration_usage import BetaCompactionIterationUsage as BetaCompactionIterationUsage from .beta_computer_left_click_config import BetaComputerLeftClickConfig as BetaComputerLeftClickConfig from .beta_computer_mouse_move_config import BetaComputerMouseMoveConfig as BetaComputerMouseMoveConfig +from .beta_computer_right_click_input import BetaComputerRightClickInput as BetaComputerRightClickInput from .beta_computer_screenshot_config import BetaComputerScreenshotConfig as BetaComputerScreenshotConfig from .beta_computer_type_config_param import BetaComputerTypeConfigParam as BetaComputerTypeConfigParam from .beta_computer_wait_config_param import BetaComputerWaitConfigParam as BetaComputerWaitConfigParam @@ -300,16 +360,26 @@ from .beta_unrestricted_network_param import BetaUnrestrictedNetworkParam as BetaUnrestrictedNetworkParam from .beta_advisor_tool_20260301_param import BetaAdvisorTool20260301Param as BetaAdvisorTool20260301Param from .beta_browser_double_click_config import BetaBrowserDoubleClickConfig as BetaBrowserDoubleClickConfig +from .beta_browser_find_tool_use_block import BetaBrowserFindToolUseBlock as BetaBrowserFindToolUseBlock +from .beta_browser_get_page_text_input import BetaBrowserGetPageTextInput as BetaBrowserGetPageTextInput +from .beta_browser_left_mouse_up_input import BetaBrowserLeftMouseUpInput as BetaBrowserLeftMouseUpInput from .beta_browser_middle_click_config import BetaBrowserMiddleClickConfig as BetaBrowserMiddleClickConfig from .beta_browser_read_console_config import BetaBrowserReadConsoleConfig as BetaBrowserReadConsoleConfig from .beta_browser_read_network_config import BetaBrowserReadNetworkConfig as BetaBrowserReadNetworkConfig from .beta_browser_scroll_config_param import BetaBrowserScrollConfigParam as BetaBrowserScrollConfigParam from .beta_browser_triple_click_config import BetaBrowserTripleClickConfig as BetaBrowserTripleClickConfig +from .beta_browser_type_tool_use_block import BetaBrowserTypeToolUseBlock as BetaBrowserTypeToolUseBlock +from .beta_browser_wait_tool_use_block import BetaBrowserWaitToolUseBlock as BetaBrowserWaitToolUseBlock +from .beta_browser_zoom_tool_use_block import BetaBrowserZoomToolUseBlock as BetaBrowserZoomToolUseBlock from .beta_cache_miss_messages_changed import BetaCacheMissMessagesChanged as BetaCacheMissMessagesChanged from .beta_code_execution_output_block import BetaCodeExecutionOutputBlock as BetaCodeExecutionOutputBlock from .beta_code_execution_result_block import BetaCodeExecutionResultBlock as BetaCodeExecutionResultBlock from .beta_compact_20260112_edit_param import BetaCompact20260112EditParam as BetaCompact20260112EditParam +from .beta_computer_double_click_input import BetaComputerDoubleClickInput as BetaComputerDoubleClickInput +from .beta_computer_key_tool_use_block import BetaComputerKeyToolUseBlock as BetaComputerKeyToolUseBlock +from .beta_computer_middle_click_input import BetaComputerMiddleClickInput as BetaComputerMiddleClickInput from .beta_computer_right_click_config import BetaComputerRightClickConfig as BetaComputerRightClickConfig +from .beta_computer_triple_click_input import BetaComputerTripleClickInput as BetaComputerTripleClickInput from .beta_context_management_response import BetaContextManagementResponse as BetaContextManagementResponse from .beta_environment_delete_response import BetaEnvironmentDeleteResponse as BetaEnvironmentDeleteResponse from .beta_fallback_credit_not_applied import BetaFallbackCreditNotApplied as BetaFallbackCreditNotApplied @@ -332,6 +402,7 @@ from .beta_web_fetch_url_source_except import BetaWebFetchURLSourceExcept as BetaWebFetchURLSourceExcept from .beta_web_fetch_url_sources_param import BetaWebFetchURLSourcesParam as BetaWebFetchURLSourcesParam from .beta_browser_get_page_text_config import BetaBrowserGetPageTextConfig as BetaBrowserGetPageTextConfig +from .beta_browser_hover_tool_use_block import BetaBrowserHoverToolUseBlock as BetaBrowserHoverToolUseBlock from .beta_browser_left_mouse_up_config import BetaBrowserLeftMouseUpConfig as BetaBrowserLeftMouseUpConfig from .beta_browser_new_tab_config_param import BetaBrowserNewTabConfigParam as BetaBrowserNewTabConfigParam from .beta_citation_char_location_param import BetaCitationCharLocationParam as BetaCitationCharLocationParam @@ -342,9 +413,13 @@ from .beta_code_execution_tool_20260120 import BetaCodeExecutionTool20260120 as BetaCodeExecutionTool20260120 from .beta_code_execution_tool_20260521 import BetaCodeExecutionTool20260521 as BetaCodeExecutionTool20260521 from .beta_computer_double_click_config import BetaComputerDoubleClickConfig as BetaComputerDoubleClickConfig +from .beta_computer_left_mouse_up_input import BetaComputerLeftMouseUpInput as BetaComputerLeftMouseUpInput from .beta_computer_middle_click_config import BetaComputerMiddleClickConfig as BetaComputerMiddleClickConfig from .beta_computer_scroll_config_param import BetaComputerScrollConfigParam as BetaComputerScrollConfigParam from .beta_computer_triple_click_config import BetaComputerTripleClickConfig as BetaComputerTripleClickConfig +from .beta_computer_type_tool_use_block import BetaComputerTypeToolUseBlock as BetaComputerTypeToolUseBlock +from .beta_computer_wait_tool_use_block import BetaComputerWaitToolUseBlock as BetaComputerWaitToolUseBlock +from .beta_computer_zoom_tool_use_block import BetaComputerZoomToolUseBlock as BetaComputerZoomToolUseBlock from .beta_container_upload_block_param import BetaContainerUploadBlockParam as BetaContainerUploadBlockParam from .beta_managed_agents_deleted_vault import BetaManagedAgentsDeletedVault as BetaManagedAgentsDeletedVault from .beta_managed_agents_delta_content import BetaManagedAgentsDeltaContent as BetaManagedAgentsDeltaContent @@ -363,7 +438,11 @@ from .beta_web_search_tool_result_error import BetaWebSearchToolResultError as BetaWebSearchToolResultError from .beta_advisor_redacted_result_block import BetaAdvisorRedactedResultBlock as BetaAdvisorRedactedResultBlock from .beta_browser_hold_key_config_param import BetaBrowserHoldKeyConfigParam as BetaBrowserHoldKeyConfigParam +from .beta_browser_javascript_exec_input import BetaBrowserJavascriptExecInput as BetaBrowserJavascriptExecInput +from .beta_browser_left_click_drag_input import BetaBrowserLeftClickDragInput as BetaBrowserLeftClickDragInput +from .beta_browser_left_mouse_down_input import BetaBrowserLeftMouseDownInput as BetaBrowserLeftMouseDownInput from .beta_browser_navigate_config_param import BetaBrowserNavigateConfigParam as BetaBrowserNavigateConfigParam +from .beta_browser_scroll_tool_use_block import BetaBrowserScrollToolUseBlock as BetaBrowserScrollToolUseBlock from .beta_browser_state_tab_entry_param import BetaBrowserStateTabEntryParam as BetaBrowserStateTabEntryParam from .beta_browser_toolset_configs_param import BetaBrowserToolsetConfigsParam as BetaBrowserToolsetConfigsParam from .beta_cache_control_ephemeral_param import BetaCacheControlEphemeralParam as BetaCacheControlEphemeralParam @@ -375,6 +454,7 @@ from .beta_raw_content_block_delta_event import BetaRawContentBlockDeltaEvent as BetaRawContentBlockDeltaEvent from .beta_raw_content_block_start_event import BetaRawContentBlockStartEvent as BetaRawContentBlockStartEvent from .beta_redacted_thinking_block_param import BetaRedactedThinkingBlockParam as BetaRedactedThinkingBlockParam +from .beta_response_tool_use_block_union import BetaResponseToolUseBlockUnion as BetaResponseToolUseBlockUnion from .beta_thinking_config_enabled_param import BetaThinkingConfigEnabledParam as BetaThinkingConfigEnabledParam from .beta_tool_search_tool_result_block import BetaToolSearchToolResultBlock as BetaToolSearchToolResultBlock from .beta_tool_search_tool_result_error import BetaToolSearchToolResultError as BetaToolSearchToolResultError @@ -388,11 +468,16 @@ from .beta_browser_left_click_drag_config import BetaBrowserLeftClickDragConfig as BetaBrowserLeftClickDragConfig from .beta_browser_left_mouse_down_config import BetaBrowserLeftMouseDownConfig as BetaBrowserLeftMouseDownConfig from .beta_browser_list_tabs_config_param import BetaBrowserListTabsConfigParam as BetaBrowserListTabsConfigParam +from .beta_browser_new_tab_tool_use_block import BetaBrowserNewTabToolUseBlock as BetaBrowserNewTabToolUseBlock from .beta_browser_read_page_config_param import BetaBrowserReadPageConfigParam as BetaBrowserReadPageConfigParam from .beta_browser_scroll_to_config_param import BetaBrowserScrollToConfigParam as BetaBrowserScrollToConfigParam from .beta_browser_toolset_20260801_param import BetaBrowserToolset20260801Param as BetaBrowserToolset20260801Param from .beta_compaction_content_block_delta import BetaCompactionContentBlockDelta as BetaCompactionContentBlockDelta +from .beta_computer_cursor_position_input import BetaComputerCursorPositionInput as BetaComputerCursorPositionInput from .beta_computer_hold_key_config_param import BetaComputerHoldKeyConfigParam as BetaComputerHoldKeyConfigParam +from .beta_computer_left_click_drag_input import BetaComputerLeftClickDragInput as BetaComputerLeftClickDragInput +from .beta_computer_left_mouse_down_input import BetaComputerLeftMouseDownInput as BetaComputerLeftMouseDownInput +from .beta_computer_scroll_tool_use_block import BetaComputerScrollToolUseBlock as BetaComputerScrollToolUseBlock from .beta_computer_toolset_configs_param import BetaComputerToolsetConfigsParam as BetaComputerToolsetConfigsParam from .beta_dream_memory_store_input_param import BetaDreamMemoryStoreInputParam as BetaDreamMemoryStoreInputParam from .beta_managed_agents_agent_reference import BetaManagedAgentsAgentReference as BetaManagedAgentsAgentReference @@ -414,8 +499,10 @@ from .beta_advisor_tool_result_block_param import BetaAdvisorToolResultBlockParam as BetaAdvisorToolResultBlockParam from .beta_advisor_tool_result_error_param import BetaAdvisorToolResultErrorParam as BetaAdvisorToolResultErrorParam from .beta_browser_form_input_config_param import BetaBrowserFormInputConfigParam as BetaBrowserFormInputConfigParam +from .beta_browser_hold_key_tool_use_block import BetaBrowserHoldKeyToolUseBlock as BetaBrowserHoldKeyToolUseBlock from .beta_browser_left_click_config_param import BetaBrowserLeftClickConfigParam as BetaBrowserLeftClickConfigParam from .beta_browser_mouse_move_config_param import BetaBrowserMouseMoveConfigParam as BetaBrowserMouseMoveConfigParam +from .beta_browser_navigate_tool_use_block import BetaBrowserNavigateToolUseBlock as BetaBrowserNavigateToolUseBlock from .beta_browser_screenshot_config_param import BetaBrowserScreenshotConfigParam as BetaBrowserScreenshotConfigParam from .beta_browser_switch_tab_config_param import BetaBrowserSwitchTabConfigParam as BetaBrowserSwitchTabConfigParam from .beta_citation_content_block_location import BetaCitationContentBlockLocation as BetaCitationContentBlockLocation @@ -433,6 +520,7 @@ from .beta_managed_agents_grep_tool_config import BetaManagedAgentsGrepToolConfig as BetaManagedAgentsGrepToolConfig from .beta_managed_agents_read_tool_config import BetaManagedAgentsReadToolConfig as BetaManagedAgentsReadToolConfig from .beta_output_behavior_update_existing import BetaOutputBehaviorUpdateExisting as BetaOutputBehaviorUpdateExisting +from .beta_response_browser_tool_use_block import BetaResponseBrowserToolUseBlock as BetaResponseBrowserToolUseBlock from .beta_tool_search_tool_regex_20251119 import BetaToolSearchToolRegex20251119 as BetaToolSearchToolRegex20251119 from .beta_tool_text_editor_20241022_param import BetaToolTextEditor20241022Param as BetaToolTextEditor20241022Param from .beta_tool_text_editor_20250124_param import BetaToolTextEditor20250124Param as BetaToolTextEditor20250124Param @@ -442,10 +530,15 @@ from .beta_web_fetch_url_source_only_param import BetaWebFetchURLSourceOnlyParam as BetaWebFetchURLSourceOnlyParam from .beta_bash_code_execution_output_block import BetaBashCodeExecutionOutputBlock as BetaBashCodeExecutionOutputBlock from .beta_bash_code_execution_result_block import BetaBashCodeExecutionResultBlock as BetaBashCodeExecutionResultBlock +from .beta_browser_close_tab_tool_use_block import BetaBrowserCloseTabToolUseBlock as BetaBrowserCloseTabToolUseBlock from .beta_browser_file_upload_config_param import BetaBrowserFileUploadConfigParam as BetaBrowserFileUploadConfigParam +from .beta_browser_list_tabs_tool_use_block import BetaBrowserListTabsToolUseBlock as BetaBrowserListTabsToolUseBlock +from .beta_browser_read_page_tool_use_block import BetaBrowserReadPageToolUseBlock as BetaBrowserReadPageToolUseBlock from .beta_browser_right_click_config_param import BetaBrowserRightClickConfigParam as BetaBrowserRightClickConfigParam +from .beta_browser_scroll_to_tool_use_block import BetaBrowserScrollToToolUseBlock as BetaBrowserScrollToToolUseBlock from .beta_code_execution_tool_result_block import BetaCodeExecutionToolResultBlock as BetaCodeExecutionToolResultBlock from .beta_code_execution_tool_result_error import BetaCodeExecutionToolResultError as BetaCodeExecutionToolResultError +from .beta_computer_hold_key_tool_use_block import BetaComputerHoldKeyToolUseBlock as BetaComputerHoldKeyToolUseBlock from .beta_computer_left_click_config_param import BetaComputerLeftClickConfigParam as BetaComputerLeftClickConfigParam from .beta_computer_mouse_move_config_param import BetaComputerMouseMoveConfigParam as BetaComputerMouseMoveConfigParam from .beta_computer_screenshot_config_param import ( @@ -469,6 +562,7 @@ from .beta_managed_agents_write_tool_config import BetaManagedAgentsWriteToolConfig as BetaManagedAgentsWriteToolConfig from .beta_output_behavior_create_new_param import BetaOutputBehaviorCreateNewParam as BetaOutputBehaviorCreateNewParam from .beta_request_tool_removal_block_param import BetaRequestToolRemovalBlockParam as BetaRequestToolRemovalBlockParam +from .beta_response_computer_tool_use_block import BetaResponseComputerToolUseBlock as BetaResponseComputerToolUseBlock from .beta_tool_change_tool_reference_param import BetaToolChangeToolReferenceParam as BetaToolChangeToolReferenceParam from .beta_tool_computer_use_20241022_param import BetaToolComputerUse20241022Param as BetaToolComputerUse20241022Param from .beta_tool_computer_use_20250124_param import BetaToolComputerUse20250124Param as BetaToolComputerUse20250124Param @@ -483,15 +577,22 @@ from .beta_browser_double_click_config_param import ( BetaBrowserDoubleClickConfigParam as BetaBrowserDoubleClickConfigParam, ) +from .beta_browser_form_input_tool_use_block import BetaBrowserFormInputToolUseBlock as BetaBrowserFormInputToolUseBlock +from .beta_browser_left_click_tool_use_block import BetaBrowserLeftClickToolUseBlock as BetaBrowserLeftClickToolUseBlock from .beta_browser_middle_click_config_param import ( BetaBrowserMiddleClickConfigParam as BetaBrowserMiddleClickConfigParam, ) +from .beta_browser_mouse_move_tool_use_block import BetaBrowserMouseMoveToolUseBlock as BetaBrowserMouseMoveToolUseBlock from .beta_browser_read_console_config_param import ( BetaBrowserReadConsoleConfigParam as BetaBrowserReadConsoleConfigParam, ) from .beta_browser_read_network_config_param import ( BetaBrowserReadNetworkConfigParam as BetaBrowserReadNetworkConfigParam, ) +from .beta_browser_screenshot_tool_use_block import ( + BetaBrowserScreenshotToolUseBlock as BetaBrowserScreenshotToolUseBlock, +) +from .beta_browser_switch_tab_tool_use_block import BetaBrowserSwitchTabToolUseBlock as BetaBrowserSwitchTabToolUseBlock from .beta_browser_triple_click_config_param import ( BetaBrowserTripleClickConfigParam as BetaBrowserTripleClickConfigParam, ) @@ -542,12 +643,18 @@ from .beta_webhook_vault_archived_event_data import ( BetaWebhookVaultArchivedEventData as BetaWebhookVaultArchivedEventData, ) +from .beta_browser_file_upload_tool_use_block import ( + BetaBrowserFileUploadToolUseBlock as BetaBrowserFileUploadToolUseBlock, +) from .beta_browser_get_page_text_config_param import ( BetaBrowserGetPageTextConfigParam as BetaBrowserGetPageTextConfigParam, ) from .beta_browser_left_mouse_up_config_param import ( BetaBrowserLeftMouseUpConfigParam as BetaBrowserLeftMouseUpConfigParam, ) +from .beta_browser_right_click_tool_use_block import ( + BetaBrowserRightClickToolUseBlock as BetaBrowserRightClickToolUseBlock, +) from .beta_clear_thinking_20251015_edit_param import ( BetaClearThinking20251015EditParam as BetaClearThinking20251015EditParam, ) @@ -566,9 +673,18 @@ from .beta_computer_double_click_config_param import ( BetaComputerDoubleClickConfigParam as BetaComputerDoubleClickConfigParam, ) +from .beta_computer_left_click_tool_use_block import ( + BetaComputerLeftClickToolUseBlock as BetaComputerLeftClickToolUseBlock, +) from .beta_computer_middle_click_config_param import ( BetaComputerMiddleClickConfigParam as BetaComputerMiddleClickConfigParam, ) +from .beta_computer_mouse_move_tool_use_block import ( + BetaComputerMouseMoveToolUseBlock as BetaComputerMouseMoveToolUseBlock, +) +from .beta_computer_screenshot_tool_use_block import ( + BetaComputerScreenshotToolUseBlock as BetaComputerScreenshotToolUseBlock, +) from .beta_computer_triple_click_config_param import ( BetaComputerTripleClickConfigParam as BetaComputerTripleClickConfigParam, ) @@ -623,12 +739,30 @@ from .beta_advisor_redacted_result_block_param import ( BetaAdvisorRedactedResultBlockParam as BetaAdvisorRedactedResultBlockParam, ) +from .beta_browser_double_click_tool_use_block import ( + BetaBrowserDoubleClickToolUseBlock as BetaBrowserDoubleClickToolUseBlock, +) +from .beta_browser_middle_click_tool_use_block import ( + BetaBrowserMiddleClickToolUseBlock as BetaBrowserMiddleClickToolUseBlock, +) +from .beta_browser_read_console_tool_use_block import ( + BetaBrowserReadConsoleToolUseBlock as BetaBrowserReadConsoleToolUseBlock, +) +from .beta_browser_read_network_tool_use_block import ( + BetaBrowserReadNetworkToolUseBlock as BetaBrowserReadNetworkToolUseBlock, +) +from .beta_browser_triple_click_tool_use_block import ( + BetaBrowserTripleClickToolUseBlock as BetaBrowserTripleClickToolUseBlock, +) from .beta_clear_tool_uses_20250919_edit_param import ( BetaClearToolUses20250919EditParam as BetaClearToolUses20250919EditParam, ) from .beta_computer_left_mouse_up_config_param import ( BetaComputerLeftMouseUpConfigParam as BetaComputerLeftMouseUpConfigParam, ) +from .beta_computer_right_click_tool_use_block import ( + BetaComputerRightClickToolUseBlock as BetaComputerRightClickToolUseBlock, +) from .beta_managed_agents_cache_creation_usage import ( BetaManagedAgentsCacheCreationUsage as BetaManagedAgentsCacheCreationUsage, ) @@ -665,6 +799,9 @@ from .beta_request_mcp_tool_result_block_param import ( BetaRequestMCPToolResultBlockParam as BetaRequestMCPToolResultBlockParam, ) +from .beta_response_browser_key_tool_use_block import ( + BetaResponseBrowserKeyToolUseBlock as BetaResponseBrowserKeyToolUseBlock, +) from .beta_response_tool_change_tool_reference import ( BetaResponseToolChangeToolReference as BetaResponseToolChangeToolReference, ) @@ -686,6 +823,9 @@ from .beta_webhook_session_archived_event_data import ( BetaWebhookSessionArchivedEventData as BetaWebhookSessionArchivedEventData, ) +from .beta_browser_get_page_text_tool_use_block import ( + BetaBrowserGetPageTextToolUseBlock as BetaBrowserGetPageTextToolUseBlock, +) from .beta_browser_javascript_exec_config_param import ( BetaBrowserJavascriptExecConfigParam as BetaBrowserJavascriptExecConfigParam, ) @@ -695,9 +835,21 @@ from .beta_browser_left_mouse_down_config_param import ( BetaBrowserLeftMouseDownConfigParam as BetaBrowserLeftMouseDownConfigParam, ) +from .beta_browser_left_mouse_up_tool_use_block import ( + BetaBrowserLeftMouseUpToolUseBlock as BetaBrowserLeftMouseUpToolUseBlock, +) from .beta_citations_web_search_result_location import ( BetaCitationsWebSearchResultLocation as BetaCitationsWebSearchResultLocation, ) +from .beta_computer_double_click_tool_use_block import ( + BetaComputerDoubleClickToolUseBlock as BetaComputerDoubleClickToolUseBlock, +) +from .beta_computer_middle_click_tool_use_block import ( + BetaComputerMiddleClickToolUseBlock as BetaComputerMiddleClickToolUseBlock, +) +from .beta_computer_triple_click_tool_use_block import ( + BetaComputerTripleClickToolUseBlock as BetaComputerTripleClickToolUseBlock, +) from .beta_managed_agents_agent_message_preview import ( BetaManagedAgentsAgentMessagePreview as BetaManagedAgentsAgentMessagePreview, ) @@ -719,6 +871,24 @@ from .beta_managed_agents_web_fetch_tool_config import ( BetaManagedAgentsWebFetchToolConfig as BetaManagedAgentsWebFetchToolConfig, ) +from .beta_managed_agents_web_fetch_url_sources import ( + BetaManagedAgentsWebFetchURLSources as BetaManagedAgentsWebFetchURLSources, +) +from .beta_response_browser_find_tool_use_block import ( + BetaResponseBrowserFindToolUseBlock as BetaResponseBrowserFindToolUseBlock, +) +from .beta_response_browser_type_tool_use_block import ( + BetaResponseBrowserTypeToolUseBlock as BetaResponseBrowserTypeToolUseBlock, +) +from .beta_response_browser_wait_tool_use_block import ( + BetaResponseBrowserWaitToolUseBlock as BetaResponseBrowserWaitToolUseBlock, +) +from .beta_response_browser_zoom_tool_use_block import ( + BetaResponseBrowserZoomToolUseBlock as BetaResponseBrowserZoomToolUseBlock, +) +from .beta_response_computer_key_tool_use_block import ( + BetaResponseComputerKeyToolUseBlock as BetaResponseComputerKeyToolUseBlock, +) from .beta_tool_change_mcp_tool_reference_param import ( BetaToolChangeMCPToolReferenceParam as BetaToolChangeMCPToolReferenceParam, ) @@ -767,6 +937,9 @@ from .beta_computer_left_mouse_down_config_param import ( BetaComputerLeftMouseDownConfigParam as BetaComputerLeftMouseDownConfigParam, ) +from .beta_computer_left_mouse_up_tool_use_block import ( + BetaComputerLeftMouseUpToolUseBlock as BetaComputerLeftMouseUpToolUseBlock, +) from .beta_encrypted_code_execution_result_block import ( BetaEncryptedCodeExecutionResultBlock as BetaEncryptedCodeExecutionResultBlock, ) @@ -794,6 +967,18 @@ from .beta_output_behavior_update_existing_param import ( BetaOutputBehaviorUpdateExistingParam as BetaOutputBehaviorUpdateExistingParam, ) +from .beta_response_browser_hover_tool_use_block import ( + BetaResponseBrowserHoverToolUseBlock as BetaResponseBrowserHoverToolUseBlock, +) +from .beta_response_computer_type_tool_use_block import ( + BetaResponseComputerTypeToolUseBlock as BetaResponseComputerTypeToolUseBlock, +) +from .beta_response_computer_wait_tool_use_block import ( + BetaResponseComputerWaitToolUseBlock as BetaResponseComputerWaitToolUseBlock, +) +from .beta_response_computer_zoom_tool_use_block import ( + BetaResponseComputerZoomToolUseBlock as BetaResponseComputerZoomToolUseBlock, +) from .beta_thinking_dropped_input_transformation import ( BetaThinkingDroppedInputTransformation as BetaThinkingDroppedInputTransformation, ) @@ -815,6 +1000,15 @@ from .beta_bash_code_execution_result_block_param import ( BetaBashCodeExecutionResultBlockParam as BetaBashCodeExecutionResultBlockParam, ) +from .beta_browser_javascript_exec_tool_use_block import ( + BetaBrowserJavascriptExecToolUseBlock as BetaBrowserJavascriptExecToolUseBlock, +) +from .beta_browser_left_click_drag_tool_use_block import ( + BetaBrowserLeftClickDragToolUseBlock as BetaBrowserLeftClickDragToolUseBlock, +) +from .beta_browser_left_mouse_down_tool_use_block import ( + BetaBrowserLeftMouseDownToolUseBlock as BetaBrowserLeftMouseDownToolUseBlock, +) from .beta_clear_tool_uses_20250919_edit_response import ( BetaClearToolUses20250919EditResponse as BetaClearToolUses20250919EditResponse, ) @@ -845,6 +1039,9 @@ from .beta_managed_agents_session_resource_config import ( BetaManagedAgentsSessionResourceConfig as BetaManagedAgentsSessionResourceConfig, ) +from .beta_response_browser_scroll_tool_use_block import ( + BetaResponseBrowserScrollToolUseBlock as BetaResponseBrowserScrollToolUseBlock, +) from .beta_webhook_deployment_archived_event_data import ( BetaWebhookDeploymentArchivedEventData as BetaWebhookDeploymentArchivedEventData, ) @@ -860,6 +1057,15 @@ from .beta_webhook_environment_updated_event_data import ( BetaWebhookEnvironmentUpdatedEventData as BetaWebhookEnvironmentUpdatedEventData, ) +from .beta_computer_cursor_position_tool_use_block import ( + BetaComputerCursorPositionToolUseBlock as BetaComputerCursorPositionToolUseBlock, +) +from .beta_computer_left_click_drag_tool_use_block import ( + BetaComputerLeftClickDragToolUseBlock as BetaComputerLeftClickDragToolUseBlock, +) +from .beta_computer_left_mouse_down_tool_use_block import ( + BetaComputerLeftMouseDownToolUseBlock as BetaComputerLeftMouseDownToolUseBlock, +) from .beta_managed_agents_agent_archived_run_error import ( BetaManagedAgentsAgentArchivedRunError as BetaManagedAgentsAgentArchivedRunError, ) @@ -884,12 +1090,21 @@ from .beta_managed_agents_vault_archived_run_error import ( BetaManagedAgentsVaultArchivedRunError as BetaManagedAgentsVaultArchivedRunError, ) +from .beta_managed_agents_web_fetch_url_source_all import ( + BetaManagedAgentsWebFetchURLSourceAll as BetaManagedAgentsWebFetchURLSourceAll, +) from .beta_managed_agents_write_tool_config_params import ( BetaManagedAgentsWriteToolConfigParams as BetaManagedAgentsWriteToolConfigParams, ) from .beta_request_mcp_server_url_definition_param import ( BetaRequestMCPServerURLDefinitionParam as BetaRequestMCPServerURLDefinitionParam, ) +from .beta_response_browser_new_tab_tool_use_block import ( + BetaResponseBrowserNewTabToolUseBlock as BetaResponseBrowserNewTabToolUseBlock, +) +from .beta_response_computer_scroll_tool_use_block import ( + BetaResponseComputerScrollToolUseBlock as BetaResponseComputerScrollToolUseBlock, +) from .beta_response_tool_change_mcp_tool_reference import ( BetaResponseToolChangeMCPToolReference as BetaResponseToolChangeMCPToolReference, ) @@ -932,9 +1147,21 @@ from .beta_managed_agents_vault_not_found_run_error import ( BetaManagedAgentsVaultNotFoundRunError as BetaManagedAgentsVaultNotFoundRunError, ) +from .beta_managed_agents_web_fetch_url_source_none import ( + BetaManagedAgentsWebFetchURLSourceNone as BetaManagedAgentsWebFetchURLSourceNone, +) +from .beta_managed_agents_web_fetch_url_source_only import ( + BetaManagedAgentsWebFetchURLSourceOnly as BetaManagedAgentsWebFetchURLSourceOnly, +) from .beta_memory_tool_20250818_str_replace_command import ( BetaMemoryTool20250818StrReplaceCommand as BetaMemoryTool20250818StrReplaceCommand, ) +from .beta_response_browser_hold_key_tool_use_block import ( + BetaResponseBrowserHoldKeyToolUseBlock as BetaResponseBrowserHoldKeyToolUseBlock, +) +from .beta_response_browser_navigate_tool_use_block import ( + BetaResponseBrowserNavigateToolUseBlock as BetaResponseBrowserNavigateToolUseBlock, +) from .beta_webhook_deployment_run_failed_event_data import ( BetaWebhookDeploymentRunFailedEventData as BetaWebhookDeploymentRunFailedEventData, ) @@ -953,6 +1180,21 @@ from .beta_managed_agents_system_content_block_param import ( BetaManagedAgentsSystemContentBlockParam as BetaManagedAgentsSystemContentBlockParam, ) +from .beta_response_browser_close_tab_tool_use_block import ( + BetaResponseBrowserCloseTabToolUseBlock as BetaResponseBrowserCloseTabToolUseBlock, +) +from .beta_response_browser_list_tabs_tool_use_block import ( + BetaResponseBrowserListTabsToolUseBlock as BetaResponseBrowserListTabsToolUseBlock, +) +from .beta_response_browser_read_page_tool_use_block import ( + BetaResponseBrowserReadPageToolUseBlock as BetaResponseBrowserReadPageToolUseBlock, +) +from .beta_response_browser_scroll_to_tool_use_block import ( + BetaResponseBrowserScrollToToolUseBlock as BetaResponseBrowserScrollToToolUseBlock, +) +from .beta_response_computer_hold_key_tool_use_block import ( + BetaResponseComputerHoldKeyToolUseBlock as BetaResponseComputerHoldKeyToolUseBlock, +) from .beta_user_profile_external_user_details_params import ( BetaUserProfileExternalUserDetailsParams as BetaUserProfileExternalUserDetailsParams, ) @@ -980,6 +1222,24 @@ from .beta_managed_agents_outcome_evaluation_resource import ( BetaManagedAgentsOutcomeEvaluationResource as BetaManagedAgentsOutcomeEvaluationResource, ) +from .beta_managed_agents_web_fetch_url_source_except import ( + BetaManagedAgentsWebFetchURLSourceExcept as BetaManagedAgentsWebFetchURLSourceExcept, +) +from .beta_response_browser_form_input_tool_use_block import ( + BetaResponseBrowserFormInputToolUseBlock as BetaResponseBrowserFormInputToolUseBlock, +) +from .beta_response_browser_left_click_tool_use_block import ( + BetaResponseBrowserLeftClickToolUseBlock as BetaResponseBrowserLeftClickToolUseBlock, +) +from .beta_response_browser_mouse_move_tool_use_block import ( + BetaResponseBrowserMouseMoveToolUseBlock as BetaResponseBrowserMouseMoveToolUseBlock, +) +from .beta_response_browser_screenshot_tool_use_block import ( + BetaResponseBrowserScreenshotToolUseBlock as BetaResponseBrowserScreenshotToolUseBlock, +) +from .beta_response_browser_switch_tab_tool_use_block import ( + BetaResponseBrowserSwitchTabToolUseBlock as BetaResponseBrowserSwitchTabToolUseBlock, +) from .beta_response_tool_change_mcp_toolset_reference import ( BetaResponseToolChangeMCPToolsetReference as BetaResponseToolChangeMCPToolsetReference, ) @@ -1016,12 +1276,30 @@ from .beta_managed_agents_web_fetch_tool_config_params import ( BetaManagedAgentsWebFetchToolConfigParams as BetaManagedAgentsWebFetchToolConfigParams, ) +from .beta_managed_agents_web_fetch_url_sources_params import ( + BetaManagedAgentsWebFetchURLSourcesParams as BetaManagedAgentsWebFetchURLSourcesParams, +) from .beta_managed_agents_workspace_archived_run_error import ( BetaManagedAgentsWorkspaceArchivedRunError as BetaManagedAgentsWorkspaceArchivedRunError, ) from .beta_request_mcp_server_tool_configuration_param import ( BetaRequestMCPServerToolConfigurationParam as BetaRequestMCPServerToolConfigurationParam, ) +from .beta_response_browser_file_upload_tool_use_block import ( + BetaResponseBrowserFileUploadToolUseBlock as BetaResponseBrowserFileUploadToolUseBlock, +) +from .beta_response_browser_right_click_tool_use_block import ( + BetaResponseBrowserRightClickToolUseBlock as BetaResponseBrowserRightClickToolUseBlock, +) +from .beta_response_computer_left_click_tool_use_block import ( + BetaResponseComputerLeftClickToolUseBlock as BetaResponseComputerLeftClickToolUseBlock, +) +from .beta_response_computer_mouse_move_tool_use_block import ( + BetaResponseComputerMouseMoveToolUseBlock as BetaResponseComputerMouseMoveToolUseBlock, +) +from .beta_response_computer_screenshot_tool_use_block import ( + BetaResponseComputerScreenshotToolUseBlock as BetaResponseComputerScreenshotToolUseBlock, +) from .beta_webhook_deployment_run_succeeded_event_data import ( BetaWebhookDeploymentRunSucceededEventData as BetaWebhookDeploymentRunSucceededEventData, ) @@ -1037,6 +1315,24 @@ from .beta_managed_agents_web_search_tool_config_params import ( BetaManagedAgentsWebSearchToolConfigParams as BetaManagedAgentsWebSearchToolConfigParams, ) +from .beta_response_browser_double_click_tool_use_block import ( + BetaResponseBrowserDoubleClickToolUseBlock as BetaResponseBrowserDoubleClickToolUseBlock, +) +from .beta_response_browser_middle_click_tool_use_block import ( + BetaResponseBrowserMiddleClickToolUseBlock as BetaResponseBrowserMiddleClickToolUseBlock, +) +from .beta_response_browser_read_console_tool_use_block import ( + BetaResponseBrowserReadConsoleToolUseBlock as BetaResponseBrowserReadConsoleToolUseBlock, +) +from .beta_response_browser_read_network_tool_use_block import ( + BetaResponseBrowserReadNetworkToolUseBlock as BetaResponseBrowserReadNetworkToolUseBlock, +) +from .beta_response_browser_triple_click_tool_use_block import ( + BetaResponseBrowserTripleClickToolUseBlock as BetaResponseBrowserTripleClickToolUseBlock, +) +from .beta_response_computer_right_click_tool_use_block import ( + BetaResponseComputerRightClickToolUseBlock as BetaResponseComputerRightClickToolUseBlock, +) from .beta_text_editor_code_execution_tool_result_block import ( BetaTextEditorCodeExecutionToolResultBlock as BetaTextEditorCodeExecutionToolResultBlock, ) @@ -1079,6 +1375,27 @@ from .beta_managed_agents_session_rate_limited_run_error import ( BetaManagedAgentsSessionRateLimitedRunError as BetaManagedAgentsSessionRateLimitedRunError, ) +from .beta_managed_agents_web_fetch_url_source_all_param import ( + BetaManagedAgentsWebFetchURLSourceAllParam as BetaManagedAgentsWebFetchURLSourceAllParam, +) +from .beta_managed_agents_web_fetch_url_source_shorthand import ( + BetaManagedAgentsWebFetchURLSourceShorthand as BetaManagedAgentsWebFetchURLSourceShorthand, +) +from .beta_response_browser_get_page_text_tool_use_block import ( + BetaResponseBrowserGetPageTextToolUseBlock as BetaResponseBrowserGetPageTextToolUseBlock, +) +from .beta_response_browser_left_mouse_up_tool_use_block import ( + BetaResponseBrowserLeftMouseUpToolUseBlock as BetaResponseBrowserLeftMouseUpToolUseBlock, +) +from .beta_response_computer_double_click_tool_use_block import ( + BetaResponseComputerDoubleClickToolUseBlock as BetaResponseComputerDoubleClickToolUseBlock, +) +from .beta_response_computer_middle_click_tool_use_block import ( + BetaResponseComputerMiddleClickToolUseBlock as BetaResponseComputerMiddleClickToolUseBlock, +) +from .beta_response_computer_triple_click_tool_use_block import ( + BetaResponseComputerTripleClickToolUseBlock as BetaResponseComputerTripleClickToolUseBlock, +) from .beta_webhook_session_status_rescheduled_event_data import ( BetaWebhookSessionStatusRescheduledEventData as BetaWebhookSessionStatusRescheduledEventData, ) @@ -1103,6 +1420,18 @@ from .beta_managed_agents_organization_disabled_run_error import ( BetaManagedAgentsOrganizationDisabledRunError as BetaManagedAgentsOrganizationDisabledRunError, ) +from .beta_managed_agents_web_fetch_url_source_none_param import ( + BetaManagedAgentsWebFetchURLSourceNoneParam as BetaManagedAgentsWebFetchURLSourceNoneParam, +) +from .beta_managed_agents_web_fetch_url_source_only_param import ( + BetaManagedAgentsWebFetchURLSourceOnlyParam as BetaManagedAgentsWebFetchURLSourceOnlyParam, +) +from .beta_managed_agents_web_fetch_url_source_user_input import ( + BetaManagedAgentsWebFetchURLSourceUserInput as BetaManagedAgentsWebFetchURLSourceUserInput, +) +from .beta_response_computer_left_mouse_up_tool_use_block import ( + BetaResponseComputerLeftMouseUpToolUseBlock as BetaResponseComputerLeftMouseUpToolUseBlock, +) from .beta_text_editor_code_execution_create_result_block import ( BetaTextEditorCodeExecutionCreateResultBlock as BetaTextEditorCodeExecutionCreateResultBlock, ) @@ -1124,6 +1453,18 @@ from .beta_managed_agents_agent_toolset20260401_read_input import ( BetaManagedAgentsAgentToolset20260401ReadInput as BetaManagedAgentsAgentToolset20260401ReadInput, ) +from .beta_managed_agents_web_fetch_url_source_tool_filter import ( + BetaManagedAgentsWebFetchURLSourceToolFilter as BetaManagedAgentsWebFetchURLSourceToolFilter, +) +from .beta_response_browser_javascript_exec_tool_use_block import ( + BetaResponseBrowserJavascriptExecToolUseBlock as BetaResponseBrowserJavascriptExecToolUseBlock, +) +from .beta_response_browser_left_click_drag_tool_use_block import ( + BetaResponseBrowserLeftClickDragToolUseBlock as BetaResponseBrowserLeftClickDragToolUseBlock, +) +from .beta_response_browser_left_mouse_down_tool_use_block import ( + BetaResponseBrowserLeftMouseDownToolUseBlock as BetaResponseBrowserLeftMouseDownToolUseBlock, +) from .beta_managed_agents_agent_toolset20260401_write_input import ( BetaManagedAgentsAgentToolset20260401WriteInput as BetaManagedAgentsAgentToolset20260401WriteInput, ) @@ -1136,6 +1477,18 @@ from .beta_managed_agents_mcp_toolset_default_config_params import ( BetaManagedAgentsMCPToolsetDefaultConfigParams as BetaManagedAgentsMCPToolsetDefaultConfigParams, ) +from .beta_managed_agents_web_fetch_url_source_except_param import ( + BetaManagedAgentsWebFetchURLSourceExceptParam as BetaManagedAgentsWebFetchURLSourceExceptParam, +) +from .beta_response_computer_cursor_position_tool_use_block import ( + BetaResponseComputerCursorPositionToolUseBlock as BetaResponseComputerCursorPositionToolUseBlock, +) +from .beta_response_computer_left_click_drag_tool_use_block import ( + BetaResponseComputerLeftClickDragToolUseBlock as BetaResponseComputerLeftClickDragToolUseBlock, +) +from .beta_response_computer_left_mouse_down_tool_use_block import ( + BetaResponseComputerLeftMouseDownToolUseBlock as BetaResponseComputerLeftMouseDownToolUseBlock, +) from .beta_web_search_tool_result_block_param_content_param import ( BetaWebSearchToolResultBlockParamContentParam as BetaWebSearchToolResultBlockParamContentParam, ) @@ -1145,6 +1498,9 @@ from .beta_managed_agents_session_creation_rejected_run_error import ( BetaManagedAgentsSessionCreationRejectedRunError as BetaManagedAgentsSessionCreationRejectedRunError, ) +from .beta_managed_agents_web_fetch_url_source_tool_reference import ( + BetaManagedAgentsWebFetchURLSourceToolReference as BetaManagedAgentsWebFetchURLSourceToolReference, +) from .beta_text_editor_code_execution_tool_result_block_param import ( BetaTextEditorCodeExecutionToolResultBlockParam as BetaTextEditorCodeExecutionToolResultBlockParam, ) @@ -1172,12 +1528,27 @@ from .beta_code_execution_tool_result_block_param_content_param import ( BetaCodeExecutionToolResultBlockParamContentParam as BetaCodeExecutionToolResultBlockParamContentParam, ) +from .beta_managed_agents_web_fetch_url_source_user_input_param import ( + BetaManagedAgentsWebFetchURLSourceUserInputParam as BetaManagedAgentsWebFetchURLSourceUserInputParam, +) from .beta_text_editor_code_execution_create_result_block_param import ( BetaTextEditorCodeExecutionCreateResultBlockParam as BetaTextEditorCodeExecutionCreateResultBlockParam, ) from .beta_managed_agents_unknown_deployment_paused_reason_error import ( BetaManagedAgentsUnknownDeploymentPausedReasonError as BetaManagedAgentsUnknownDeploymentPausedReasonError, ) +from .beta_managed_agents_web_fetch_url_source_tool_filter_param import ( + BetaManagedAgentsWebFetchURLSourceToolFilterParam as BetaManagedAgentsWebFetchURLSourceToolFilterParam, +) +from .beta_managed_agents_web_fetch_url_source_user_input_params import ( + BetaManagedAgentsWebFetchURLSourceUserInputParams as BetaManagedAgentsWebFetchURLSourceUserInputParams, +) +from .beta_managed_agents_web_fetch_url_source_tool_filter_params import ( + BetaManagedAgentsWebFetchURLSourceToolFilterParams as BetaManagedAgentsWebFetchURLSourceToolFilterParams, +) +from .beta_managed_agents_web_fetch_url_source_tool_reference_param import ( + BetaManagedAgentsWebFetchURLSourceToolReferenceParam as BetaManagedAgentsWebFetchURLSourceToolReferenceParam, +) from .beta_text_editor_code_execution_str_replace_result_block_param import ( BetaTextEditorCodeExecutionStrReplaceResultBlockParam as BetaTextEditorCodeExecutionStrReplaceResultBlockParam, ) diff --git a/src/anthropic/types/beta/agent_create_params.py b/src/anthropic/types/beta/agent_create_params.py index 041de800c..9444de05e 100644 --- a/src/anthropic/types/beta/agent_create_params.py +++ b/src/anthropic/types/beta/agent_create_params.py @@ -48,10 +48,7 @@ class AgentCreateParams(TypedDict, total=False): """ multiagent: Optional[BetaManagedAgentsMultiagentParams] - """Multiagent orchestration configuration. - - Currently supports the `coordinator` topology with a roster of 1-20 agents. - """ + """Multiagent orchestration configuration.""" skills: Iterable[BetaManagedAgentsSkillParams] """Skills available to the agent.""" @@ -62,7 +59,7 @@ class AgentCreateParams(TypedDict, total=False): tools: Iterable[Tool] """Tool configurations available to the agent. - Maximum of 128 tools across all toolsets allowed. + Maximum of 256 tools across all toolsets allowed. """ betas: List[AnthropicBetaParam] diff --git a/src/anthropic/types/beta/agent_update_params.py b/src/anthropic/types/beta/agent_update_params.py index 14f5a5f36..47750d63a 100644 --- a/src/anthropic/types/beta/agent_update_params.py +++ b/src/anthropic/types/beta/agent_update_params.py @@ -65,7 +65,7 @@ class AgentUpdateParams(TypedDict, total=False): """Tool configurations available to the agent. Full replacement. Omit to preserve; send empty array or null to clear. Maximum - of 128 tools across all toolsets allowed. + of 256 tools across all toolsets allowed. """ version: int diff --git a/src/anthropic/types/beta/beta_browser_click_target.py b/src/anthropic/types/beta/beta_browser_click_target.py new file mode 100644 index 000000000..cd562747b --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_click_target.py @@ -0,0 +1,12 @@ +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from ..._models import UnionDiscriminator +from .beta_browser_ref_target import BetaBrowserRefTarget +from .beta_browser_coordinate_target import BetaBrowserCoordinateTarget + +__all__ = ["BetaBrowserClickTarget"] + +BetaBrowserClickTarget: TypeAlias = Annotated[ + Union[BetaBrowserCoordinateTarget, BetaBrowserRefTarget], UnionDiscriminator("type") +] diff --git a/src/anthropic/types/beta/beta_browser_close_tab_input.py b/src/anthropic/types/beta/beta_browser_close_tab_input.py new file mode 100644 index 000000000..ff53c7212 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_close_tab_input.py @@ -0,0 +1,10 @@ +from ..._models import BaseModel + +__all__ = ["BetaBrowserCloseTabInput"] + + +class BetaBrowserCloseTabInput(BaseModel): + """Close the tab with the given tab_id.""" + + tab_id: str + """The tab to close.""" diff --git a/src/anthropic/types/beta/beta_browser_close_tab_tool_use_block.py b/src/anthropic/types/beta/beta_browser_close_tab_tool_use_block.py new file mode 100644 index 000000000..c8110a2f1 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_close_tab_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_close_tab_input import BetaBrowserCloseTabInput + +__all__ = ["BetaBrowserCloseTabToolUseBlock"] + + +class BetaBrowserCloseTabToolUseBlock(BaseModel): + id: str + + input: BetaBrowserCloseTabInput + """Close the tab with the given tab_id.""" + + name: Literal["close_tab"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_coordinate_target.py b/src/anthropic/types/beta/beta_browser_coordinate_target.py new file mode 100644 index 000000000..a3b03ca8c --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_coordinate_target.py @@ -0,0 +1,20 @@ +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["BetaBrowserCoordinateTarget"] + + +class BetaBrowserCoordinateTarget(BaseModel): + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + type: Literal["coordinate"] + + x: int + """Pixels from the left edge of the viewport.""" + + y: int + """Pixels from the top edge of the viewport.""" diff --git a/src/anthropic/types/beta/beta_browser_double_click_input.py b/src/anthropic/types/beta/beta_browser_double_click_input.py new file mode 100644 index 000000000..086af96f9 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_double_click_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_click_target import BetaBrowserClickTarget + +__all__ = ["BetaBrowserDoubleClickInput"] + + +class BetaBrowserDoubleClickInput(BaseModel): + """Double left-click at a viewport coordinate or on an element by reference.""" + + target: BetaBrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + modifiers: Optional[str] = None + """Optional modifier key chord to hold for the duration of this action (e.g. + + "shift", "ctrl+shift", "cmd+alt"). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_double_click_tool_use_block.py b/src/anthropic/types/beta/beta_browser_double_click_tool_use_block.py new file mode 100644 index 000000000..8bbb437cc --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_double_click_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_double_click_input import BetaBrowserDoubleClickInput + +__all__ = ["BetaBrowserDoubleClickToolUseBlock"] + + +class BetaBrowserDoubleClickToolUseBlock(BaseModel): + id: str + + input: BetaBrowserDoubleClickInput + """Double left-click at a viewport coordinate or on an element by reference.""" + + name: Literal["double_click"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_file_upload_input.py b/src/anthropic/types/beta/beta_browser_file_upload_input.py new file mode 100644 index 000000000..03a711c46 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_file_upload_input.py @@ -0,0 +1,33 @@ +from typing import List, Optional + +from ..._models import BaseModel +from .beta_browser_ref_target import BetaBrowserRefTarget + +__all__ = ["BetaBrowserFileUploadInput"] + + +class BetaBrowserFileUploadInput(BaseModel): + """Set the value of a file-input element to one or more files. + + The target must be an + element reference; at least one of paths or document_ids is required. + """ + + target: BetaBrowserRefTarget + """ + An element on the page, identified by a reference from a prior `read_page` or + `find` result. References are scoped to the tab that produced them and become + stale after navigation or a major re-render. + """ + + document_ids: Optional[List[str]] = None + """ + References to files the harness has staged, for deployments where the browser + executor cannot read the caller's filesystem. + """ + + paths: Optional[List[str]] = None + """File paths on the browser executor's filesystem.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_file_upload_tool_use_block.py b/src/anthropic/types/beta/beta_browser_file_upload_tool_use_block.py new file mode 100644 index 000000000..ba401e89f --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_file_upload_tool_use_block.py @@ -0,0 +1,31 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_file_upload_input import BetaBrowserFileUploadInput + +__all__ = ["BetaBrowserFileUploadToolUseBlock"] + + +class BetaBrowserFileUploadToolUseBlock(BaseModel): + id: str + + input: BetaBrowserFileUploadInput + """Set the value of a file-input element to one or more files. + + The target must be an element reference; at least one of paths or document_ids + is required. + """ + + name: Literal["file_upload"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_find_input.py b/src/anthropic/types/beta/beta_browser_find_input.py new file mode 100644 index 000000000..a57d093f4 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_find_input.py @@ -0,0 +1,19 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserFindInput"] + + +class BetaBrowserFindInput(BaseModel): + """Find elements matching a natural-language description (e.g. + + "search bar", "add to + cart button") and return up to 20 matches with element references. + """ + + query: str + """Natural-language description of the element(s) to find.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_find_tool_use_block.py b/src/anthropic/types/beta/beta_browser_find_tool_use_block.py new file mode 100644 index 000000000..4e8e37a3e --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_find_tool_use_block.py @@ -0,0 +1,31 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_find_input import BetaBrowserFindInput + +__all__ = ["BetaBrowserFindToolUseBlock"] + + +class BetaBrowserFindToolUseBlock(BaseModel): + id: str + + input: BetaBrowserFindInput + """Find elements matching a natural-language description (e.g. + + "search bar", "add to cart button") and return up to 20 matches with element + references. + """ + + name: Literal["find"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_form_input_input.py b/src/anthropic/types/beta/beta_browser_form_input_input.py new file mode 100644 index 000000000..42928fa74 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_form_input_input.py @@ -0,0 +1,28 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_ref_target import BetaBrowserRefTarget +from .beta_browser_form_input_value import BetaBrowserFormInputValue + +__all__ = ["BetaBrowserFormInputInput"] + + +class BetaBrowserFormInputInput(BaseModel): + """Set the value of a form element (input, textarea, select, checkbox). + + Use a + boolean for checkboxes, an option value or text for selects. + """ + + target: BetaBrowserRefTarget + """ + An element on the page, identified by a reference from a prior `read_page` or + `find` result. References are scoped to the tab that produced them and become + stale after navigation or a major re-render. + """ + + value: BetaBrowserFormInputValue + """The value to set.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_form_input_tool_use_block.py b/src/anthropic/types/beta/beta_browser_form_input_tool_use_block.py new file mode 100644 index 000000000..147116464 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_form_input_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_form_input_input import BetaBrowserFormInputInput + +__all__ = ["BetaBrowserFormInputToolUseBlock"] + + +class BetaBrowserFormInputToolUseBlock(BaseModel): + id: str + + input: BetaBrowserFormInputInput + """Set the value of a form element (input, textarea, select, checkbox). + + Use a boolean for checkboxes, an option value or text for selects. + """ + + name: Literal["form_input"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_form_input_value.py b/src/anthropic/types/beta/beta_browser_form_input_value.py new file mode 100644 index 000000000..211bc67a7 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_form_input_value.py @@ -0,0 +1,6 @@ +from typing import Union +from typing_extensions import TypeAlias + +__all__ = ["BetaBrowserFormInputValue"] + +BetaBrowserFormInputValue: TypeAlias = Union[str, float, bool] diff --git a/src/anthropic/types/beta/beta_browser_get_page_text_input.py b/src/anthropic/types/beta/beta_browser_get_page_text_input.py new file mode 100644 index 000000000..92483e26b --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_get_page_text_input.py @@ -0,0 +1,15 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserGetPageTextInput"] + + +class BetaBrowserGetPageTextInput(BaseModel): + """ + Return the page's visible text content as plain text, prioritizing article + content. Suited to articles, documentation, and other text-heavy pages. + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_get_page_text_tool_use_block.py b/src/anthropic/types/beta/beta_browser_get_page_text_tool_use_block.py new file mode 100644 index 000000000..8d851773b --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_get_page_text_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_get_page_text_input import BetaBrowserGetPageTextInput + +__all__ = ["BetaBrowserGetPageTextToolUseBlock"] + + +class BetaBrowserGetPageTextToolUseBlock(BaseModel): + id: str + + input: BetaBrowserGetPageTextInput + """ + Return the page's visible text content as plain text, prioritizing article + content. Suited to articles, documentation, and other text-heavy pages. + """ + + name: Literal["get_page_text"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_hold_key_input.py b/src/anthropic/types/beta/beta_browser_hold_key_input.py new file mode 100644 index 000000000..71aa7957a --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_hold_key_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserHoldKeyInput"] + + +class BetaBrowserHoldKeyInput(BaseModel): + """Hold a key or key chord down for a duration, then release it. + + Uses the same key + names and "+" chord syntax as the key action. + """ + + duration: float + """Seconds to hold the key down (maximum 30).""" + + text: str + """The key or chord to hold.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_hold_key_tool_use_block.py b/src/anthropic/types/beta/beta_browser_hold_key_tool_use_block.py new file mode 100644 index 000000000..30bdeee08 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_hold_key_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_hold_key_input import BetaBrowserHoldKeyInput + +__all__ = ["BetaBrowserHoldKeyToolUseBlock"] + + +class BetaBrowserHoldKeyToolUseBlock(BaseModel): + id: str + + input: BetaBrowserHoldKeyInput + """Hold a key or key chord down for a duration, then release it. + + Uses the same key names and "+" chord syntax as the key action. + """ + + name: Literal["hold_key"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_hover_input.py b/src/anthropic/types/beta/beta_browser_hover_input.py new file mode 100644 index 000000000..a7fe737aa --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_hover_input.py @@ -0,0 +1,16 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_click_target import BetaBrowserClickTarget + +__all__ = ["BetaBrowserHoverInput"] + + +class BetaBrowserHoverInput(BaseModel): + """Move the cursor to a coordinate or element without clicking.""" + + target: BetaBrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_hover_tool_use_block.py b/src/anthropic/types/beta/beta_browser_hover_tool_use_block.py new file mode 100644 index 000000000..0576932bd --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_hover_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_hover_input import BetaBrowserHoverInput + +__all__ = ["BetaBrowserHoverToolUseBlock"] + + +class BetaBrowserHoverToolUseBlock(BaseModel): + id: str + + input: BetaBrowserHoverInput + """Move the cursor to a coordinate or element without clicking.""" + + name: Literal["hover"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_javascript_exec_input.py b/src/anthropic/types/beta/beta_browser_javascript_exec_input.py new file mode 100644 index 000000000..b3818f629 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_javascript_exec_input.py @@ -0,0 +1,19 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserJavascriptExecInput"] + + +class BetaBrowserJavascriptExecInput(BaseModel): + """ + Execute JavaScript in the page context and return the value of the last + expression. The code runs with access to the DOM, `window`, and page variables. + Write the expression you want evaluated — do NOT use `return`. + """ + + text: str + """JavaScript to execute in the page context.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_javascript_exec_tool_use_block.py b/src/anthropic/types/beta/beta_browser_javascript_exec_tool_use_block.py new file mode 100644 index 000000000..5a5a3a81d --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_javascript_exec_tool_use_block.py @@ -0,0 +1,31 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_javascript_exec_input import BetaBrowserJavascriptExecInput + +__all__ = ["BetaBrowserJavascriptExecToolUseBlock"] + + +class BetaBrowserJavascriptExecToolUseBlock(BaseModel): + id: str + + input: BetaBrowserJavascriptExecInput + """ + Execute JavaScript in the page context and return the value of the last + expression. The code runs with access to the DOM, `window`, and page variables. + Write the expression you want evaluated — do NOT use `return`. + """ + + name: Literal["javascript_exec"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_key_input.py b/src/anthropic/types/beta/beta_browser_key_input.py new file mode 100644 index 000000000..8ab8af21c --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_key_input.py @@ -0,0 +1,23 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserKeyInput"] + + +class BetaBrowserKeyInput(BaseModel): + """Press a key or key chord. + + Use "+" to combine modifiers with a key (e.g. "ctrl+a", + "cmd+shift+p") and space to sequence presses (e.g. "Backspace Backspace Delete"). + Common names like "Return", "Tab", "Escape", "BackSpace" are supported. + """ + + text: str + """The key, chord, or space-separated sequence to press.""" + + repeat: Optional[int] = None + """Number of times to repeat. Default 1.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_key_tool_use_block.py b/src/anthropic/types/beta/beta_browser_key_tool_use_block.py new file mode 100644 index 000000000..52bfff92b --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_key_tool_use_block.py @@ -0,0 +1,32 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_key_input import BetaBrowserKeyInput + +__all__ = ["BetaBrowserKeyToolUseBlock"] + + +class BetaBrowserKeyToolUseBlock(BaseModel): + id: str + + input: BetaBrowserKeyInput + """Press a key or key chord. + + Use "+" to combine modifiers with a key (e.g. "ctrl+a", "cmd+shift+p") and space + to sequence presses (e.g. "Backspace Backspace Delete"). Common names like + "Return", "Tab", "Escape", "BackSpace" are supported. + """ + + name: Literal["key"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_left_click_drag_input.py b/src/anthropic/types/beta/beta_browser_left_click_drag_input.py new file mode 100644 index 000000000..889359391 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_left_click_drag_input.py @@ -0,0 +1,27 @@ +from typing import Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel +from .beta_browser_coordinate_target import BetaBrowserCoordinateTarget + +__all__ = ["BetaBrowserLeftClickDragInput"] + + +class BetaBrowserLeftClickDragInput(BaseModel): + """Press at `from`, drag to `target`, release. Both must be coordinate targets.""" + + from_: BetaBrowserCoordinateTarget = FieldInfo(alias="from") + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + target: BetaBrowserCoordinateTarget + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_left_click_drag_tool_use_block.py b/src/anthropic/types/beta/beta_browser_left_click_drag_tool_use_block.py new file mode 100644 index 000000000..02085f970 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_left_click_drag_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_left_click_drag_input import BetaBrowserLeftClickDragInput + +__all__ = ["BetaBrowserLeftClickDragToolUseBlock"] + + +class BetaBrowserLeftClickDragToolUseBlock(BaseModel): + id: str + + input: BetaBrowserLeftClickDragInput + """Press at `from`, drag to `target`, release. Both must be coordinate targets.""" + + name: Literal["left_click_drag"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_left_click_input.py b/src/anthropic/types/beta/beta_browser_left_click_input.py new file mode 100644 index 000000000..027585c66 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_left_click_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_click_target import BetaBrowserClickTarget + +__all__ = ["BetaBrowserLeftClickInput"] + + +class BetaBrowserLeftClickInput(BaseModel): + """Left-click at a viewport coordinate or on an element by reference.""" + + target: BetaBrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + modifiers: Optional[str] = None + """Optional modifier key chord to hold for the duration of this action (e.g. + + "shift", "ctrl+shift", "cmd+alt"). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_left_click_tool_use_block.py b/src/anthropic/types/beta/beta_browser_left_click_tool_use_block.py new file mode 100644 index 000000000..8a17054d7 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_left_click_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_left_click_input import BetaBrowserLeftClickInput + +__all__ = ["BetaBrowserLeftClickToolUseBlock"] + + +class BetaBrowserLeftClickToolUseBlock(BaseModel): + id: str + + input: BetaBrowserLeftClickInput + """Left-click at a viewport coordinate or on an element by reference.""" + + name: Literal["left_click"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_left_mouse_down_input.py b/src/anthropic/types/beta/beta_browser_left_mouse_down_input.py new file mode 100644 index 000000000..c2ed8fae1 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_left_mouse_down_input.py @@ -0,0 +1,23 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_coordinate_target import BetaBrowserCoordinateTarget + +__all__ = ["BetaBrowserLeftMouseDownInput"] + + +class BetaBrowserLeftMouseDownInput(BaseModel): + """Press and hold the left mouse button at a viewport coordinate. + + Pair with + left_mouse_up to perform a custom drag. + """ + + target: BetaBrowserCoordinateTarget + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_left_mouse_down_tool_use_block.py b/src/anthropic/types/beta/beta_browser_left_mouse_down_tool_use_block.py new file mode 100644 index 000000000..5660e5a57 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_left_mouse_down_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_left_mouse_down_input import BetaBrowserLeftMouseDownInput + +__all__ = ["BetaBrowserLeftMouseDownToolUseBlock"] + + +class BetaBrowserLeftMouseDownToolUseBlock(BaseModel): + id: str + + input: BetaBrowserLeftMouseDownInput + """Press and hold the left mouse button at a viewport coordinate. + + Pair with left_mouse_up to perform a custom drag. + """ + + name: Literal["left_mouse_down"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_left_mouse_up_input.py b/src/anthropic/types/beta/beta_browser_left_mouse_up_input.py new file mode 100644 index 000000000..ea4bb2ef5 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_left_mouse_up_input.py @@ -0,0 +1,19 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_coordinate_target import BetaBrowserCoordinateTarget + +__all__ = ["BetaBrowserLeftMouseUpInput"] + + +class BetaBrowserLeftMouseUpInput(BaseModel): + """Release the left mouse button at a viewport coordinate.""" + + target: BetaBrowserCoordinateTarget + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_left_mouse_up_tool_use_block.py b/src/anthropic/types/beta/beta_browser_left_mouse_up_tool_use_block.py new file mode 100644 index 000000000..c6c310022 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_left_mouse_up_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_left_mouse_up_input import BetaBrowserLeftMouseUpInput + +__all__ = ["BetaBrowserLeftMouseUpToolUseBlock"] + + +class BetaBrowserLeftMouseUpToolUseBlock(BaseModel): + id: str + + input: BetaBrowserLeftMouseUpInput + """Release the left mouse button at a viewport coordinate.""" + + name: Literal["left_mouse_up"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_list_tabs_input.py b/src/anthropic/types/beta/beta_browser_list_tabs_input.py new file mode 100644 index 000000000..8d5b24ac9 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_list_tabs_input.py @@ -0,0 +1,9 @@ +from ..._models import BaseModel + +__all__ = ["BetaBrowserListTabsInput"] + + +class BetaBrowserListTabsInput(BaseModel): + """List all open tabs with each tab's tab_id, title, and URL.""" + + pass diff --git a/src/anthropic/types/beta/beta_browser_list_tabs_tool_use_block.py b/src/anthropic/types/beta/beta_browser_list_tabs_tool_use_block.py new file mode 100644 index 000000000..7ce66d1a8 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_list_tabs_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_list_tabs_input import BetaBrowserListTabsInput + +__all__ = ["BetaBrowserListTabsToolUseBlock"] + + +class BetaBrowserListTabsToolUseBlock(BaseModel): + id: str + + input: BetaBrowserListTabsInput + """List all open tabs with each tab's tab_id, title, and URL.""" + + name: Literal["list_tabs"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_member_input.py b/src/anthropic/types/beta/beta_browser_member_input.py new file mode 100644 index 000000000..7186b3a5f --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_member_input.py @@ -0,0 +1,70 @@ +from typing import Union +from typing_extensions import TypeAlias + +from .beta_browser_key_input import BetaBrowserKeyInput +from .beta_browser_find_input import BetaBrowserFindInput +from .beta_browser_type_input import BetaBrowserTypeInput +from .beta_browser_wait_input import BetaBrowserWaitInput +from .beta_browser_zoom_input import BetaBrowserZoomInput +from .beta_browser_hover_input import BetaBrowserHoverInput +from .beta_browser_scroll_input import BetaBrowserScrollInput +from .beta_browser_new_tab_input import BetaBrowserNewTabInput +from .beta_browser_hold_key_input import BetaBrowserHoldKeyInput +from .beta_browser_navigate_input import BetaBrowserNavigateInput +from .beta_browser_close_tab_input import BetaBrowserCloseTabInput +from .beta_browser_list_tabs_input import BetaBrowserListTabsInput +from .beta_browser_read_page_input import BetaBrowserReadPageInput +from .beta_browser_scroll_to_input import BetaBrowserScrollToInput +from .beta_browser_form_input_input import BetaBrowserFormInputInput +from .beta_browser_left_click_input import BetaBrowserLeftClickInput +from .beta_browser_mouse_move_input import BetaBrowserMouseMoveInput +from .beta_browser_screenshot_input import BetaBrowserScreenshotInput +from .beta_browser_switch_tab_input import BetaBrowserSwitchTabInput +from .beta_browser_file_upload_input import BetaBrowserFileUploadInput +from .beta_browser_right_click_input import BetaBrowserRightClickInput +from .beta_browser_double_click_input import BetaBrowserDoubleClickInput +from .beta_browser_middle_click_input import BetaBrowserMiddleClickInput +from .beta_browser_read_console_input import BetaBrowserReadConsoleInput +from .beta_browser_read_network_input import BetaBrowserReadNetworkInput +from .beta_browser_triple_click_input import BetaBrowserTripleClickInput +from .beta_browser_get_page_text_input import BetaBrowserGetPageTextInput +from .beta_browser_left_mouse_up_input import BetaBrowserLeftMouseUpInput +from .beta_browser_javascript_exec_input import BetaBrowserJavascriptExecInput +from .beta_browser_left_click_drag_input import BetaBrowserLeftClickDragInput +from .beta_browser_left_mouse_down_input import BetaBrowserLeftMouseDownInput + +__all__ = ["BetaBrowserMemberInput"] + +BetaBrowserMemberInput: TypeAlias = Union[ + BetaBrowserNavigateInput, + BetaBrowserListTabsInput, + BetaBrowserNewTabInput, + BetaBrowserSwitchTabInput, + BetaBrowserCloseTabInput, + BetaBrowserReadPageInput, + BetaBrowserGetPageTextInput, + BetaBrowserReadConsoleInput, + BetaBrowserReadNetworkInput, + BetaBrowserFindInput, + BetaBrowserFormInputInput, + BetaBrowserFileUploadInput, + BetaBrowserScrollToInput, + BetaBrowserScreenshotInput, + BetaBrowserZoomInput, + BetaBrowserLeftClickInput, + BetaBrowserRightClickInput, + BetaBrowserMiddleClickInput, + BetaBrowserDoubleClickInput, + BetaBrowserTripleClickInput, + BetaBrowserHoverInput, + BetaBrowserLeftClickDragInput, + BetaBrowserLeftMouseDownInput, + BetaBrowserLeftMouseUpInput, + BetaBrowserMouseMoveInput, + BetaBrowserScrollInput, + BetaBrowserTypeInput, + BetaBrowserKeyInput, + BetaBrowserHoldKeyInput, + BetaBrowserWaitInput, + BetaBrowserJavascriptExecInput, +] diff --git a/src/anthropic/types/beta/beta_browser_member_name.py b/src/anthropic/types/beta/beta_browser_member_name.py new file mode 100644 index 000000000..37f54c080 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_member_name.py @@ -0,0 +1,72 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["BetaBrowserMemberName", "BETA_BROWSER_MEMBER_NAME_VALUES"] + +BetaBrowserMemberName: TypeAlias = Literal[ + "navigate", + "list_tabs", + "new_tab", + "switch_tab", + "close_tab", + "read_page", + "get_page_text", + "read_console", + "read_network", + "find", + "form_input", + "file_upload", + "scroll_to", + "screenshot", + "zoom", + "left_click", + "right_click", + "middle_click", + "double_click", + "triple_click", + "hover", + "left_click_drag", + "left_mouse_down", + "left_mouse_up", + "mouse_move", + "scroll", + "type", + "key", + "hold_key", + "wait", + "javascript_exec", +] + +BETA_BROWSER_MEMBER_NAME_VALUES: tuple[BetaBrowserMemberName, ...] = ( + "navigate", + "list_tabs", + "new_tab", + "switch_tab", + "close_tab", + "read_page", + "get_page_text", + "read_console", + "read_network", + "find", + "form_input", + "file_upload", + "scroll_to", + "screenshot", + "zoom", + "left_click", + "right_click", + "middle_click", + "double_click", + "triple_click", + "hover", + "left_click_drag", + "left_mouse_down", + "left_mouse_up", + "mouse_move", + "scroll", + "type", + "key", + "hold_key", + "wait", + "javascript_exec", +) +"""The values of `BetaBrowserMemberName`.""" diff --git a/src/anthropic/types/beta/beta_browser_middle_click_input.py b/src/anthropic/types/beta/beta_browser_middle_click_input.py new file mode 100644 index 000000000..6fd5898a0 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_middle_click_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_click_target import BetaBrowserClickTarget + +__all__ = ["BetaBrowserMiddleClickInput"] + + +class BetaBrowserMiddleClickInput(BaseModel): + """Middle-click at a viewport coordinate or on an element by reference.""" + + target: BetaBrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + modifiers: Optional[str] = None + """Optional modifier key chord to hold for the duration of this action (e.g. + + "shift", "ctrl+shift", "cmd+alt"). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_middle_click_tool_use_block.py b/src/anthropic/types/beta/beta_browser_middle_click_tool_use_block.py new file mode 100644 index 000000000..7584acf60 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_middle_click_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_middle_click_input import BetaBrowserMiddleClickInput + +__all__ = ["BetaBrowserMiddleClickToolUseBlock"] + + +class BetaBrowserMiddleClickToolUseBlock(BaseModel): + id: str + + input: BetaBrowserMiddleClickInput + """Middle-click at a viewport coordinate or on an element by reference.""" + + name: Literal["middle_click"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_mouse_move_input.py b/src/anthropic/types/beta/beta_browser_mouse_move_input.py new file mode 100644 index 000000000..6a6172079 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_mouse_move_input.py @@ -0,0 +1,19 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_coordinate_target import BetaBrowserCoordinateTarget + +__all__ = ["BetaBrowserMouseMoveInput"] + + +class BetaBrowserMouseMoveInput(BaseModel): + """Move the pointer to a viewport coordinate without clicking.""" + + target: BetaBrowserCoordinateTarget + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_mouse_move_tool_use_block.py b/src/anthropic/types/beta/beta_browser_mouse_move_tool_use_block.py new file mode 100644 index 000000000..f90cc4e1a --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_mouse_move_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_mouse_move_input import BetaBrowserMouseMoveInput + +__all__ = ["BetaBrowserMouseMoveToolUseBlock"] + + +class BetaBrowserMouseMoveToolUseBlock(BaseModel): + id: str + + input: BetaBrowserMouseMoveInput + """Move the pointer to a viewport coordinate without clicking.""" + + name: Literal["mouse_move"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_navigate_input.py b/src/anthropic/types/beta/beta_browser_navigate_input.py new file mode 100644 index 000000000..8d3229c56 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_navigate_input.py @@ -0,0 +1,21 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserNavigateInput"] + + +class BetaBrowserNavigateInput(BaseModel): + """Navigate to a URL, or go back/forward/reload in history. + + The protocol may be + omitted (defaults to https://). + """ + + url: str + """ + The URL to navigate to, or "back" / "forward" / "reload" for history navigation. + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_navigate_tool_use_block.py b/src/anthropic/types/beta/beta_browser_navigate_tool_use_block.py new file mode 100644 index 000000000..a6eeae0e9 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_navigate_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_navigate_input import BetaBrowserNavigateInput + +__all__ = ["BetaBrowserNavigateToolUseBlock"] + + +class BetaBrowserNavigateToolUseBlock(BaseModel): + id: str + + input: BetaBrowserNavigateInput + """Navigate to a URL, or go back/forward/reload in history. + + The protocol may be omitted (defaults to https://). + """ + + name: Literal["navigate"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_new_tab_input.py b/src/anthropic/types/beta/beta_browser_new_tab_input.py new file mode 100644 index 000000000..4fa6edcd9 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_new_tab_input.py @@ -0,0 +1,9 @@ +from ..._models import BaseModel + +__all__ = ["BetaBrowserNewTabInput"] + + +class BetaBrowserNewTabInput(BaseModel): + """Open a new empty tab and return its tab_id.""" + + pass diff --git a/src/anthropic/types/beta/beta_browser_new_tab_tool_use_block.py b/src/anthropic/types/beta/beta_browser_new_tab_tool_use_block.py new file mode 100644 index 000000000..3315f4777 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_new_tab_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_new_tab_input import BetaBrowserNewTabInput + +__all__ = ["BetaBrowserNewTabToolUseBlock"] + + +class BetaBrowserNewTabToolUseBlock(BaseModel): + id: str + + input: BetaBrowserNewTabInput + """Open a new empty tab and return its tab_id.""" + + name: Literal["new_tab"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_read_console_input.py b/src/anthropic/types/beta/beta_browser_read_console_input.py new file mode 100644 index 000000000..3a5c72042 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_read_console_input.py @@ -0,0 +1,16 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserReadConsoleInput"] + + +class BetaBrowserReadConsoleInput(BaseModel): + """ + Return console output (log entries, errors, warnings) accumulated since the + driver attached to the tab and since the last read, one line per entry. An empty + result does not mean no traffic for a tab that predates attach. + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_read_console_tool_use_block.py b/src/anthropic/types/beta/beta_browser_read_console_tool_use_block.py new file mode 100644 index 000000000..e4afdce1a --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_read_console_tool_use_block.py @@ -0,0 +1,31 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_read_console_input import BetaBrowserReadConsoleInput + +__all__ = ["BetaBrowserReadConsoleToolUseBlock"] + + +class BetaBrowserReadConsoleToolUseBlock(BaseModel): + id: str + + input: BetaBrowserReadConsoleInput + """ + Return console output (log entries, errors, warnings) accumulated since the + driver attached to the tab and since the last read, one line per entry. An empty + result does not mean no traffic for a tab that predates attach. + """ + + name: Literal["read_console"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_read_network_input.py b/src/anthropic/types/beta/beta_browser_read_network_input.py new file mode 100644 index 000000000..e42fea89f --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_read_network_input.py @@ -0,0 +1,16 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserReadNetworkInput"] + + +class BetaBrowserReadNetworkInput(BaseModel): + """ + Return the network requests (method, URL, status, MIME type, timing) recorded + since the driver attached to the tab and since the last read, one line per entry. + An empty result does not mean no traffic for a tab that predates attach. + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_read_network_tool_use_block.py b/src/anthropic/types/beta/beta_browser_read_network_tool_use_block.py new file mode 100644 index 000000000..92fb42aae --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_read_network_tool_use_block.py @@ -0,0 +1,31 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_read_network_input import BetaBrowserReadNetworkInput + +__all__ = ["BetaBrowserReadNetworkToolUseBlock"] + + +class BetaBrowserReadNetworkToolUseBlock(BaseModel): + id: str + + input: BetaBrowserReadNetworkInput + """ + Return the network requests (method, URL, status, MIME type, timing) recorded + since the driver attached to the tab and since the last read, one line per + entry. An empty result does not mean no traffic for a tab that predates attach. + """ + + name: Literal["read_network"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_read_page_filter.py b/src/anthropic/types/beta/beta_browser_read_page_filter.py new file mode 100644 index 000000000..da153371a --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_read_page_filter.py @@ -0,0 +1,5 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["BetaBrowserReadPageFilter"] + +BetaBrowserReadPageFilter: TypeAlias = Literal["all", "interactive"] diff --git a/src/anthropic/types/beta/beta_browser_read_page_input.py b/src/anthropic/types/beta/beta_browser_read_page_input.py new file mode 100644 index 000000000..636301664 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_read_page_input.py @@ -0,0 +1,31 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_read_page_filter import BetaBrowserReadPageFilter + +__all__ = ["BetaBrowserReadPageInput"] + + +class BetaBrowserReadPageInput(BaseModel): + """ + Return a structured accessibility tree of the page (or the subtree rooted at + `ref`), with element references like [ref_7] that can be used as targets on later + actions. Output is capped at 50,000 characters — narrow with `ref` or a smaller + `depth` when exceeded. + """ + + depth: Optional[int] = None + """Maximum tree depth. Default 15.""" + + filter: Optional[BetaBrowserReadPageFilter] = None + """Which elements to include. + + Omitted: every visible element. "interactive": interactive elements only. "all": + additionally includes off-viewport elements. + """ + + ref: Optional[str] = None + """Element reference to read a subtree from. Omit to read from the page root.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_read_page_tool_use_block.py b/src/anthropic/types/beta/beta_browser_read_page_tool_use_block.py new file mode 100644 index 000000000..3b7e3658a --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_read_page_tool_use_block.py @@ -0,0 +1,32 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_read_page_input import BetaBrowserReadPageInput + +__all__ = ["BetaBrowserReadPageToolUseBlock"] + + +class BetaBrowserReadPageToolUseBlock(BaseModel): + id: str + + input: BetaBrowserReadPageInput + """ + Return a structured accessibility tree of the page (or the subtree rooted at + `ref`), with element references like [ref_7] that can be used as targets on + later actions. Output is capped at 50,000 characters — narrow with `ref` or a + smaller `depth` when exceeded. + """ + + name: Literal["read_page"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_ref_target.py b/src/anthropic/types/beta/beta_browser_ref_target.py new file mode 100644 index 000000000..853360f3c --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_ref_target.py @@ -0,0 +1,21 @@ +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["BetaBrowserRefTarget"] + + +class BetaBrowserRefTarget(BaseModel): + """ + An element on the page, identified by a reference from a prior `read_page` or + `find` result. References are scoped to the tab that produced them and become + stale after navigation or a major re-render. + """ + + ref: str + """An element reference (e.g. + + "ref_7") returned by a prior `read_page` or `find` result. + """ + + type: Literal["ref"] diff --git a/src/anthropic/types/beta/beta_browser_right_click_input.py b/src/anthropic/types/beta/beta_browser_right_click_input.py new file mode 100644 index 000000000..8efaa0262 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_right_click_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_click_target import BetaBrowserClickTarget + +__all__ = ["BetaBrowserRightClickInput"] + + +class BetaBrowserRightClickInput(BaseModel): + """Right-click at a viewport coordinate or on an element by reference.""" + + target: BetaBrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + modifiers: Optional[str] = None + """Optional modifier key chord to hold for the duration of this action (e.g. + + "shift", "ctrl+shift", "cmd+alt"). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_right_click_tool_use_block.py b/src/anthropic/types/beta/beta_browser_right_click_tool_use_block.py new file mode 100644 index 000000000..e49d18da5 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_right_click_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_right_click_input import BetaBrowserRightClickInput + +__all__ = ["BetaBrowserRightClickToolUseBlock"] + + +class BetaBrowserRightClickToolUseBlock(BaseModel): + id: str + + input: BetaBrowserRightClickInput + """Right-click at a viewport coordinate or on an element by reference.""" + + name: Literal["right_click"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_screenshot_input.py b/src/anthropic/types/beta/beta_browser_screenshot_input.py new file mode 100644 index 000000000..fc9525c4f --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_screenshot_input.py @@ -0,0 +1,12 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserScreenshotInput"] + + +class BetaBrowserScreenshotInput(BaseModel): + """Capture the current browser viewport.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_screenshot_tool_use_block.py b/src/anthropic/types/beta/beta_browser_screenshot_tool_use_block.py new file mode 100644 index 000000000..89b783048 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_screenshot_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_screenshot_input import BetaBrowserScreenshotInput + +__all__ = ["BetaBrowserScreenshotToolUseBlock"] + + +class BetaBrowserScreenshotToolUseBlock(BaseModel): + id: str + + input: BetaBrowserScreenshotInput + """Capture the current browser viewport.""" + + name: Literal["screenshot"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_scroll_direction.py b/src/anthropic/types/beta/beta_browser_scroll_direction.py new file mode 100644 index 000000000..4c5c9140c --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_scroll_direction.py @@ -0,0 +1,5 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["BetaBrowserScrollDirection"] + +BetaBrowserScrollDirection: TypeAlias = Literal["up", "down", "left", "right"] diff --git a/src/anthropic/types/beta/beta_browser_scroll_input.py b/src/anthropic/types/beta/beta_browser_scroll_input.py new file mode 100644 index 000000000..fff124837 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_scroll_input.py @@ -0,0 +1,25 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_scroll_direction import BetaBrowserScrollDirection +from .beta_browser_coordinate_target import BetaBrowserCoordinateTarget + +__all__ = ["BetaBrowserScrollInput"] + + +class BetaBrowserScrollInput(BaseModel): + """Scroll at a viewport position. `target` must be a coordinate target.""" + + scroll_direction: BetaBrowserScrollDirection + + target: BetaBrowserCoordinateTarget + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + scroll_amount: Optional[int] = None + """Scroll-wheel notches (1–10). Default 3.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_scroll_to_input.py b/src/anthropic/types/beta/beta_browser_scroll_to_input.py new file mode 100644 index 000000000..98853924e --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_scroll_to_input.py @@ -0,0 +1,20 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_ref_target import BetaBrowserRefTarget + +__all__ = ["BetaBrowserScrollToInput"] + + +class BetaBrowserScrollToInput(BaseModel): + """Scroll an element into view.""" + + target: BetaBrowserRefTarget + """ + An element on the page, identified by a reference from a prior `read_page` or + `find` result. References are scoped to the tab that produced them and become + stale after navigation or a major re-render. + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_scroll_to_tool_use_block.py b/src/anthropic/types/beta/beta_browser_scroll_to_tool_use_block.py new file mode 100644 index 000000000..62c22d055 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_scroll_to_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_scroll_to_input import BetaBrowserScrollToInput + +__all__ = ["BetaBrowserScrollToToolUseBlock"] + + +class BetaBrowserScrollToToolUseBlock(BaseModel): + id: str + + input: BetaBrowserScrollToInput + """Scroll an element into view.""" + + name: Literal["scroll_to"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_scroll_tool_use_block.py b/src/anthropic/types/beta/beta_browser_scroll_tool_use_block.py new file mode 100644 index 000000000..8f18ba6ee --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_scroll_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_scroll_input import BetaBrowserScrollInput + +__all__ = ["BetaBrowserScrollToolUseBlock"] + + +class BetaBrowserScrollToolUseBlock(BaseModel): + id: str + + input: BetaBrowserScrollInput + """Scroll at a viewport position. `target` must be a coordinate target.""" + + name: Literal["scroll"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_switch_tab_input.py b/src/anthropic/types/beta/beta_browser_switch_tab_input.py new file mode 100644 index 000000000..fdc215e43 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_switch_tab_input.py @@ -0,0 +1,13 @@ +from ..._models import BaseModel + +__all__ = ["BetaBrowserSwitchTabInput"] + + +class BetaBrowserSwitchTabInput(BaseModel): + """ + Make the tab with the given tab_id the active tab — the tab that actions without + a tab_id apply to. + """ + + tab_id: str + """The tab to switch to.""" diff --git a/src/anthropic/types/beta/beta_browser_switch_tab_tool_use_block.py b/src/anthropic/types/beta/beta_browser_switch_tab_tool_use_block.py new file mode 100644 index 000000000..fba81fa95 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_switch_tab_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_switch_tab_input import BetaBrowserSwitchTabInput + +__all__ = ["BetaBrowserSwitchTabToolUseBlock"] + + +class BetaBrowserSwitchTabToolUseBlock(BaseModel): + id: str + + input: BetaBrowserSwitchTabInput + """ + Make the tab with the given tab_id the active tab — the tab that actions without + a tab_id apply to. + """ + + name: Literal["switch_tab"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_tool_use_block.py b/src/anthropic/types/beta/beta_browser_tool_use_block.py new file mode 100644 index 000000000..9debd85f1 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_tool_use_block.py @@ -0,0 +1,74 @@ +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from ..._models import UnionDiscriminator +from .beta_browser_key_tool_use_block import BetaBrowserKeyToolUseBlock +from .beta_browser_find_tool_use_block import BetaBrowserFindToolUseBlock +from .beta_browser_type_tool_use_block import BetaBrowserTypeToolUseBlock +from .beta_browser_wait_tool_use_block import BetaBrowserWaitToolUseBlock +from .beta_browser_zoom_tool_use_block import BetaBrowserZoomToolUseBlock +from .beta_browser_hover_tool_use_block import BetaBrowserHoverToolUseBlock +from .beta_browser_scroll_tool_use_block import BetaBrowserScrollToolUseBlock +from .beta_browser_new_tab_tool_use_block import BetaBrowserNewTabToolUseBlock +from .beta_browser_hold_key_tool_use_block import BetaBrowserHoldKeyToolUseBlock +from .beta_browser_navigate_tool_use_block import BetaBrowserNavigateToolUseBlock +from .beta_browser_close_tab_tool_use_block import BetaBrowserCloseTabToolUseBlock +from .beta_browser_list_tabs_tool_use_block import BetaBrowserListTabsToolUseBlock +from .beta_browser_read_page_tool_use_block import BetaBrowserReadPageToolUseBlock +from .beta_browser_scroll_to_tool_use_block import BetaBrowserScrollToToolUseBlock +from .beta_browser_form_input_tool_use_block import BetaBrowserFormInputToolUseBlock +from .beta_browser_left_click_tool_use_block import BetaBrowserLeftClickToolUseBlock +from .beta_browser_mouse_move_tool_use_block import BetaBrowserMouseMoveToolUseBlock +from .beta_browser_screenshot_tool_use_block import BetaBrowserScreenshotToolUseBlock +from .beta_browser_switch_tab_tool_use_block import BetaBrowserSwitchTabToolUseBlock +from .beta_browser_file_upload_tool_use_block import BetaBrowserFileUploadToolUseBlock +from .beta_browser_right_click_tool_use_block import BetaBrowserRightClickToolUseBlock +from .beta_browser_double_click_tool_use_block import BetaBrowserDoubleClickToolUseBlock +from .beta_browser_middle_click_tool_use_block import BetaBrowserMiddleClickToolUseBlock +from .beta_browser_read_console_tool_use_block import BetaBrowserReadConsoleToolUseBlock +from .beta_browser_read_network_tool_use_block import BetaBrowserReadNetworkToolUseBlock +from .beta_browser_triple_click_tool_use_block import BetaBrowserTripleClickToolUseBlock +from .beta_browser_get_page_text_tool_use_block import BetaBrowserGetPageTextToolUseBlock +from .beta_browser_left_mouse_up_tool_use_block import BetaBrowserLeftMouseUpToolUseBlock +from .beta_browser_javascript_exec_tool_use_block import BetaBrowserJavascriptExecToolUseBlock +from .beta_browser_left_click_drag_tool_use_block import BetaBrowserLeftClickDragToolUseBlock +from .beta_browser_left_mouse_down_tool_use_block import BetaBrowserLeftMouseDownToolUseBlock + +__all__ = ["BetaBrowserToolUseBlock"] + +BetaBrowserToolUseBlock: TypeAlias = Annotated[ + Union[ + BetaBrowserNavigateToolUseBlock, + BetaBrowserListTabsToolUseBlock, + BetaBrowserNewTabToolUseBlock, + BetaBrowserSwitchTabToolUseBlock, + BetaBrowserCloseTabToolUseBlock, + BetaBrowserReadPageToolUseBlock, + BetaBrowserGetPageTextToolUseBlock, + BetaBrowserReadConsoleToolUseBlock, + BetaBrowserReadNetworkToolUseBlock, + BetaBrowserFindToolUseBlock, + BetaBrowserFormInputToolUseBlock, + BetaBrowserFileUploadToolUseBlock, + BetaBrowserScrollToToolUseBlock, + BetaBrowserScreenshotToolUseBlock, + BetaBrowserZoomToolUseBlock, + BetaBrowserLeftClickToolUseBlock, + BetaBrowserRightClickToolUseBlock, + BetaBrowserMiddleClickToolUseBlock, + BetaBrowserDoubleClickToolUseBlock, + BetaBrowserTripleClickToolUseBlock, + BetaBrowserHoverToolUseBlock, + BetaBrowserLeftClickDragToolUseBlock, + BetaBrowserLeftMouseDownToolUseBlock, + BetaBrowserLeftMouseUpToolUseBlock, + BetaBrowserMouseMoveToolUseBlock, + BetaBrowserScrollToolUseBlock, + BetaBrowserTypeToolUseBlock, + BetaBrowserKeyToolUseBlock, + BetaBrowserHoldKeyToolUseBlock, + BetaBrowserWaitToolUseBlock, + BetaBrowserJavascriptExecToolUseBlock, + ], + UnionDiscriminator("name"), +] diff --git a/src/anthropic/types/beta/beta_browser_triple_click_input.py b/src/anthropic/types/beta/beta_browser_triple_click_input.py new file mode 100644 index 000000000..4621adaf8 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_triple_click_input.py @@ -0,0 +1,25 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_browser_click_target import BetaBrowserClickTarget + +__all__ = ["BetaBrowserTripleClickInput"] + + +class BetaBrowserTripleClickInput(BaseModel): + """ + Triple left-click at a viewport coordinate or on an element by reference + (typically selects a line or paragraph). + """ + + target: BetaBrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + modifiers: Optional[str] = None + """Optional modifier key chord to hold for the duration of this action (e.g. + + "shift", "ctrl+shift", "cmd+alt"). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_triple_click_tool_use_block.py b/src/anthropic/types/beta/beta_browser_triple_click_tool_use_block.py new file mode 100644 index 000000000..95b392497 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_triple_click_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_triple_click_input import BetaBrowserTripleClickInput + +__all__ = ["BetaBrowserTripleClickToolUseBlock"] + + +class BetaBrowserTripleClickToolUseBlock(BaseModel): + id: str + + input: BetaBrowserTripleClickInput + """ + Triple left-click at a viewport coordinate or on an element by reference + (typically selects a line or paragraph). + """ + + name: Literal["triple_click"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_type_input.py b/src/anthropic/types/beta/beta_browser_type_input.py new file mode 100644 index 000000000..6eb2a0f89 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_type_input.py @@ -0,0 +1,15 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserTypeInput"] + + +class BetaBrowserTypeInput(BaseModel): + """Type a literal string at the current focus.""" + + text: str + """The text to type.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_type_tool_use_block.py b/src/anthropic/types/beta/beta_browser_type_tool_use_block.py new file mode 100644 index 000000000..44a5cd971 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_type_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_type_input import BetaBrowserTypeInput + +__all__ = ["BetaBrowserTypeToolUseBlock"] + + +class BetaBrowserTypeToolUseBlock(BaseModel): + id: str + + input: BetaBrowserTypeInput + """Type a literal string at the current focus.""" + + name: Literal["type"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_wait_input.py b/src/anthropic/types/beta/beta_browser_wait_input.py new file mode 100644 index 000000000..83f39265d --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_wait_input.py @@ -0,0 +1,15 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserWaitInput"] + + +class BetaBrowserWaitInput(BaseModel): + """Pause for the given duration.""" + + duration: float + """Seconds to wait (maximum 30).""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_wait_tool_use_block.py b/src/anthropic/types/beta/beta_browser_wait_tool_use_block.py new file mode 100644 index 000000000..761844258 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_wait_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_wait_input import BetaBrowserWaitInput + +__all__ = ["BetaBrowserWaitToolUseBlock"] + + +class BetaBrowserWaitToolUseBlock(BaseModel): + id: str + + input: BetaBrowserWaitInput + """Pause for the given duration.""" + + name: Literal["wait"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_browser_zoom_input.py b/src/anthropic/types/beta/beta_browser_zoom_input.py new file mode 100644 index 000000000..824ff6685 --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_zoom_input.py @@ -0,0 +1,19 @@ +from typing import List, Optional + +from ..._models import BaseModel + +__all__ = ["BetaBrowserZoomInput"] + + +class BetaBrowserZoomInput(BaseModel): + """ + Return a cropped screenshot of the given viewport region, scaled up for closer + inspection — useful for small icons, buttons, or text. Coordinates are in the + same viewport-pixel space as a full screenshot. + """ + + region: List[int] + """[x0, y0, x1, y1] in viewport pixels.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/beta/beta_browser_zoom_tool_use_block.py b/src/anthropic/types/beta/beta_browser_zoom_tool_use_block.py new file mode 100644 index 000000000..b73962b3e --- /dev/null +++ b/src/anthropic/types/beta/beta_browser_zoom_tool_use_block.py @@ -0,0 +1,31 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_browser_zoom_input import BetaBrowserZoomInput + +__all__ = ["BetaBrowserZoomToolUseBlock"] + + +class BetaBrowserZoomToolUseBlock(BaseModel): + id: str + + input: BetaBrowserZoomInput + """ + Return a cropped screenshot of the given viewport region, scaled up for closer + inspection — useful for small icons, buttons, or text. Coordinates are in the + same viewport-pixel space as a full screenshot. + """ + + name: Literal["zoom"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_cursor_position_input.py b/src/anthropic/types/beta/beta_computer_cursor_position_input.py new file mode 100644 index 000000000..d7bf80cef --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_cursor_position_input.py @@ -0,0 +1,9 @@ +from ..._models import BaseModel + +__all__ = ["BetaComputerCursorPositionInput"] + + +class BetaComputerCursorPositionInput(BaseModel): + """Get the current (x, y) pixel coordinate of the cursor.""" + + pass diff --git a/src/anthropic/types/beta/beta_computer_cursor_position_tool_use_block.py b/src/anthropic/types/beta/beta_computer_cursor_position_tool_use_block.py new file mode 100644 index 000000000..3de432bfd --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_cursor_position_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_cursor_position_input import BetaComputerCursorPositionInput + +__all__ = ["BetaComputerCursorPositionToolUseBlock"] + + +class BetaComputerCursorPositionToolUseBlock(BaseModel): + id: str + + input: BetaComputerCursorPositionInput + """Get the current (x, y) pixel coordinate of the cursor.""" + + name: Literal["cursor_position"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_double_click_input.py b/src/anthropic/types/beta/beta_computer_double_click_input.py new file mode 100644 index 000000000..889516d9d --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_double_click_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from ..._models import BaseModel + +__all__ = ["BetaComputerDoubleClickInput"] + + +class BetaComputerDoubleClickInput(BaseModel): + """ + Double-click the left mouse button at the specified (x, y) pixel coordinate, or + the current cursor position if `coordinate` is omitted. + """ + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/beta/beta_computer_double_click_tool_use_block.py b/src/anthropic/types/beta/beta_computer_double_click_tool_use_block.py new file mode 100644 index 000000000..322775688 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_double_click_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_double_click_input import BetaComputerDoubleClickInput + +__all__ = ["BetaComputerDoubleClickToolUseBlock"] + + +class BetaComputerDoubleClickToolUseBlock(BaseModel): + id: str + + input: BetaComputerDoubleClickInput + """ + Double-click the left mouse button at the specified (x, y) pixel coordinate, or + the current cursor position if `coordinate` is omitted. + """ + + name: Literal["double_click"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_hold_key_input.py b/src/anthropic/types/beta/beta_computer_hold_key_input.py new file mode 100644 index 000000000..59b34218b --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_hold_key_input.py @@ -0,0 +1,17 @@ +from ..._models import BaseModel + +__all__ = ["BetaComputerHoldKeyInput"] + + +class BetaComputerHoldKeyInput(BaseModel): + """Hold down a key or key-combination for a specified duration. + + Uses the same key + syntax as `key`. + """ + + duration: int + """Duration to hold the key, in seconds.""" + + text: str + """The key or key-combination to hold.""" diff --git a/src/anthropic/types/beta/beta_computer_hold_key_tool_use_block.py b/src/anthropic/types/beta/beta_computer_hold_key_tool_use_block.py new file mode 100644 index 000000000..72d09df3f --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_hold_key_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_hold_key_input import BetaComputerHoldKeyInput + +__all__ = ["BetaComputerHoldKeyToolUseBlock"] + + +class BetaComputerHoldKeyToolUseBlock(BaseModel): + id: str + + input: BetaComputerHoldKeyInput + """Hold down a key or key-combination for a specified duration. + + Uses the same key syntax as `key`. + """ + + name: Literal["hold_key"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_key_input.py b/src/anthropic/types/beta/beta_computer_key_input.py new file mode 100644 index 000000000..aaaa3dac9 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_key_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from ..._models import BaseModel + +__all__ = ["BetaComputerKeyInput"] + + +class BetaComputerKeyInput(BaseModel): + """Press a key or key-combination on the keyboard. + + Use "+" to combine modifiers with + a key (e.g. "ctrl+s", "alt+Tab", "ctrl+shift+Escape"). Key names are + case-insensitive; common names like "Return", "Tab", "Escape", "Up", "Down", + "Left", "Right", "Home", "End", "Page_Up", "Page_Down", "Delete", "BackSpace" are + supported. + """ + + text: str + """The key or key-combination to press.""" + + repeat: Optional[int] = None + """Number of times to repeat the key press. Default is 1.""" diff --git a/src/anthropic/types/beta/beta_computer_key_tool_use_block.py b/src/anthropic/types/beta/beta_computer_key_tool_use_block.py new file mode 100644 index 000000000..5a2a737fb --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_key_tool_use_block.py @@ -0,0 +1,33 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_key_input import BetaComputerKeyInput + +__all__ = ["BetaComputerKeyToolUseBlock"] + + +class BetaComputerKeyToolUseBlock(BaseModel): + id: str + + input: BetaComputerKeyInput + """Press a key or key-combination on the keyboard. + + Use "+" to combine modifiers with a key (e.g. "ctrl+s", "alt+Tab", + "ctrl+shift+Escape"). Key names are case-insensitive; common names like + "Return", "Tab", "Escape", "Up", "Down", "Left", "Right", "Home", "End", + "Page_Up", "Page_Down", "Delete", "BackSpace" are supported. + """ + + name: Literal["key"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_left_click_drag_input.py b/src/anthropic/types/beta/beta_computer_left_click_drag_input.py new file mode 100644 index 000000000..d450e52f6 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_left_click_drag_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from ..._models import BaseModel + +__all__ = ["BetaComputerLeftClickDragInput"] + + +class BetaComputerLeftClickDragInput(BaseModel): + """Click and drag the cursor from `start_coordinate` to `coordinate`.""" + + coordinate: List[int] + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + start_coordinate: List[int] + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/beta/beta_computer_left_click_drag_tool_use_block.py b/src/anthropic/types/beta/beta_computer_left_click_drag_tool_use_block.py new file mode 100644 index 000000000..025110907 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_left_click_drag_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_left_click_drag_input import BetaComputerLeftClickDragInput + +__all__ = ["BetaComputerLeftClickDragToolUseBlock"] + + +class BetaComputerLeftClickDragToolUseBlock(BaseModel): + id: str + + input: BetaComputerLeftClickDragInput + """Click and drag the cursor from `start_coordinate` to `coordinate`.""" + + name: Literal["left_click_drag"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_left_click_input.py b/src/anthropic/types/beta/beta_computer_left_click_input.py new file mode 100644 index 000000000..36670c2ac --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_left_click_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from ..._models import BaseModel + +__all__ = ["BetaComputerLeftClickInput"] + + +class BetaComputerLeftClickInput(BaseModel): + """ + Click the left mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/beta/beta_computer_left_click_tool_use_block.py b/src/anthropic/types/beta/beta_computer_left_click_tool_use_block.py new file mode 100644 index 000000000..66be7e769 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_left_click_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_left_click_input import BetaComputerLeftClickInput + +__all__ = ["BetaComputerLeftClickToolUseBlock"] + + +class BetaComputerLeftClickToolUseBlock(BaseModel): + id: str + + input: BetaComputerLeftClickInput + """ + Click the left mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + name: Literal["left_click"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_left_mouse_down_input.py b/src/anthropic/types/beta/beta_computer_left_mouse_down_input.py new file mode 100644 index 000000000..30aef8728 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_left_mouse_down_input.py @@ -0,0 +1,9 @@ +from ..._models import BaseModel + +__all__ = ["BetaComputerLeftMouseDownInput"] + + +class BetaComputerLeftMouseDownInput(BaseModel): + """Press and hold the left mouse button at the current cursor position.""" + + pass diff --git a/src/anthropic/types/beta/beta_computer_left_mouse_down_tool_use_block.py b/src/anthropic/types/beta/beta_computer_left_mouse_down_tool_use_block.py new file mode 100644 index 000000000..fec44278c --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_left_mouse_down_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_left_mouse_down_input import BetaComputerLeftMouseDownInput + +__all__ = ["BetaComputerLeftMouseDownToolUseBlock"] + + +class BetaComputerLeftMouseDownToolUseBlock(BaseModel): + id: str + + input: BetaComputerLeftMouseDownInput + """Press and hold the left mouse button at the current cursor position.""" + + name: Literal["left_mouse_down"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_left_mouse_up_input.py b/src/anthropic/types/beta/beta_computer_left_mouse_up_input.py new file mode 100644 index 000000000..ef822bdd7 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_left_mouse_up_input.py @@ -0,0 +1,9 @@ +from ..._models import BaseModel + +__all__ = ["BetaComputerLeftMouseUpInput"] + + +class BetaComputerLeftMouseUpInput(BaseModel): + """Release the left mouse button.""" + + pass diff --git a/src/anthropic/types/beta/beta_computer_left_mouse_up_tool_use_block.py b/src/anthropic/types/beta/beta_computer_left_mouse_up_tool_use_block.py new file mode 100644 index 000000000..e2888f926 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_left_mouse_up_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_left_mouse_up_input import BetaComputerLeftMouseUpInput + +__all__ = ["BetaComputerLeftMouseUpToolUseBlock"] + + +class BetaComputerLeftMouseUpToolUseBlock(BaseModel): + id: str + + input: BetaComputerLeftMouseUpInput + """Release the left mouse button.""" + + name: Literal["left_mouse_up"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_member_input.py b/src/anthropic/types/beta/beta_computer_member_input.py new file mode 100644 index 000000000..faf7c683b --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_member_input.py @@ -0,0 +1,42 @@ +from typing import Union +from typing_extensions import TypeAlias + +from .beta_computer_key_input import BetaComputerKeyInput +from .beta_computer_type_input import BetaComputerTypeInput +from .beta_computer_wait_input import BetaComputerWaitInput +from .beta_computer_zoom_input import BetaComputerZoomInput +from .beta_computer_scroll_input import BetaComputerScrollInput +from .beta_computer_hold_key_input import BetaComputerHoldKeyInput +from .beta_computer_left_click_input import BetaComputerLeftClickInput +from .beta_computer_mouse_move_input import BetaComputerMouseMoveInput +from .beta_computer_screenshot_input import BetaComputerScreenshotInput +from .beta_computer_right_click_input import BetaComputerRightClickInput +from .beta_computer_double_click_input import BetaComputerDoubleClickInput +from .beta_computer_middle_click_input import BetaComputerMiddleClickInput +from .beta_computer_triple_click_input import BetaComputerTripleClickInput +from .beta_computer_left_mouse_up_input import BetaComputerLeftMouseUpInput +from .beta_computer_cursor_position_input import BetaComputerCursorPositionInput +from .beta_computer_left_click_drag_input import BetaComputerLeftClickDragInput +from .beta_computer_left_mouse_down_input import BetaComputerLeftMouseDownInput + +__all__ = ["BetaComputerMemberInput"] + +BetaComputerMemberInput: TypeAlias = Union[ + BetaComputerKeyInput, + BetaComputerHoldKeyInput, + BetaComputerTypeInput, + BetaComputerCursorPositionInput, + BetaComputerMouseMoveInput, + BetaComputerLeftMouseDownInput, + BetaComputerLeftMouseUpInput, + BetaComputerLeftClickInput, + BetaComputerLeftClickDragInput, + BetaComputerRightClickInput, + BetaComputerMiddleClickInput, + BetaComputerDoubleClickInput, + BetaComputerTripleClickInput, + BetaComputerScrollInput, + BetaComputerWaitInput, + BetaComputerScreenshotInput, + BetaComputerZoomInput, +] diff --git a/src/anthropic/types/beta/beta_computer_member_name.py b/src/anthropic/types/beta/beta_computer_member_name.py new file mode 100644 index 000000000..43b9fa238 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_member_name.py @@ -0,0 +1,44 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["BetaComputerMemberName", "BETA_COMPUTER_MEMBER_NAME_VALUES"] + +BetaComputerMemberName: TypeAlias = Literal[ + "key", + "hold_key", + "type", + "cursor_position", + "mouse_move", + "left_mouse_down", + "left_mouse_up", + "left_click", + "left_click_drag", + "right_click", + "middle_click", + "double_click", + "triple_click", + "scroll", + "wait", + "screenshot", + "zoom", +] + +BETA_COMPUTER_MEMBER_NAME_VALUES: tuple[BetaComputerMemberName, ...] = ( + "key", + "hold_key", + "type", + "cursor_position", + "mouse_move", + "left_mouse_down", + "left_mouse_up", + "left_click", + "left_click_drag", + "right_click", + "middle_click", + "double_click", + "triple_click", + "scroll", + "wait", + "screenshot", + "zoom", +) +"""The values of `BetaComputerMemberName`.""" diff --git a/src/anthropic/types/beta/beta_computer_middle_click_input.py b/src/anthropic/types/beta/beta_computer_middle_click_input.py new file mode 100644 index 000000000..52b071a8a --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_middle_click_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from ..._models import BaseModel + +__all__ = ["BetaComputerMiddleClickInput"] + + +class BetaComputerMiddleClickInput(BaseModel): + """ + Click the middle mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/beta/beta_computer_middle_click_tool_use_block.py b/src/anthropic/types/beta/beta_computer_middle_click_tool_use_block.py new file mode 100644 index 000000000..e080c2ace --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_middle_click_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_middle_click_input import BetaComputerMiddleClickInput + +__all__ = ["BetaComputerMiddleClickToolUseBlock"] + + +class BetaComputerMiddleClickToolUseBlock(BaseModel): + id: str + + input: BetaComputerMiddleClickInput + """ + Click the middle mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + name: Literal["middle_click"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_mouse_move_input.py b/src/anthropic/types/beta/beta_computer_mouse_move_input.py new file mode 100644 index 000000000..99dc001de --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_mouse_move_input.py @@ -0,0 +1,16 @@ +from typing import List + +from ..._models import BaseModel + +__all__ = ["BetaComputerMouseMoveInput"] + + +class BetaComputerMouseMoveInput(BaseModel): + """Move the cursor to a specified (x, y) pixel coordinate. + + Use this ONLY to hover + without clicking; otherwise use a click action directly. + """ + + coordinate: List[int] + """(x, y): x pixels from the left edge, y pixels from the top edge.""" diff --git a/src/anthropic/types/beta/beta_computer_mouse_move_tool_use_block.py b/src/anthropic/types/beta/beta_computer_mouse_move_tool_use_block.py new file mode 100644 index 000000000..e9ecaaf83 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_mouse_move_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_mouse_move_input import BetaComputerMouseMoveInput + +__all__ = ["BetaComputerMouseMoveToolUseBlock"] + + +class BetaComputerMouseMoveToolUseBlock(BaseModel): + id: str + + input: BetaComputerMouseMoveInput + """Move the cursor to a specified (x, y) pixel coordinate. + + Use this ONLY to hover without clicking; otherwise use a click action directly. + """ + + name: Literal["mouse_move"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_right_click_input.py b/src/anthropic/types/beta/beta_computer_right_click_input.py new file mode 100644 index 000000000..d3878683e --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_right_click_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from ..._models import BaseModel + +__all__ = ["BetaComputerRightClickInput"] + + +class BetaComputerRightClickInput(BaseModel): + """ + Click the right mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/beta/beta_computer_right_click_tool_use_block.py b/src/anthropic/types/beta/beta_computer_right_click_tool_use_block.py new file mode 100644 index 000000000..e0403b983 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_right_click_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_right_click_input import BetaComputerRightClickInput + +__all__ = ["BetaComputerRightClickToolUseBlock"] + + +class BetaComputerRightClickToolUseBlock(BaseModel): + id: str + + input: BetaComputerRightClickInput + """ + Click the right mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + name: Literal["right_click"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_screenshot_input.py b/src/anthropic/types/beta/beta_computer_screenshot_input.py new file mode 100644 index 000000000..4ba36b4a9 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_screenshot_input.py @@ -0,0 +1,9 @@ +from ..._models import BaseModel + +__all__ = ["BetaComputerScreenshotInput"] + + +class BetaComputerScreenshotInput(BaseModel): + """Take a screenshot of the screen.""" + + pass diff --git a/src/anthropic/types/beta/beta_computer_screenshot_tool_use_block.py b/src/anthropic/types/beta/beta_computer_screenshot_tool_use_block.py new file mode 100644 index 000000000..a123d5ae3 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_screenshot_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_screenshot_input import BetaComputerScreenshotInput + +__all__ = ["BetaComputerScreenshotToolUseBlock"] + + +class BetaComputerScreenshotToolUseBlock(BaseModel): + id: str + + input: BetaComputerScreenshotInput + """Take a screenshot of the screen.""" + + name: Literal["screenshot"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_scroll_direction.py b/src/anthropic/types/beta/beta_computer_scroll_direction.py new file mode 100644 index 000000000..28bc05323 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_scroll_direction.py @@ -0,0 +1,5 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["BetaComputerScrollDirection"] + +BetaComputerScrollDirection: TypeAlias = Literal["up", "down", "left", "right"] diff --git a/src/anthropic/types/beta/beta_computer_scroll_input.py b/src/anthropic/types/beta/beta_computer_scroll_input.py new file mode 100644 index 000000000..f4e8f2cda --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_scroll_input.py @@ -0,0 +1,27 @@ +from typing import List, Optional + +from ..._models import BaseModel +from .beta_computer_scroll_direction import BetaComputerScrollDirection + +__all__ = ["BetaComputerScrollInput"] + + +class BetaComputerScrollInput(BaseModel): + """ + Scroll the screen at the specified (x, y) pixel coordinate, or the current cursor + position if `coordinate` is omitted. Do NOT use PageUp/PageDown to scroll. + """ + + scroll_amount: int + """Number of 'clicks' of the scroll wheel.""" + + scroll_direction: BetaComputerScrollDirection + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/beta/beta_computer_scroll_tool_use_block.py b/src/anthropic/types/beta/beta_computer_scroll_tool_use_block.py new file mode 100644 index 000000000..af399d51a --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_scroll_tool_use_block.py @@ -0,0 +1,31 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_scroll_input import BetaComputerScrollInput + +__all__ = ["BetaComputerScrollToolUseBlock"] + + +class BetaComputerScrollToolUseBlock(BaseModel): + id: str + + input: BetaComputerScrollInput + """ + Scroll the screen at the specified (x, y) pixel coordinate, or the current + cursor position if `coordinate` is omitted. Do NOT use PageUp/PageDown to + scroll. + """ + + name: Literal["scroll"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_tool_use_block.py b/src/anthropic/types/beta/beta_computer_tool_use_block.py new file mode 100644 index 000000000..b5cf0d357 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_tool_use_block.py @@ -0,0 +1,46 @@ +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from ..._models import UnionDiscriminator +from .beta_computer_key_tool_use_block import BetaComputerKeyToolUseBlock +from .beta_computer_type_tool_use_block import BetaComputerTypeToolUseBlock +from .beta_computer_wait_tool_use_block import BetaComputerWaitToolUseBlock +from .beta_computer_zoom_tool_use_block import BetaComputerZoomToolUseBlock +from .beta_computer_scroll_tool_use_block import BetaComputerScrollToolUseBlock +from .beta_computer_hold_key_tool_use_block import BetaComputerHoldKeyToolUseBlock +from .beta_computer_left_click_tool_use_block import BetaComputerLeftClickToolUseBlock +from .beta_computer_mouse_move_tool_use_block import BetaComputerMouseMoveToolUseBlock +from .beta_computer_screenshot_tool_use_block import BetaComputerScreenshotToolUseBlock +from .beta_computer_right_click_tool_use_block import BetaComputerRightClickToolUseBlock +from .beta_computer_double_click_tool_use_block import BetaComputerDoubleClickToolUseBlock +from .beta_computer_middle_click_tool_use_block import BetaComputerMiddleClickToolUseBlock +from .beta_computer_triple_click_tool_use_block import BetaComputerTripleClickToolUseBlock +from .beta_computer_left_mouse_up_tool_use_block import BetaComputerLeftMouseUpToolUseBlock +from .beta_computer_cursor_position_tool_use_block import BetaComputerCursorPositionToolUseBlock +from .beta_computer_left_click_drag_tool_use_block import BetaComputerLeftClickDragToolUseBlock +from .beta_computer_left_mouse_down_tool_use_block import BetaComputerLeftMouseDownToolUseBlock + +__all__ = ["BetaComputerToolUseBlock"] + +BetaComputerToolUseBlock: TypeAlias = Annotated[ + Union[ + BetaComputerKeyToolUseBlock, + BetaComputerHoldKeyToolUseBlock, + BetaComputerTypeToolUseBlock, + BetaComputerCursorPositionToolUseBlock, + BetaComputerMouseMoveToolUseBlock, + BetaComputerLeftMouseDownToolUseBlock, + BetaComputerLeftMouseUpToolUseBlock, + BetaComputerLeftClickToolUseBlock, + BetaComputerLeftClickDragToolUseBlock, + BetaComputerRightClickToolUseBlock, + BetaComputerMiddleClickToolUseBlock, + BetaComputerDoubleClickToolUseBlock, + BetaComputerTripleClickToolUseBlock, + BetaComputerScrollToolUseBlock, + BetaComputerWaitToolUseBlock, + BetaComputerScreenshotToolUseBlock, + BetaComputerZoomToolUseBlock, + ], + UnionDiscriminator("name"), +] diff --git a/src/anthropic/types/beta/beta_computer_triple_click_input.py b/src/anthropic/types/beta/beta_computer_triple_click_input.py new file mode 100644 index 000000000..6d1614f18 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_triple_click_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from ..._models import BaseModel + +__all__ = ["BetaComputerTripleClickInput"] + + +class BetaComputerTripleClickInput(BaseModel): + """ + Triple-click the left mouse button at the specified (x, y) pixel coordinate, or + the current cursor position if `coordinate` is omitted. + """ + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/beta/beta_computer_triple_click_tool_use_block.py b/src/anthropic/types/beta/beta_computer_triple_click_tool_use_block.py new file mode 100644 index 000000000..3ee13c18b --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_triple_click_tool_use_block.py @@ -0,0 +1,30 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_triple_click_input import BetaComputerTripleClickInput + +__all__ = ["BetaComputerTripleClickToolUseBlock"] + + +class BetaComputerTripleClickToolUseBlock(BaseModel): + id: str + + input: BetaComputerTripleClickInput + """ + Triple-click the left mouse button at the specified (x, y) pixel coordinate, or + the current cursor position if `coordinate` is omitted. + """ + + name: Literal["triple_click"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_type_input.py b/src/anthropic/types/beta/beta_computer_type_input.py new file mode 100644 index 000000000..f171f98db --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_type_input.py @@ -0,0 +1,10 @@ +from ..._models import BaseModel + +__all__ = ["BetaComputerTypeInput"] + + +class BetaComputerTypeInput(BaseModel): + """Type a string of text on the keyboard.""" + + text: str + """The text to type.""" diff --git a/src/anthropic/types/beta/beta_computer_type_tool_use_block.py b/src/anthropic/types/beta/beta_computer_type_tool_use_block.py new file mode 100644 index 000000000..e6e4e5074 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_type_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_type_input import BetaComputerTypeInput + +__all__ = ["BetaComputerTypeToolUseBlock"] + + +class BetaComputerTypeToolUseBlock(BaseModel): + id: str + + input: BetaComputerTypeInput + """Type a string of text on the keyboard.""" + + name: Literal["type"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_wait_input.py b/src/anthropic/types/beta/beta_computer_wait_input.py new file mode 100644 index 000000000..e6c780d1c --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_wait_input.py @@ -0,0 +1,10 @@ +from ..._models import BaseModel + +__all__ = ["BetaComputerWaitInput"] + + +class BetaComputerWaitInput(BaseModel): + """Wait for a specified duration.""" + + duration: int + """Duration to wait, in seconds.""" diff --git a/src/anthropic/types/beta/beta_computer_wait_tool_use_block.py b/src/anthropic/types/beta/beta_computer_wait_tool_use_block.py new file mode 100644 index 000000000..29b1c94c8 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_wait_tool_use_block.py @@ -0,0 +1,27 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_wait_input import BetaComputerWaitInput + +__all__ = ["BetaComputerWaitToolUseBlock"] + + +class BetaComputerWaitToolUseBlock(BaseModel): + id: str + + input: BetaComputerWaitInput + """Wait for a specified duration.""" + + name: Literal["wait"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_computer_zoom_input.py b/src/anthropic/types/beta/beta_computer_zoom_input.py new file mode 100644 index 000000000..63838df77 --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_zoom_input.py @@ -0,0 +1,17 @@ +from typing import List + +from ..._models import BaseModel + +__all__ = ["BetaComputerZoomInput"] + + +class BetaComputerZoomInput(BaseModel): + """Take a screenshot of a rectangular region. + + Region coordinates are in the + full-screenshot space (not physical display pixels). The crop is scaled up to + fill the image budget so fine details become legible. + """ + + region: List[int] + """(x0, y0, x1, y1): The region to capture.""" diff --git a/src/anthropic/types/beta/beta_computer_zoom_tool_use_block.py b/src/anthropic/types/beta/beta_computer_zoom_tool_use_block.py new file mode 100644 index 000000000..05788b1bc --- /dev/null +++ b/src/anthropic/types/beta/beta_computer_zoom_tool_use_block.py @@ -0,0 +1,32 @@ +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_tool_use_caller import BetaToolUseCaller +from .beta_computer_zoom_input import BetaComputerZoomInput + +__all__ = ["BetaComputerZoomToolUseBlock"] + + +class BetaComputerZoomToolUseBlock(BaseModel): + id: str + + input: BetaComputerZoomInput + """Take a screenshot of a rectangular region. + + Region coordinates are in the full-screenshot space (not physical display + pixels). The crop is scaled up to fill the image budget so fine details become + legible. + """ + + name: Literal["zoom"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] + + caller: Optional[BetaToolUseCaller] = None + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ diff --git a/src/anthropic/types/beta/beta_managed_agents_advisor_params.py b/src/anthropic/types/beta/beta_managed_agents_advisor_params.py index fee5ce971..092cc27bd 100644 --- a/src/anthropic/types/beta/beta_managed_agents_advisor_params.py +++ b/src/anthropic/types/beta/beta_managed_agents_advisor_params.py @@ -13,8 +13,7 @@ class BetaManagedAgentsAdvisorParams(TypedDict, total=False): model: Required[str] """A Claude model id. - The model must be permitted as an advisor for this agent's model — see the - sessions/threads/advisor spec. + The model must be permitted as an advisor for this agent's model. """ type: Required[Literal["advisor"]] diff --git a/src/anthropic/types/beta/beta_managed_agents_model.py b/src/anthropic/types/beta/beta_managed_agents_model.py index 190cecec1..eced11a0b 100644 --- a/src/anthropic/types/beta/beta_managed_agents_model.py +++ b/src/anthropic/types/beta/beta_managed_agents_model.py @@ -5,6 +5,7 @@ BetaManagedAgentsModel: TypeAlias = Union[ Literal[ + "claude-haiku-5-5", "claude-sonnet-5-5", "claude-opus-5-5", "claude-fable-5-1", diff --git a/src/anthropic/types/beta/beta_managed_agents_model_param.py b/src/anthropic/types/beta/beta_managed_agents_model_param.py index 86c551a61..0533f1db5 100644 --- a/src/anthropic/types/beta/beta_managed_agents_model_param.py +++ b/src/anthropic/types/beta/beta_managed_agents_model_param.py @@ -7,6 +7,7 @@ BetaManagedAgentsModelParam: TypeAlias = Union[ Literal[ + "claude-haiku-5-5", "claude-sonnet-5-5", "claude-opus-5-5", "claude-fable-5-1", diff --git a/src/anthropic/types/beta/beta_managed_agents_multiagent_params.py b/src/anthropic/types/beta/beta_managed_agents_multiagent_params.py index 094a6896e..779d16e5d 100644 --- a/src/anthropic/types/beta/beta_managed_agents_multiagent_params.py +++ b/src/anthropic/types/beta/beta_managed_agents_multiagent_params.py @@ -9,10 +9,7 @@ class BetaManagedAgentsMultiagentParams(TypedDict, total=False): - """Multiagent orchestration configuration. - - Currently supports the `coordinator` topology. - """ + """Multiagent orchestration configuration.""" agents: Required[SequenceNotStr[BetaManagedAgentsMultiagentRosterEntryParams]] """Agents the coordinator may spawn as session threads. diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_tool_config.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_tool_config.py index cfb80049b..31cea0123 100644 --- a/src/anthropic/types/beta/beta_managed_agents_web_fetch_tool_config.py +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_tool_config.py @@ -5,6 +5,7 @@ from .beta_managed_agents_auto_policy import BetaManagedAgentsAutoPolicy from .beta_managed_agents_always_ask_policy import BetaManagedAgentsAlwaysAskPolicy from .beta_managed_agents_always_allow_policy import BetaManagedAgentsAlwaysAllowPolicy +from .beta_managed_agents_web_fetch_url_sources import BetaManagedAgentsWebFetchURLSources __all__ = ["BetaManagedAgentsWebFetchToolConfig", "PermissionPolicy"] @@ -26,6 +27,12 @@ class BetaManagedAgentsWebFetchToolConfig(BaseModel): type: Literal["web_fetch"] + url_sources: Optional[BetaManagedAgentsWebFetchURLSources] = None + """Which sources contribute URLs the tool may fetch, always in the object form. + + Null when not set, which allows every source. + """ + allowed_domains: Optional[List[str]] = None blocked_domains: Optional[List[str]] = None diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_tool_config_params.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_tool_config_params.py index 0136bd2c6..f8875594a 100644 --- a/src/anthropic/types/beta/beta_managed_agents_web_fetch_tool_config_params.py +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_tool_config_params.py @@ -7,6 +7,7 @@ from .beta_managed_agents_auto_policy_param import BetaManagedAgentsAutoPolicyParam from .beta_managed_agents_always_ask_policy_param import BetaManagedAgentsAlwaysAskPolicyParam from .beta_managed_agents_always_allow_policy_param import BetaManagedAgentsAlwaysAllowPolicyParam +from .beta_managed_agents_web_fetch_url_sources_params import BetaManagedAgentsWebFetchURLSourcesParams __all__ = ["BetaManagedAgentsWebFetchToolConfigParams", "PermissionPolicy"] @@ -56,3 +57,6 @@ class BetaManagedAgentsWebFetchToolConfigParams(TypedDict, total=False): """ type: Literal["web_fetch"] + + url_sources: Optional[BetaManagedAgentsWebFetchURLSourcesParams] + """Which sources contribute URLs the tool may fetch. Omit to allow every source.""" diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_all.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_all.py new file mode 100644 index 000000000..b89de431c --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_all.py @@ -0,0 +1,11 @@ +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["BetaManagedAgentsWebFetchURLSourceAll"] + + +class BetaManagedAgentsWebFetchURLSourceAll(BaseModel): + """Every URL from this source may be fetched. This is the default.""" + + type: Literal["all"] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_all_param.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_all_param.py new file mode 100644 index 000000000..81e4f48ca --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_all_param.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["BetaManagedAgentsWebFetchURLSourceAllParam"] + + +class BetaManagedAgentsWebFetchURLSourceAllParam(TypedDict, total=False): + """Every URL from this source may be fetched. This is the default.""" + + type: Required[Literal["all"]] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_except.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_except.py new file mode 100644 index 000000000..67f995dee --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_except.py @@ -0,0 +1,22 @@ +from typing import List +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_managed_agents_web_fetch_url_source_tool_reference import BetaManagedAgentsWebFetchURLSourceToolReference + +__all__ = ["BetaManagedAgentsWebFetchURLSourceExcept"] + + +class BetaManagedAgentsWebFetchURLSourceExcept(BaseModel): + """ + Every tool's results contribute URLs that may be fetched, except the named tools' results. + """ + + tools: List[BetaManagedAgentsWebFetchURLSourceToolReference] + """The tools whose results do not contribute. + + Between 1 and 128 entries, each with a different name. An empty list is + rejected; use "all" to leave out no tool's results. + """ + + type: Literal["except"] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_except_param.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_except_param.py new file mode 100644 index 000000000..11dc7a71e --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_except_param.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Literal, Required, TypedDict + +from .beta_managed_agents_web_fetch_url_source_tool_reference_param import ( + BetaManagedAgentsWebFetchURLSourceToolReferenceParam, +) + +__all__ = ["BetaManagedAgentsWebFetchURLSourceExceptParam"] + + +class BetaManagedAgentsWebFetchURLSourceExceptParam(TypedDict, total=False): + """ + Every tool's results contribute URLs that may be fetched, except the named tools' results. + """ + + tools: Required[Iterable[BetaManagedAgentsWebFetchURLSourceToolReferenceParam]] + """The tools whose results do not contribute. + + Between 1 and 128 entries, each with a different name. An empty list is + rejected; use "all" to leave out no tool's results. + """ + + type: Required[Literal["except"]] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_none.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_none.py new file mode 100644 index 000000000..bba478725 --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_none.py @@ -0,0 +1,11 @@ +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["BetaManagedAgentsWebFetchURLSourceNone"] + + +class BetaManagedAgentsWebFetchURLSourceNone(BaseModel): + """This source contributes no URLs that may be fetched.""" + + type: Literal["none"] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_none_param.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_none_param.py new file mode 100644 index 000000000..f99401779 --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_none_param.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["BetaManagedAgentsWebFetchURLSourceNoneParam"] + + +class BetaManagedAgentsWebFetchURLSourceNoneParam(TypedDict, total=False): + """This source contributes no URLs that may be fetched.""" + + type: Required[Literal["none"]] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_only.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_only.py new file mode 100644 index 000000000..e4c4d7e69 --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_only.py @@ -0,0 +1,20 @@ +from typing import List +from typing_extensions import Literal + +from ..._models import BaseModel +from .beta_managed_agents_web_fetch_url_source_tool_reference import BetaManagedAgentsWebFetchURLSourceToolReference + +__all__ = ["BetaManagedAgentsWebFetchURLSourceOnly"] + + +class BetaManagedAgentsWebFetchURLSourceOnly(BaseModel): + """Only the named tools' results contribute URLs that may be fetched.""" + + tools: List[BetaManagedAgentsWebFetchURLSourceToolReference] + """The tools whose results contribute. + + Between 1 and 128 entries, each with a different name. An empty list is + rejected; use "none" to allow no tool's results. + """ + + type: Literal["only"] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_only_param.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_only_param.py new file mode 100644 index 000000000..8f52453ff --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_only_param.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Iterable +from typing_extensions import Literal, Required, TypedDict + +from .beta_managed_agents_web_fetch_url_source_tool_reference_param import ( + BetaManagedAgentsWebFetchURLSourceToolReferenceParam, +) + +__all__ = ["BetaManagedAgentsWebFetchURLSourceOnlyParam"] + + +class BetaManagedAgentsWebFetchURLSourceOnlyParam(TypedDict, total=False): + """Only the named tools' results contribute URLs that may be fetched.""" + + tools: Required[Iterable[BetaManagedAgentsWebFetchURLSourceToolReferenceParam]] + """The tools whose results contribute. + + Between 1 and 128 entries, each with a different name. An empty list is + rejected; use "none" to allow no tool's results. + """ + + type: Required[Literal["only"]] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_shorthand.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_shorthand.py new file mode 100644 index 000000000..b42600ae2 --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_shorthand.py @@ -0,0 +1,5 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["BetaManagedAgentsWebFetchURLSourceShorthand"] + +BetaManagedAgentsWebFetchURLSourceShorthand: TypeAlias = Literal["all", "none"] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_filter.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_filter.py new file mode 100644 index 000000000..7fc805726 --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_filter.py @@ -0,0 +1,20 @@ +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from ..._models import UnionDiscriminator +from .beta_managed_agents_web_fetch_url_source_all import BetaManagedAgentsWebFetchURLSourceAll +from .beta_managed_agents_web_fetch_url_source_none import BetaManagedAgentsWebFetchURLSourceNone +from .beta_managed_agents_web_fetch_url_source_only import BetaManagedAgentsWebFetchURLSourceOnly +from .beta_managed_agents_web_fetch_url_source_except import BetaManagedAgentsWebFetchURLSourceExcept + +__all__ = ["BetaManagedAgentsWebFetchURLSourceToolFilter"] + +BetaManagedAgentsWebFetchURLSourceToolFilter: TypeAlias = Annotated[ + Union[ + BetaManagedAgentsWebFetchURLSourceAll, + BetaManagedAgentsWebFetchURLSourceNone, + BetaManagedAgentsWebFetchURLSourceOnly, + BetaManagedAgentsWebFetchURLSourceExcept, + ], + UnionDiscriminator("type"), +] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_filter_param.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_filter_param.py new file mode 100644 index 000000000..6f16ea2ea --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_filter_param.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Union +from typing_extensions import TypeAlias + +from .beta_managed_agents_web_fetch_url_source_all_param import BetaManagedAgentsWebFetchURLSourceAllParam +from .beta_managed_agents_web_fetch_url_source_none_param import BetaManagedAgentsWebFetchURLSourceNoneParam +from .beta_managed_agents_web_fetch_url_source_only_param import BetaManagedAgentsWebFetchURLSourceOnlyParam +from .beta_managed_agents_web_fetch_url_source_except_param import BetaManagedAgentsWebFetchURLSourceExceptParam + +__all__ = ["BetaManagedAgentsWebFetchURLSourceToolFilterParam"] + +BetaManagedAgentsWebFetchURLSourceToolFilterParam: TypeAlias = Union[ + BetaManagedAgentsWebFetchURLSourceAllParam, + BetaManagedAgentsWebFetchURLSourceNoneParam, + BetaManagedAgentsWebFetchURLSourceOnlyParam, + BetaManagedAgentsWebFetchURLSourceExceptParam, +] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_filter_params.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_filter_params.py new file mode 100644 index 000000000..012167b70 --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_filter_params.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import Union +from typing_extensions import TypeAlias + +from .beta_managed_agents_web_fetch_url_source_shorthand import BetaManagedAgentsWebFetchURLSourceShorthand +from .beta_managed_agents_web_fetch_url_source_tool_filter_param import ( + BetaManagedAgentsWebFetchURLSourceToolFilterParam, +) + +__all__ = ["BetaManagedAgentsWebFetchURLSourceToolFilterParams"] + +BetaManagedAgentsWebFetchURLSourceToolFilterParams: TypeAlias = Union[ + BetaManagedAgentsWebFetchURLSourceShorthand, BetaManagedAgentsWebFetchURLSourceToolFilterParam +] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_reference.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_reference.py new file mode 100644 index 000000000..372531416 --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_reference.py @@ -0,0 +1,18 @@ +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["BetaManagedAgentsWebFetchURLSourceToolReference"] + + +class BetaManagedAgentsWebFetchURLSourceToolReference(BaseModel): + """Names one tool in an only or except list.""" + + name: str + """Name of the tool. + + Compared exactly, so upper and lower case letters are different. + """ + + type: Literal["tool_reference"] + """Must be "tool_reference".""" diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_reference_param.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_reference_param.py new file mode 100644 index 000000000..738c9319e --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_tool_reference_param.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["BetaManagedAgentsWebFetchURLSourceToolReferenceParam"] + + +class BetaManagedAgentsWebFetchURLSourceToolReferenceParam(TypedDict, total=False): + """Names one tool in an only or except list.""" + + name: Required[str] + """Name of the tool. + + Compared exactly, so upper and lower case letters are different. + """ + + type: Required[Literal["tool_reference"]] + """Must be "tool_reference".""" diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_user_input.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_user_input.py new file mode 100644 index 000000000..e7539bd0b --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_user_input.py @@ -0,0 +1,12 @@ +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from ..._models import UnionDiscriminator +from .beta_managed_agents_web_fetch_url_source_all import BetaManagedAgentsWebFetchURLSourceAll +from .beta_managed_agents_web_fetch_url_source_none import BetaManagedAgentsWebFetchURLSourceNone + +__all__ = ["BetaManagedAgentsWebFetchURLSourceUserInput"] + +BetaManagedAgentsWebFetchURLSourceUserInput: TypeAlias = Annotated[ + Union[BetaManagedAgentsWebFetchURLSourceAll, BetaManagedAgentsWebFetchURLSourceNone], UnionDiscriminator("type") +] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_user_input_param.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_user_input_param.py new file mode 100644 index 000000000..1a12d3715 --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_user_input_param.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Union +from typing_extensions import TypeAlias + +from .beta_managed_agents_web_fetch_url_source_all_param import BetaManagedAgentsWebFetchURLSourceAllParam +from .beta_managed_agents_web_fetch_url_source_none_param import BetaManagedAgentsWebFetchURLSourceNoneParam + +__all__ = ["BetaManagedAgentsWebFetchURLSourceUserInputParam"] + +BetaManagedAgentsWebFetchURLSourceUserInputParam: TypeAlias = Union[ + BetaManagedAgentsWebFetchURLSourceAllParam, BetaManagedAgentsWebFetchURLSourceNoneParam +] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_user_input_params.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_user_input_params.py new file mode 100644 index 000000000..d59aa1e4c --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_source_user_input_params.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Union +from typing_extensions import TypeAlias + +from .beta_managed_agents_web_fetch_url_source_shorthand import BetaManagedAgentsWebFetchURLSourceShorthand +from .beta_managed_agents_web_fetch_url_source_user_input_param import BetaManagedAgentsWebFetchURLSourceUserInputParam + +__all__ = ["BetaManagedAgentsWebFetchURLSourceUserInputParams"] + +BetaManagedAgentsWebFetchURLSourceUserInputParams: TypeAlias = Union[ + BetaManagedAgentsWebFetchURLSourceShorthand, BetaManagedAgentsWebFetchURLSourceUserInputParam +] diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_sources.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_sources.py new file mode 100644 index 000000000..df56f2bc4 --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_sources.py @@ -0,0 +1,32 @@ +from typing import Optional + +from ..._models import BaseModel +from .beta_managed_agents_web_fetch_url_source_user_input import BetaManagedAgentsWebFetchURLSourceUserInput +from .beta_managed_agents_web_fetch_url_source_tool_filter import BetaManagedAgentsWebFetchURLSourceToolFilter + +__all__ = ["BetaManagedAgentsWebFetchURLSources"] + + +class BetaManagedAgentsWebFetchURLSources(BaseModel): + """Which sources contribute URLs the web_fetch tool may fetch. + + A key that is null was not set and allows every URL from that source. + """ + + client_tool_results: Optional[BetaManagedAgentsWebFetchURLSourceToolFilter] = None + """Which custom tools' results contribute URLs that may be fetched. + + Null when not set, which allows every custom tool's results. + """ + + server_tool_results: Optional[BetaManagedAgentsWebFetchURLSourceToolFilter] = None + """ + Which of the web_search and web_fetch tools' results contribute URLs that may be + fetched. Null when not set, which allows both. + """ + + user_input: Optional[BetaManagedAgentsWebFetchURLSourceUserInput] = None + """Whether URLs in the text of user messages may be fetched. + + Null when not set, which allows them. + """ diff --git a/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_sources_params.py b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_sources_params.py new file mode 100644 index 000000000..039a6c82e --- /dev/null +++ b/src/anthropic/types/beta/beta_managed_agents_web_fetch_url_sources_params.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +from .beta_managed_agents_web_fetch_url_source_user_input_params import ( + BetaManagedAgentsWebFetchURLSourceUserInputParams, +) +from .beta_managed_agents_web_fetch_url_source_tool_filter_params import ( + BetaManagedAgentsWebFetchURLSourceToolFilterParams, +) + +__all__ = ["BetaManagedAgentsWebFetchURLSourcesParams"] + + +class BetaManagedAgentsWebFetchURLSourcesParams(TypedDict, total=False): + """Which sources contribute URLs the web_fetch tool may fetch. + + When web_fetch is limited to URLs the conversation has already shown the model (in a user message, a custom tool's result, or an earlier web_search or web_fetch result), each key narrows one of those sources and defaults to "all". Setting all three keys to "none" is rejected. + """ + + client_tool_results: Optional[BetaManagedAgentsWebFetchURLSourceToolFilterParams] + """ + Which custom tools' results contribute URLs that may be fetched: "all" (the + default), "none", or an only or except list. Each name in a list must be a + custom tool in the same tools array. + """ + + server_tool_results: Optional[BetaManagedAgentsWebFetchURLSourceToolFilterParams] + """ + Which of the web_search and web_fetch tools' results contribute URLs that may be + fetched: "all" (the default), "none", or an only or except list. Each name in a + list must be "web_search" or "web_fetch". + """ + + user_input: Optional[BetaManagedAgentsWebFetchURLSourceUserInputParams] + """ + Whether URLs in the text of user messages may be fetched: "all" (the default) or + "none". + """ diff --git a/src/anthropic/types/beta/beta_model_capabilities.py b/src/anthropic/types/beta/beta_model_capabilities.py index 8cef4644d..a29c63be7 100644 --- a/src/anthropic/types/beta/beta_model_capabilities.py +++ b/src/anthropic/types/beta/beta_model_capabilities.py @@ -5,6 +5,7 @@ from .beta_capability_support import BetaCapabilitySupport from .beta_thinking_capability import BetaThinkingCapability from .beta_compaction_capability import BetaCompactionCapability +from .beta_server_tools_capability import BetaServerToolsCapability from .beta_context_management_capability import BetaContextManagementCapability __all__ = ["BetaModelCapabilities"] @@ -20,7 +21,12 @@ class BetaModelCapabilities(BaseModel): """Whether the model supports citation generation.""" code_execution: BetaCapabilitySupport - """Whether the model supports code execution tools.""" + """ + Whether code that the model runs in the code execution tool can call the + request's other tools, as in programmatic tool calling and dynamic filtering for + web search and web fetch. Support for the code execution tool itself is in + `server_tools.code_execution`. + """ compaction: Optional[BetaCompactionCapability] = None """ @@ -40,6 +46,14 @@ class BetaModelCapabilities(BaseModel): pdf_input: BetaCapabilitySupport """Whether the model accepts PDF content blocks.""" + server_tools: BetaServerToolsCapability + """Whether this model supports the web search and code execution server tools. + + `supported` is true when the model supports at least one of the tools. A + supported tool can still be rejected for your organization, for example when an + admin has turned web search off. + """ + structured_outputs: BetaCapabilitySupport """Whether the model supports structured output / JSON mode / strict tool schemas.""" diff --git a/src/anthropic/types/beta/beta_model_info.py b/src/anthropic/types/beta/beta_model_info.py index fef60b840..0b347e690 100644 --- a/src/anthropic/types/beta/beta_model_info.py +++ b/src/anthropic/types/beta/beta_model_info.py @@ -3,6 +3,7 @@ from typing_extensions import Literal from ..._models import BaseModel +from .beta_model_line import BetaModelLine from .beta_model_capabilities import BetaModelCapabilities __all__ = ["BetaModelInfo"] @@ -31,15 +32,50 @@ class BetaModelInfo(BaseModel): May be set to an epoch value if the release date is unknown. """ + deprecated_at: Optional[datetime] = None + """ + RFC 3339 datetime string representing the time of the model's most recent + deprecation. Populated for `deprecated` and `retired` models; `null` while the + model is `active`. + """ + display_name: str """A human-readable name for the model.""" + lifecycle: Literal["active", "deprecated", "retired"] + """The model's current lifecycle stage. + + - `active`: The model is available for use, open to new adopters, and not + scheduled for retirement. + - `deprecated`: The model remains callable for organizations with existing + access, but is headed for retirement and closed to new adopters. + - `retired`: The model is no longer available for use; inference requests naming + it fail. It remains in the catalogue as the historical record of its + retirement. + """ + + line: Optional[BetaModelLine] = None + """ + The model line this model belongs to, such as `opus` for both Claude Opus 4.5 + and Claude Opus 4.6. More lines may be added. `null` when the model belongs to + no line; do not infer a line from the `id`. + """ + max_input_tokens: Optional[int] = None """Maximum input context window size in tokens for this model.""" max_tokens: Optional[int] = None """Maximum value for the `max_tokens` parameter when using this model.""" + retires_at: Optional[datetime] = None + """ + RFC 3339 datetime string representing the model's currently scheduled retirement + date. The schedule can be revised until retirement occurs; `null` while the + model is `active` or while no retirement is scheduled. A past date on a + `deprecated` model means retirement is overdue, not that it has occurred: + `lifecycle` is the retirement signal. + """ + type: Literal["model"] """Object type. diff --git a/src/anthropic/types/beta/beta_model_line.py b/src/anthropic/types/beta/beta_model_line.py new file mode 100644 index 000000000..af177ed9f --- /dev/null +++ b/src/anthropic/types/beta/beta_model_line.py @@ -0,0 +1,5 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["BetaModelLine"] + +BetaModelLine: TypeAlias = Literal["haiku", "sonnet", "opus", "fable", "mythos"] diff --git a/src/anthropic/types/beta/beta_response_browser_close_tab_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_close_tab_tool_use_block.py new file mode 100644 index 000000000..3589b389b --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_close_tab_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_close_tab_tool_use_block import BetaBrowserCloseTabToolUseBlock + +__all__ = ["BetaResponseBrowserCloseTabToolUseBlock"] + +BetaResponseBrowserCloseTabToolUseBlock = BetaBrowserCloseTabToolUseBlock +""" +BetaResponseBrowserCloseTabToolUseBlock has been renamed to BetaBrowserCloseTabToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_double_click_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_double_click_tool_use_block.py new file mode 100644 index 000000000..8f8b24109 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_double_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_double_click_tool_use_block import BetaBrowserDoubleClickToolUseBlock + +__all__ = ["BetaResponseBrowserDoubleClickToolUseBlock"] + +BetaResponseBrowserDoubleClickToolUseBlock = BetaBrowserDoubleClickToolUseBlock +""" +BetaResponseBrowserDoubleClickToolUseBlock has been renamed to BetaBrowserDoubleClickToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_file_upload_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_file_upload_tool_use_block.py new file mode 100644 index 000000000..90a119b50 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_file_upload_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_file_upload_tool_use_block import BetaBrowserFileUploadToolUseBlock + +__all__ = ["BetaResponseBrowserFileUploadToolUseBlock"] + +BetaResponseBrowserFileUploadToolUseBlock = BetaBrowserFileUploadToolUseBlock +""" +BetaResponseBrowserFileUploadToolUseBlock has been renamed to BetaBrowserFileUploadToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_find_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_find_tool_use_block.py new file mode 100644 index 000000000..7c96cc13f --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_find_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_find_tool_use_block import BetaBrowserFindToolUseBlock + +__all__ = ["BetaResponseBrowserFindToolUseBlock"] + +BetaResponseBrowserFindToolUseBlock = BetaBrowserFindToolUseBlock +""" +BetaResponseBrowserFindToolUseBlock has been renamed to BetaBrowserFindToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_form_input_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_form_input_tool_use_block.py new file mode 100644 index 000000000..d49262b7d --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_form_input_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_form_input_tool_use_block import BetaBrowserFormInputToolUseBlock + +__all__ = ["BetaResponseBrowserFormInputToolUseBlock"] + +BetaResponseBrowserFormInputToolUseBlock = BetaBrowserFormInputToolUseBlock +""" +BetaResponseBrowserFormInputToolUseBlock has been renamed to BetaBrowserFormInputToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_get_page_text_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_get_page_text_tool_use_block.py new file mode 100644 index 000000000..a607169db --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_get_page_text_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_get_page_text_tool_use_block import BetaBrowserGetPageTextToolUseBlock + +__all__ = ["BetaResponseBrowserGetPageTextToolUseBlock"] + +BetaResponseBrowserGetPageTextToolUseBlock = BetaBrowserGetPageTextToolUseBlock +""" +BetaResponseBrowserGetPageTextToolUseBlock has been renamed to BetaBrowserGetPageTextToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_hold_key_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_hold_key_tool_use_block.py new file mode 100644 index 000000000..67c321ead --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_hold_key_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_hold_key_tool_use_block import BetaBrowserHoldKeyToolUseBlock + +__all__ = ["BetaResponseBrowserHoldKeyToolUseBlock"] + +BetaResponseBrowserHoldKeyToolUseBlock = BetaBrowserHoldKeyToolUseBlock +""" +BetaResponseBrowserHoldKeyToolUseBlock has been renamed to BetaBrowserHoldKeyToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_hover_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_hover_tool_use_block.py new file mode 100644 index 000000000..0fb190712 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_hover_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_hover_tool_use_block import BetaBrowserHoverToolUseBlock + +__all__ = ["BetaResponseBrowserHoverToolUseBlock"] + +BetaResponseBrowserHoverToolUseBlock = BetaBrowserHoverToolUseBlock +""" +BetaResponseBrowserHoverToolUseBlock has been renamed to BetaBrowserHoverToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_javascript_exec_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_javascript_exec_tool_use_block.py new file mode 100644 index 000000000..f3ea23d5e --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_javascript_exec_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_javascript_exec_tool_use_block import BetaBrowserJavascriptExecToolUseBlock + +__all__ = ["BetaResponseBrowserJavascriptExecToolUseBlock"] + +BetaResponseBrowserJavascriptExecToolUseBlock = BetaBrowserJavascriptExecToolUseBlock +""" +BetaResponseBrowserJavascriptExecToolUseBlock has been renamed to BetaBrowserJavascriptExecToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_key_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_key_tool_use_block.py new file mode 100644 index 000000000..4913ee1bb --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_key_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_key_tool_use_block import BetaBrowserKeyToolUseBlock + +__all__ = ["BetaResponseBrowserKeyToolUseBlock"] + +BetaResponseBrowserKeyToolUseBlock = BetaBrowserKeyToolUseBlock +""" +BetaResponseBrowserKeyToolUseBlock has been renamed to BetaBrowserKeyToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_left_click_drag_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_left_click_drag_tool_use_block.py new file mode 100644 index 000000000..ca260bdb1 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_left_click_drag_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_left_click_drag_tool_use_block import BetaBrowserLeftClickDragToolUseBlock + +__all__ = ["BetaResponseBrowserLeftClickDragToolUseBlock"] + +BetaResponseBrowserLeftClickDragToolUseBlock = BetaBrowserLeftClickDragToolUseBlock +""" +BetaResponseBrowserLeftClickDragToolUseBlock has been renamed to BetaBrowserLeftClickDragToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_left_click_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_left_click_tool_use_block.py new file mode 100644 index 000000000..e67d1b56a --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_left_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_left_click_tool_use_block import BetaBrowserLeftClickToolUseBlock + +__all__ = ["BetaResponseBrowserLeftClickToolUseBlock"] + +BetaResponseBrowserLeftClickToolUseBlock = BetaBrowserLeftClickToolUseBlock +""" +BetaResponseBrowserLeftClickToolUseBlock has been renamed to BetaBrowserLeftClickToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_left_mouse_down_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_left_mouse_down_tool_use_block.py new file mode 100644 index 000000000..506098c17 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_left_mouse_down_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_left_mouse_down_tool_use_block import BetaBrowserLeftMouseDownToolUseBlock + +__all__ = ["BetaResponseBrowserLeftMouseDownToolUseBlock"] + +BetaResponseBrowserLeftMouseDownToolUseBlock = BetaBrowserLeftMouseDownToolUseBlock +""" +BetaResponseBrowserLeftMouseDownToolUseBlock has been renamed to BetaBrowserLeftMouseDownToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_left_mouse_up_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_left_mouse_up_tool_use_block.py new file mode 100644 index 000000000..cbe7f460a --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_left_mouse_up_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_left_mouse_up_tool_use_block import BetaBrowserLeftMouseUpToolUseBlock + +__all__ = ["BetaResponseBrowserLeftMouseUpToolUseBlock"] + +BetaResponseBrowserLeftMouseUpToolUseBlock = BetaBrowserLeftMouseUpToolUseBlock +""" +BetaResponseBrowserLeftMouseUpToolUseBlock has been renamed to BetaBrowserLeftMouseUpToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_list_tabs_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_list_tabs_tool_use_block.py new file mode 100644 index 000000000..18e5e7715 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_list_tabs_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_list_tabs_tool_use_block import BetaBrowserListTabsToolUseBlock + +__all__ = ["BetaResponseBrowserListTabsToolUseBlock"] + +BetaResponseBrowserListTabsToolUseBlock = BetaBrowserListTabsToolUseBlock +""" +BetaResponseBrowserListTabsToolUseBlock has been renamed to BetaBrowserListTabsToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_middle_click_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_middle_click_tool_use_block.py new file mode 100644 index 000000000..e51f5a685 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_middle_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_middle_click_tool_use_block import BetaBrowserMiddleClickToolUseBlock + +__all__ = ["BetaResponseBrowserMiddleClickToolUseBlock"] + +BetaResponseBrowserMiddleClickToolUseBlock = BetaBrowserMiddleClickToolUseBlock +""" +BetaResponseBrowserMiddleClickToolUseBlock has been renamed to BetaBrowserMiddleClickToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_mouse_move_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_mouse_move_tool_use_block.py new file mode 100644 index 000000000..a0ce55b01 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_mouse_move_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_mouse_move_tool_use_block import BetaBrowserMouseMoveToolUseBlock + +__all__ = ["BetaResponseBrowserMouseMoveToolUseBlock"] + +BetaResponseBrowserMouseMoveToolUseBlock = BetaBrowserMouseMoveToolUseBlock +""" +BetaResponseBrowserMouseMoveToolUseBlock has been renamed to BetaBrowserMouseMoveToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_navigate_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_navigate_tool_use_block.py new file mode 100644 index 000000000..a4330e3cc --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_navigate_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_navigate_tool_use_block import BetaBrowserNavigateToolUseBlock + +__all__ = ["BetaResponseBrowserNavigateToolUseBlock"] + +BetaResponseBrowserNavigateToolUseBlock = BetaBrowserNavigateToolUseBlock +""" +BetaResponseBrowserNavigateToolUseBlock has been renamed to BetaBrowserNavigateToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_new_tab_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_new_tab_tool_use_block.py new file mode 100644 index 000000000..ff31c12f3 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_new_tab_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_new_tab_tool_use_block import BetaBrowserNewTabToolUseBlock + +__all__ = ["BetaResponseBrowserNewTabToolUseBlock"] + +BetaResponseBrowserNewTabToolUseBlock = BetaBrowserNewTabToolUseBlock +""" +BetaResponseBrowserNewTabToolUseBlock has been renamed to BetaBrowserNewTabToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_read_console_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_read_console_tool_use_block.py new file mode 100644 index 000000000..e7c0e5115 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_read_console_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_read_console_tool_use_block import BetaBrowserReadConsoleToolUseBlock + +__all__ = ["BetaResponseBrowserReadConsoleToolUseBlock"] + +BetaResponseBrowserReadConsoleToolUseBlock = BetaBrowserReadConsoleToolUseBlock +""" +BetaResponseBrowserReadConsoleToolUseBlock has been renamed to BetaBrowserReadConsoleToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_read_network_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_read_network_tool_use_block.py new file mode 100644 index 000000000..1e2c1bb86 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_read_network_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_read_network_tool_use_block import BetaBrowserReadNetworkToolUseBlock + +__all__ = ["BetaResponseBrowserReadNetworkToolUseBlock"] + +BetaResponseBrowserReadNetworkToolUseBlock = BetaBrowserReadNetworkToolUseBlock +""" +BetaResponseBrowserReadNetworkToolUseBlock has been renamed to BetaBrowserReadNetworkToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_read_page_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_read_page_tool_use_block.py new file mode 100644 index 000000000..d776917c7 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_read_page_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_read_page_tool_use_block import BetaBrowserReadPageToolUseBlock + +__all__ = ["BetaResponseBrowserReadPageToolUseBlock"] + +BetaResponseBrowserReadPageToolUseBlock = BetaBrowserReadPageToolUseBlock +""" +BetaResponseBrowserReadPageToolUseBlock has been renamed to BetaBrowserReadPageToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_right_click_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_right_click_tool_use_block.py new file mode 100644 index 000000000..649adcd7e --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_right_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_right_click_tool_use_block import BetaBrowserRightClickToolUseBlock + +__all__ = ["BetaResponseBrowserRightClickToolUseBlock"] + +BetaResponseBrowserRightClickToolUseBlock = BetaBrowserRightClickToolUseBlock +""" +BetaResponseBrowserRightClickToolUseBlock has been renamed to BetaBrowserRightClickToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_screenshot_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_screenshot_tool_use_block.py new file mode 100644 index 000000000..039d33737 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_screenshot_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_screenshot_tool_use_block import BetaBrowserScreenshotToolUseBlock + +__all__ = ["BetaResponseBrowserScreenshotToolUseBlock"] + +BetaResponseBrowserScreenshotToolUseBlock = BetaBrowserScreenshotToolUseBlock +""" +BetaResponseBrowserScreenshotToolUseBlock has been renamed to BetaBrowserScreenshotToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_scroll_to_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_scroll_to_tool_use_block.py new file mode 100644 index 000000000..3d3d359a0 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_scroll_to_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_scroll_to_tool_use_block import BetaBrowserScrollToToolUseBlock + +__all__ = ["BetaResponseBrowserScrollToToolUseBlock"] + +BetaResponseBrowserScrollToToolUseBlock = BetaBrowserScrollToToolUseBlock +""" +BetaResponseBrowserScrollToToolUseBlock has been renamed to BetaBrowserScrollToToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_scroll_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_scroll_tool_use_block.py new file mode 100644 index 000000000..4c8930a31 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_scroll_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_scroll_tool_use_block import BetaBrowserScrollToolUseBlock + +__all__ = ["BetaResponseBrowserScrollToolUseBlock"] + +BetaResponseBrowserScrollToolUseBlock = BetaBrowserScrollToolUseBlock +""" +BetaResponseBrowserScrollToolUseBlock has been renamed to BetaBrowserScrollToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_switch_tab_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_switch_tab_tool_use_block.py new file mode 100644 index 000000000..230696814 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_switch_tab_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_switch_tab_tool_use_block import BetaBrowserSwitchTabToolUseBlock + +__all__ = ["BetaResponseBrowserSwitchTabToolUseBlock"] + +BetaResponseBrowserSwitchTabToolUseBlock = BetaBrowserSwitchTabToolUseBlock +""" +BetaResponseBrowserSwitchTabToolUseBlock has been renamed to BetaBrowserSwitchTabToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_tool_use_block.py new file mode 100644 index 000000000..1bf2bdd2d --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_tool_use_block.py @@ -0,0 +1,6 @@ +from .beta_browser_tool_use_block import BetaBrowserToolUseBlock + +__all__ = ["BetaResponseBrowserToolUseBlock"] + +BetaResponseBrowserToolUseBlock = BetaBrowserToolUseBlock +"""BetaResponseBrowserToolUseBlock has been renamed to BetaBrowserToolUseBlock""" diff --git a/src/anthropic/types/beta/beta_response_browser_triple_click_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_triple_click_tool_use_block.py new file mode 100644 index 000000000..b2c8bfa2a --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_triple_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_triple_click_tool_use_block import BetaBrowserTripleClickToolUseBlock + +__all__ = ["BetaResponseBrowserTripleClickToolUseBlock"] + +BetaResponseBrowserTripleClickToolUseBlock = BetaBrowserTripleClickToolUseBlock +""" +BetaResponseBrowserTripleClickToolUseBlock has been renamed to BetaBrowserTripleClickToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_type_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_type_tool_use_block.py new file mode 100644 index 000000000..203238280 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_type_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_type_tool_use_block import BetaBrowserTypeToolUseBlock + +__all__ = ["BetaResponseBrowserTypeToolUseBlock"] + +BetaResponseBrowserTypeToolUseBlock = BetaBrowserTypeToolUseBlock +""" +BetaResponseBrowserTypeToolUseBlock has been renamed to BetaBrowserTypeToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_wait_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_wait_tool_use_block.py new file mode 100644 index 000000000..581b4fd18 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_wait_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_wait_tool_use_block import BetaBrowserWaitToolUseBlock + +__all__ = ["BetaResponseBrowserWaitToolUseBlock"] + +BetaResponseBrowserWaitToolUseBlock = BetaBrowserWaitToolUseBlock +""" +BetaResponseBrowserWaitToolUseBlock has been renamed to BetaBrowserWaitToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_browser_zoom_tool_use_block.py b/src/anthropic/types/beta/beta_response_browser_zoom_tool_use_block.py new file mode 100644 index 000000000..96ee6de0c --- /dev/null +++ b/src/anthropic/types/beta/beta_response_browser_zoom_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_browser_zoom_tool_use_block import BetaBrowserZoomToolUseBlock + +__all__ = ["BetaResponseBrowserZoomToolUseBlock"] + +BetaResponseBrowserZoomToolUseBlock = BetaBrowserZoomToolUseBlock +""" +BetaResponseBrowserZoomToolUseBlock has been renamed to BetaBrowserZoomToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_cursor_position_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_cursor_position_tool_use_block.py new file mode 100644 index 000000000..a159509a7 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_cursor_position_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_cursor_position_tool_use_block import BetaComputerCursorPositionToolUseBlock + +__all__ = ["BetaResponseComputerCursorPositionToolUseBlock"] + +BetaResponseComputerCursorPositionToolUseBlock = BetaComputerCursorPositionToolUseBlock +""" +BetaResponseComputerCursorPositionToolUseBlock has been renamed to BetaComputerCursorPositionToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_double_click_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_double_click_tool_use_block.py new file mode 100644 index 000000000..42b67fd79 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_double_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_double_click_tool_use_block import BetaComputerDoubleClickToolUseBlock + +__all__ = ["BetaResponseComputerDoubleClickToolUseBlock"] + +BetaResponseComputerDoubleClickToolUseBlock = BetaComputerDoubleClickToolUseBlock +""" +BetaResponseComputerDoubleClickToolUseBlock has been renamed to BetaComputerDoubleClickToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_hold_key_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_hold_key_tool_use_block.py new file mode 100644 index 000000000..2324c5a99 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_hold_key_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_hold_key_tool_use_block import BetaComputerHoldKeyToolUseBlock + +__all__ = ["BetaResponseComputerHoldKeyToolUseBlock"] + +BetaResponseComputerHoldKeyToolUseBlock = BetaComputerHoldKeyToolUseBlock +""" +BetaResponseComputerHoldKeyToolUseBlock has been renamed to BetaComputerHoldKeyToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_key_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_key_tool_use_block.py new file mode 100644 index 000000000..4170a6c40 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_key_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_key_tool_use_block import BetaComputerKeyToolUseBlock + +__all__ = ["BetaResponseComputerKeyToolUseBlock"] + +BetaResponseComputerKeyToolUseBlock = BetaComputerKeyToolUseBlock +""" +BetaResponseComputerKeyToolUseBlock has been renamed to BetaComputerKeyToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_left_click_drag_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_left_click_drag_tool_use_block.py new file mode 100644 index 000000000..0a3703e30 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_left_click_drag_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_left_click_drag_tool_use_block import BetaComputerLeftClickDragToolUseBlock + +__all__ = ["BetaResponseComputerLeftClickDragToolUseBlock"] + +BetaResponseComputerLeftClickDragToolUseBlock = BetaComputerLeftClickDragToolUseBlock +""" +BetaResponseComputerLeftClickDragToolUseBlock has been renamed to BetaComputerLeftClickDragToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_left_click_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_left_click_tool_use_block.py new file mode 100644 index 000000000..c24139320 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_left_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_left_click_tool_use_block import BetaComputerLeftClickToolUseBlock + +__all__ = ["BetaResponseComputerLeftClickToolUseBlock"] + +BetaResponseComputerLeftClickToolUseBlock = BetaComputerLeftClickToolUseBlock +""" +BetaResponseComputerLeftClickToolUseBlock has been renamed to BetaComputerLeftClickToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_left_mouse_down_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_left_mouse_down_tool_use_block.py new file mode 100644 index 000000000..59592dcfa --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_left_mouse_down_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_left_mouse_down_tool_use_block import BetaComputerLeftMouseDownToolUseBlock + +__all__ = ["BetaResponseComputerLeftMouseDownToolUseBlock"] + +BetaResponseComputerLeftMouseDownToolUseBlock = BetaComputerLeftMouseDownToolUseBlock +""" +BetaResponseComputerLeftMouseDownToolUseBlock has been renamed to BetaComputerLeftMouseDownToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_left_mouse_up_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_left_mouse_up_tool_use_block.py new file mode 100644 index 000000000..06c728457 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_left_mouse_up_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_left_mouse_up_tool_use_block import BetaComputerLeftMouseUpToolUseBlock + +__all__ = ["BetaResponseComputerLeftMouseUpToolUseBlock"] + +BetaResponseComputerLeftMouseUpToolUseBlock = BetaComputerLeftMouseUpToolUseBlock +""" +BetaResponseComputerLeftMouseUpToolUseBlock has been renamed to BetaComputerLeftMouseUpToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_middle_click_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_middle_click_tool_use_block.py new file mode 100644 index 000000000..719a0f79e --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_middle_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_middle_click_tool_use_block import BetaComputerMiddleClickToolUseBlock + +__all__ = ["BetaResponseComputerMiddleClickToolUseBlock"] + +BetaResponseComputerMiddleClickToolUseBlock = BetaComputerMiddleClickToolUseBlock +""" +BetaResponseComputerMiddleClickToolUseBlock has been renamed to BetaComputerMiddleClickToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_mouse_move_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_mouse_move_tool_use_block.py new file mode 100644 index 000000000..7a86ffee0 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_mouse_move_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_mouse_move_tool_use_block import BetaComputerMouseMoveToolUseBlock + +__all__ = ["BetaResponseComputerMouseMoveToolUseBlock"] + +BetaResponseComputerMouseMoveToolUseBlock = BetaComputerMouseMoveToolUseBlock +""" +BetaResponseComputerMouseMoveToolUseBlock has been renamed to BetaComputerMouseMoveToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_right_click_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_right_click_tool_use_block.py new file mode 100644 index 000000000..131711178 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_right_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_right_click_tool_use_block import BetaComputerRightClickToolUseBlock + +__all__ = ["BetaResponseComputerRightClickToolUseBlock"] + +BetaResponseComputerRightClickToolUseBlock = BetaComputerRightClickToolUseBlock +""" +BetaResponseComputerRightClickToolUseBlock has been renamed to BetaComputerRightClickToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_screenshot_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_screenshot_tool_use_block.py new file mode 100644 index 000000000..90214c697 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_screenshot_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_screenshot_tool_use_block import BetaComputerScreenshotToolUseBlock + +__all__ = ["BetaResponseComputerScreenshotToolUseBlock"] + +BetaResponseComputerScreenshotToolUseBlock = BetaComputerScreenshotToolUseBlock +""" +BetaResponseComputerScreenshotToolUseBlock has been renamed to BetaComputerScreenshotToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_scroll_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_scroll_tool_use_block.py new file mode 100644 index 000000000..8707e82d9 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_scroll_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_scroll_tool_use_block import BetaComputerScrollToolUseBlock + +__all__ = ["BetaResponseComputerScrollToolUseBlock"] + +BetaResponseComputerScrollToolUseBlock = BetaComputerScrollToolUseBlock +""" +BetaResponseComputerScrollToolUseBlock has been renamed to BetaComputerScrollToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_tool_use_block.py new file mode 100644 index 000000000..ee401077d --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_tool_use_block.py @@ -0,0 +1,6 @@ +from .beta_computer_tool_use_block import BetaComputerToolUseBlock + +__all__ = ["BetaResponseComputerToolUseBlock"] + +BetaResponseComputerToolUseBlock = BetaComputerToolUseBlock +"""BetaResponseComputerToolUseBlock has been renamed to BetaComputerToolUseBlock""" diff --git a/src/anthropic/types/beta/beta_response_computer_triple_click_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_triple_click_tool_use_block.py new file mode 100644 index 000000000..094697d3e --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_triple_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_triple_click_tool_use_block import BetaComputerTripleClickToolUseBlock + +__all__ = ["BetaResponseComputerTripleClickToolUseBlock"] + +BetaResponseComputerTripleClickToolUseBlock = BetaComputerTripleClickToolUseBlock +""" +BetaResponseComputerTripleClickToolUseBlock has been renamed to BetaComputerTripleClickToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_type_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_type_tool_use_block.py new file mode 100644 index 000000000..b225dec32 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_type_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_type_tool_use_block import BetaComputerTypeToolUseBlock + +__all__ = ["BetaResponseComputerTypeToolUseBlock"] + +BetaResponseComputerTypeToolUseBlock = BetaComputerTypeToolUseBlock +""" +BetaResponseComputerTypeToolUseBlock has been renamed to BetaComputerTypeToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_wait_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_wait_tool_use_block.py new file mode 100644 index 000000000..764f6c520 --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_wait_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_wait_tool_use_block import BetaComputerWaitToolUseBlock + +__all__ = ["BetaResponseComputerWaitToolUseBlock"] + +BetaResponseComputerWaitToolUseBlock = BetaComputerWaitToolUseBlock +""" +BetaResponseComputerWaitToolUseBlock has been renamed to BetaComputerWaitToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_computer_zoom_tool_use_block.py b/src/anthropic/types/beta/beta_response_computer_zoom_tool_use_block.py new file mode 100644 index 000000000..f90a89ddb --- /dev/null +++ b/src/anthropic/types/beta/beta_response_computer_zoom_tool_use_block.py @@ -0,0 +1,8 @@ +from .beta_computer_zoom_tool_use_block import BetaComputerZoomToolUseBlock + +__all__ = ["BetaResponseComputerZoomToolUseBlock"] + +BetaResponseComputerZoomToolUseBlock = BetaComputerZoomToolUseBlock +""" +BetaResponseComputerZoomToolUseBlock has been renamed to BetaComputerZoomToolUseBlock +""" diff --git a/src/anthropic/types/beta/beta_response_tool_use_block_union.py b/src/anthropic/types/beta/beta_response_tool_use_block_union.py new file mode 100644 index 000000000..5e0e422ab --- /dev/null +++ b/src/anthropic/types/beta/beta_response_tool_use_block_union.py @@ -0,0 +1,6 @@ +from .beta_toolset_tool_use_block import BetaToolsetToolUseBlock + +__all__ = ["BetaResponseToolUseBlockUnion"] + +BetaResponseToolUseBlockUnion = BetaToolsetToolUseBlock +"""BetaResponseToolUseBlockUnion has been renamed to BetaToolsetToolUseBlock""" diff --git a/src/anthropic/types/beta/beta_server_tools_capability.py b/src/anthropic/types/beta/beta_server_tools_capability.py new file mode 100644 index 000000000..1a564761e --- /dev/null +++ b/src/anthropic/types/beta/beta_server_tools_capability.py @@ -0,0 +1,23 @@ +from ..._models import BaseModel +from .beta_capability_support import BetaCapabilitySupport + +__all__ = ["BetaServerToolsCapability"] + + +class BetaServerToolsCapability(BaseModel): + """Web search and code execution tool support, with one entry per tool.""" + + code_execution: BetaCapabilitySupport + """ + Whether the model supports the code execution tool: true when the model supports + at least one version of the tool, not necessarily every version. + """ + + supported: bool + """Whether this capability is supported by the model.""" + + web_search: BetaCapabilitySupport + """ + Whether the model supports the web search tool: true when the model supports at + least one version of the tool, not necessarily every version. + """ diff --git a/src/anthropic/types/beta/beta_thinking_types.py b/src/anthropic/types/beta/beta_thinking_types.py index 5e9707b2e..0cc2a23cc 100644 --- a/src/anthropic/types/beta/beta_thinking_types.py +++ b/src/anthropic/types/beta/beta_thinking_types.py @@ -5,10 +5,26 @@ class BetaThinkingTypes(BaseModel): - """Supported thinking type configurations.""" + """Which `thinking.type` values the model accepts on requests. + + Read each key on its own: for example, `enabled` can be false while `disabled` is true. + """ adaptive: BetaCapabilitySupport - """Whether the model supports thinking with type 'adaptive' (auto).""" + """ + Whether the model accepts thinking with type 'adaptive' (the model decides + whether and how much to think). + """ + + disabled: BetaCapabilitySupport + """Whether the model accepts thinking with type 'disabled' (thinking turned off). + + False exactly when a request that sends it gets a 400 from this model. True on a + model that does not support thinking. + """ enabled: BetaCapabilitySupport - """Whether the model supports thinking with type 'enabled'.""" + """ + Whether the model accepts thinking with type 'enabled' (extended thinking with a + caller-set `budget_tokens`). + """ diff --git a/src/anthropic/types/beta/beta_tool_use_caller.py b/src/anthropic/types/beta/beta_tool_use_caller.py new file mode 100644 index 000000000..0d58549db --- /dev/null +++ b/src/anthropic/types/beta/beta_tool_use_caller.py @@ -0,0 +1,13 @@ +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from ..._models import UnionDiscriminator +from .beta_direct_caller import BetaDirectCaller +from .beta_server_tool_caller import BetaServerToolCaller +from .beta_server_tool_caller_20260120 import BetaServerToolCaller20260120 + +__all__ = ["BetaToolUseCaller"] + +BetaToolUseCaller: TypeAlias = Annotated[ + Union[BetaDirectCaller, BetaServerToolCaller, BetaServerToolCaller20260120], UnionDiscriminator("type") +] diff --git a/src/anthropic/types/beta/beta_toolset_tool_use_block.py b/src/anthropic/types/beta/beta_toolset_tool_use_block.py new file mode 100644 index 000000000..83ff8abf1 --- /dev/null +++ b/src/anthropic/types/beta/beta_toolset_tool_use_block.py @@ -0,0 +1,10 @@ +from typing import Union +from typing_extensions import TypeAlias + +from .beta_tool_use_block import BetaToolUseBlock +from .beta_browser_tool_use_block import BetaBrowserToolUseBlock +from .beta_computer_tool_use_block import BetaComputerToolUseBlock + +__all__ = ["BetaToolsetToolUseBlock"] + +BetaToolsetToolUseBlock: TypeAlias = Union[BetaBrowserToolUseBlock, BetaComputerToolUseBlock, BetaToolUseBlock] diff --git a/src/anthropic/types/beta/model_list_params.py b/src/anthropic/types/beta/model_list_params.py index 59caf6563..fb735e303 100644 --- a/src/anthropic/types/beta/model_list_params.py +++ b/src/anthropic/types/beta/model_list_params.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import List -from typing_extensions import TypedDict +from typing_extensions import Literal, TypedDict from ..anthropic_beta_param import AnthropicBetaParam @@ -21,6 +21,14 @@ class ModelListParams(TypedDict, total=False): When provided, returns the page of results immediately before this object. """ + lifecycle: List[Literal["active", "deprecated", "retired"]] + """ + Filter the list to models in any of the given lifecycle stages (`active`, + `deprecated`, or `retired`). Up to 3 values. When omitted, the list contains the + `active` and `deprecated` models; `retired` models appear only when `retired` is + requested explicitly. + """ + limit: int """Number of items to return per page. diff --git a/src/anthropic/types/beta/organization/beta_rbac_role.py b/src/anthropic/types/beta/organization/beta_rbac_role.py index c04268aeb..9b864ab29 100644 --- a/src/anthropic/types/beta/organization/beta_rbac_role.py +++ b/src/anthropic/types/beta/organization/beta_rbac_role.py @@ -13,8 +13,19 @@ class BetaRBACRole(BaseModel): created_at: datetime """RFC 3339 datetime string indicating when the RBAC Role was created.""" + display_name: str + """Name of the RBAC Role. + + For a role created by Anthropic, this name can differ from the label claude.ai + shows, and Anthropic may change the name. To keep a lasting reference to a role, + store its `id`. + """ + name: str - """Name of the RBAC Role.""" + """Deprecated: use `display_name` instead. + + Name of the RBAC Role; always the same value as `display_name`. + """ type: Literal["rbac_role"] """Object type. diff --git a/src/anthropic/types/beta/organization/federation/beta_federation_rule.py b/src/anthropic/types/beta/organization/federation/beta_federation_rule.py index d25f8df2d..5bfe1eea6 100644 --- a/src/anthropic/types/beta/organization/federation/beta_federation_rule.py +++ b/src/anthropic/types/beta/organization/federation/beta_federation_rule.py @@ -71,9 +71,10 @@ class BetaFederationRule(BaseModel): """Space-separated OAuth scopes granted on the minted token.""" target: BetaServiceAccountTarget - """Identity that tokens minted via this rule act as. + """What this rule targets. - Currently always a `service_account` target. + Check `type` before reading the other fields. Tokens minted via a rule whose + target `type` is `service_account` act as that service account. """ token_lifetime_seconds: int diff --git a/src/anthropic/types/beta/organization/spend_limit_set_params.py b/src/anthropic/types/beta/organization/spend_limit_set_params.py index 0be2c2dd3..57be78276 100644 --- a/src/anthropic/types/beta/organization/spend_limit_set_params.py +++ b/src/anthropic/types/beta/organization/spend_limit_set_params.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import Union, Optional +from typing import List, Union, Optional from typing_extensions import Required, TypeAlias, TypedDict +from ...anthropic_beta_param import AnthropicBetaParam from .beta_spend_limit_period import BetaSpendLimitPeriod from .beta_spend_limit_user_scope_param import BetaSpendLimitUserScopeParam from .beta_spend_limit_workspace_scope_param import BetaSpendLimitWorkspaceScopeParam @@ -31,6 +32,9 @@ class SpendLimitSetParams(TypedDict, total=False): period: BetaSpendLimitPeriod + betas: List[AnthropicBetaParam] + """Optional header to specify the beta version(s) you want to use.""" + Scope: TypeAlias = Union[ BetaSpendLimitUserScopeParam, BetaSpendLimitOrganizationScopeParam, BetaSpendLimitWorkspaceScopeParam diff --git a/src/anthropic/types/beta/organization/workspace_list_params.py b/src/anthropic/types/beta/organization/workspace_list_params.py index 8194a9a7e..368d537ff 100644 --- a/src/anthropic/types/beta/organization/workspace_list_params.py +++ b/src/anthropic/types/beta/organization/workspace_list_params.py @@ -21,6 +21,9 @@ class WorkspaceListParams(TypedDict, total=False): include_archived: bool """Whether to include Workspaces that have been archived in the response""" + include_default: bool + """Whether to include the organization's default Workspace in the response""" + limit: int """Number of items to return per page. diff --git a/src/anthropic/types/beta/sessions/beta_managed_agents_session_thread.py b/src/anthropic/types/beta/sessions/beta_managed_agents_session_thread.py index 699adf83e..67357b9bd 100644 --- a/src/anthropic/types/beta/sessions/beta_managed_agents_session_thread.py +++ b/src/anthropic/types/beta/sessions/beta_managed_agents_session_thread.py @@ -19,7 +19,7 @@ class BetaManagedAgentsSessionThread(BaseModel): """An execution thread within a `session`. - Each session has one primary thread plus zero or more child threads spawned by the coordinator. + Each session has one primary thread plus zero or more child threads. """ id: str diff --git a/src/anthropic/types/beta/sessions/beta_managed_agents_session_thread_created_event.py b/src/anthropic/types/beta/sessions/beta_managed_agents_session_thread_created_event.py index 4b3656772..f92caa9e2 100644 --- a/src/anthropic/types/beta/sessions/beta_managed_agents_session_thread_created_event.py +++ b/src/anthropic/types/beta/sessions/beta_managed_agents_session_thread_created_event.py @@ -7,7 +7,7 @@ class BetaManagedAgentsSessionThreadCreatedEvent(BaseModel): - """Emitted when a subagent is spawned as a new thread. + """Emitted when a child thread is created. Written to the parent thread's output stream so clients observing the session see child creation. """ diff --git a/src/anthropic/types/browser_click_target.py b/src/anthropic/types/browser_click_target.py new file mode 100644 index 000000000..a6d31134d --- /dev/null +++ b/src/anthropic/types/browser_click_target.py @@ -0,0 +1,10 @@ +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from .._models import UnionDiscriminator +from .browser_ref_target import BrowserRefTarget +from .browser_coordinate_target import BrowserCoordinateTarget + +__all__ = ["BrowserClickTarget"] + +BrowserClickTarget: TypeAlias = Annotated[Union[BrowserCoordinateTarget, BrowserRefTarget], UnionDiscriminator("type")] diff --git a/src/anthropic/types/browser_close_tab_input.py b/src/anthropic/types/browser_close_tab_input.py new file mode 100644 index 000000000..f8755e09f --- /dev/null +++ b/src/anthropic/types/browser_close_tab_input.py @@ -0,0 +1,10 @@ +from .._models import BaseModel + +__all__ = ["BrowserCloseTabInput"] + + +class BrowserCloseTabInput(BaseModel): + """Close the tab with the given tab_id.""" + + tab_id: str + """The tab to close.""" diff --git a/src/anthropic/types/browser_close_tab_tool_use_block.py b/src/anthropic/types/browser_close_tab_tool_use_block.py new file mode 100644 index 000000000..92552bcf4 --- /dev/null +++ b/src/anthropic/types/browser_close_tab_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_close_tab_input import BrowserCloseTabInput + +__all__ = ["BrowserCloseTabToolUseBlock"] + + +class BrowserCloseTabToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserCloseTabInput + """Close the tab with the given tab_id.""" + + name: Literal["close_tab"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_coordinate_target.py b/src/anthropic/types/browser_coordinate_target.py new file mode 100644 index 000000000..6cbb7c790 --- /dev/null +++ b/src/anthropic/types/browser_coordinate_target.py @@ -0,0 +1,20 @@ +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["BrowserCoordinateTarget"] + + +class BrowserCoordinateTarget(BaseModel): + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + type: Literal["coordinate"] + + x: int + """Pixels from the left edge of the viewport.""" + + y: int + """Pixels from the top edge of the viewport.""" diff --git a/src/anthropic/types/browser_double_click_input.py b/src/anthropic/types/browser_double_click_input.py new file mode 100644 index 000000000..350a5b4ae --- /dev/null +++ b/src/anthropic/types/browser_double_click_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_click_target import BrowserClickTarget + +__all__ = ["BrowserDoubleClickInput"] + + +class BrowserDoubleClickInput(BaseModel): + """Double left-click at a viewport coordinate or on an element by reference.""" + + target: BrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + modifiers: Optional[str] = None + """Optional modifier key chord to hold for the duration of this action (e.g. + + "shift", "ctrl+shift", "cmd+alt"). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_double_click_tool_use_block.py b/src/anthropic/types/browser_double_click_tool_use_block.py new file mode 100644 index 000000000..143a24746 --- /dev/null +++ b/src/anthropic/types/browser_double_click_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_double_click_input import BrowserDoubleClickInput + +__all__ = ["BrowserDoubleClickToolUseBlock"] + + +class BrowserDoubleClickToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserDoubleClickInput + """Double left-click at a viewport coordinate or on an element by reference.""" + + name: Literal["double_click"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_file_upload_input.py b/src/anthropic/types/browser_file_upload_input.py new file mode 100644 index 000000000..01e3447ce --- /dev/null +++ b/src/anthropic/types/browser_file_upload_input.py @@ -0,0 +1,33 @@ +from typing import List, Optional + +from .._models import BaseModel +from .browser_ref_target import BrowserRefTarget + +__all__ = ["BrowserFileUploadInput"] + + +class BrowserFileUploadInput(BaseModel): + """Set the value of a file-input element to one or more files. + + The target must be an + element reference; at least one of paths or document_ids is required. + """ + + target: BrowserRefTarget + """ + An element on the page, identified by a reference from a prior `read_page` or + `find` result. References are scoped to the tab that produced them and become + stale after navigation or a major re-render. + """ + + document_ids: Optional[List[str]] = None + """ + References to files the harness has staged, for deployments where the browser + executor cannot read the caller's filesystem. + """ + + paths: Optional[List[str]] = None + """File paths on the browser executor's filesystem.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_file_upload_tool_use_block.py b/src/anthropic/types/browser_file_upload_tool_use_block.py new file mode 100644 index 000000000..6e0ffaadf --- /dev/null +++ b/src/anthropic/types/browser_file_upload_tool_use_block.py @@ -0,0 +1,30 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_file_upload_input import BrowserFileUploadInput + +__all__ = ["BrowserFileUploadToolUseBlock"] + + +class BrowserFileUploadToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserFileUploadInput + """Set the value of a file-input element to one or more files. + + The target must be an element reference; at least one of paths or document_ids + is required. + """ + + name: Literal["file_upload"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_find_input.py b/src/anthropic/types/browser_find_input.py new file mode 100644 index 000000000..4b10b6187 --- /dev/null +++ b/src/anthropic/types/browser_find_input.py @@ -0,0 +1,19 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserFindInput"] + + +class BrowserFindInput(BaseModel): + """Find elements matching a natural-language description (e.g. + + "search bar", "add to + cart button") and return up to 20 matches with element references. + """ + + query: str + """Natural-language description of the element(s) to find.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_find_tool_use_block.py b/src/anthropic/types/browser_find_tool_use_block.py new file mode 100644 index 000000000..b6126c52c --- /dev/null +++ b/src/anthropic/types/browser_find_tool_use_block.py @@ -0,0 +1,30 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_find_input import BrowserFindInput + +__all__ = ["BrowserFindToolUseBlock"] + + +class BrowserFindToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserFindInput + """Find elements matching a natural-language description (e.g. + + "search bar", "add to cart button") and return up to 20 matches with element + references. + """ + + name: Literal["find"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_form_input_input.py b/src/anthropic/types/browser_form_input_input.py new file mode 100644 index 000000000..b4498037e --- /dev/null +++ b/src/anthropic/types/browser_form_input_input.py @@ -0,0 +1,28 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_ref_target import BrowserRefTarget +from .browser_form_input_value import BrowserFormInputValue + +__all__ = ["BrowserFormInputInput"] + + +class BrowserFormInputInput(BaseModel): + """Set the value of a form element (input, textarea, select, checkbox). + + Use a + boolean for checkboxes, an option value or text for selects. + """ + + target: BrowserRefTarget + """ + An element on the page, identified by a reference from a prior `read_page` or + `find` result. References are scoped to the tab that produced them and become + stale after navigation or a major re-render. + """ + + value: BrowserFormInputValue + """The value to set.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_form_input_tool_use_block.py b/src/anthropic/types/browser_form_input_tool_use_block.py new file mode 100644 index 000000000..d2bd75d4b --- /dev/null +++ b/src/anthropic/types/browser_form_input_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_form_input_input import BrowserFormInputInput + +__all__ = ["BrowserFormInputToolUseBlock"] + + +class BrowserFormInputToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserFormInputInput + """Set the value of a form element (input, textarea, select, checkbox). + + Use a boolean for checkboxes, an option value or text for selects. + """ + + name: Literal["form_input"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_form_input_value.py b/src/anthropic/types/browser_form_input_value.py new file mode 100644 index 000000000..f269597ea --- /dev/null +++ b/src/anthropic/types/browser_form_input_value.py @@ -0,0 +1,6 @@ +from typing import Union +from typing_extensions import TypeAlias + +__all__ = ["BrowserFormInputValue"] + +BrowserFormInputValue: TypeAlias = Union[str, float, bool] diff --git a/src/anthropic/types/browser_get_page_text_input.py b/src/anthropic/types/browser_get_page_text_input.py new file mode 100644 index 000000000..494df4020 --- /dev/null +++ b/src/anthropic/types/browser_get_page_text_input.py @@ -0,0 +1,15 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserGetPageTextInput"] + + +class BrowserGetPageTextInput(BaseModel): + """ + Return the page's visible text content as plain text, prioritizing article + content. Suited to articles, documentation, and other text-heavy pages. + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_get_page_text_tool_use_block.py b/src/anthropic/types/browser_get_page_text_tool_use_block.py new file mode 100644 index 000000000..85fb32d8d --- /dev/null +++ b/src/anthropic/types/browser_get_page_text_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_get_page_text_input import BrowserGetPageTextInput + +__all__ = ["BrowserGetPageTextToolUseBlock"] + + +class BrowserGetPageTextToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserGetPageTextInput + """ + Return the page's visible text content as plain text, prioritizing article + content. Suited to articles, documentation, and other text-heavy pages. + """ + + name: Literal["get_page_text"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_hold_key_input.py b/src/anthropic/types/browser_hold_key_input.py new file mode 100644 index 000000000..6604d8fae --- /dev/null +++ b/src/anthropic/types/browser_hold_key_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserHoldKeyInput"] + + +class BrowserHoldKeyInput(BaseModel): + """Hold a key or key chord down for a duration, then release it. + + Uses the same key + names and "+" chord syntax as the key action. + """ + + duration: float + """Seconds to hold the key down (maximum 30).""" + + text: str + """The key or chord to hold.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_hold_key_tool_use_block.py b/src/anthropic/types/browser_hold_key_tool_use_block.py new file mode 100644 index 000000000..7d30f2054 --- /dev/null +++ b/src/anthropic/types/browser_hold_key_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_hold_key_input import BrowserHoldKeyInput + +__all__ = ["BrowserHoldKeyToolUseBlock"] + + +class BrowserHoldKeyToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserHoldKeyInput + """Hold a key or key chord down for a duration, then release it. + + Uses the same key names and "+" chord syntax as the key action. + """ + + name: Literal["hold_key"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_hover_input.py b/src/anthropic/types/browser_hover_input.py new file mode 100644 index 000000000..706ad4ede --- /dev/null +++ b/src/anthropic/types/browser_hover_input.py @@ -0,0 +1,16 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_click_target import BrowserClickTarget + +__all__ = ["BrowserHoverInput"] + + +class BrowserHoverInput(BaseModel): + """Move the cursor to a coordinate or element without clicking.""" + + target: BrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_hover_tool_use_block.py b/src/anthropic/types/browser_hover_tool_use_block.py new file mode 100644 index 000000000..9b3128283 --- /dev/null +++ b/src/anthropic/types/browser_hover_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_hover_input import BrowserHoverInput + +__all__ = ["BrowserHoverToolUseBlock"] + + +class BrowserHoverToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserHoverInput + """Move the cursor to a coordinate or element without clicking.""" + + name: Literal["hover"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_javascript_exec_input.py b/src/anthropic/types/browser_javascript_exec_input.py new file mode 100644 index 000000000..ed26a6d62 --- /dev/null +++ b/src/anthropic/types/browser_javascript_exec_input.py @@ -0,0 +1,19 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserJavascriptExecInput"] + + +class BrowserJavascriptExecInput(BaseModel): + """ + Execute JavaScript in the page context and return the value of the last + expression. The code runs with access to the DOM, `window`, and page variables. + Write the expression you want evaluated — do NOT use `return`. + """ + + text: str + """JavaScript to execute in the page context.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_javascript_exec_tool_use_block.py b/src/anthropic/types/browser_javascript_exec_tool_use_block.py new file mode 100644 index 000000000..3a2745489 --- /dev/null +++ b/src/anthropic/types/browser_javascript_exec_tool_use_block.py @@ -0,0 +1,30 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_javascript_exec_input import BrowserJavascriptExecInput + +__all__ = ["BrowserJavascriptExecToolUseBlock"] + + +class BrowserJavascriptExecToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserJavascriptExecInput + """ + Execute JavaScript in the page context and return the value of the last + expression. The code runs with access to the DOM, `window`, and page variables. + Write the expression you want evaluated — do NOT use `return`. + """ + + name: Literal["javascript_exec"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_key_input.py b/src/anthropic/types/browser_key_input.py new file mode 100644 index 000000000..dd8038da8 --- /dev/null +++ b/src/anthropic/types/browser_key_input.py @@ -0,0 +1,23 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserKeyInput"] + + +class BrowserKeyInput(BaseModel): + """Press a key or key chord. + + Use "+" to combine modifiers with a key (e.g. "ctrl+a", + "cmd+shift+p") and space to sequence presses (e.g. "Backspace Backspace Delete"). + Common names like "Return", "Tab", "Escape", "BackSpace" are supported. + """ + + text: str + """The key, chord, or space-separated sequence to press.""" + + repeat: Optional[int] = None + """Number of times to repeat. Default 1.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_key_tool_use_block.py b/src/anthropic/types/browser_key_tool_use_block.py new file mode 100644 index 000000000..a45c73175 --- /dev/null +++ b/src/anthropic/types/browser_key_tool_use_block.py @@ -0,0 +1,31 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_key_input import BrowserKeyInput + +__all__ = ["BrowserKeyToolUseBlock"] + + +class BrowserKeyToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserKeyInput + """Press a key or key chord. + + Use "+" to combine modifiers with a key (e.g. "ctrl+a", "cmd+shift+p") and space + to sequence presses (e.g. "Backspace Backspace Delete"). Common names like + "Return", "Tab", "Escape", "BackSpace" are supported. + """ + + name: Literal["key"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_left_click_drag_input.py b/src/anthropic/types/browser_left_click_drag_input.py new file mode 100644 index 000000000..9d3a9cc82 --- /dev/null +++ b/src/anthropic/types/browser_left_click_drag_input.py @@ -0,0 +1,27 @@ +from typing import Optional + +from pydantic import Field as FieldInfo + +from .._models import BaseModel +from .browser_coordinate_target import BrowserCoordinateTarget + +__all__ = ["BrowserLeftClickDragInput"] + + +class BrowserLeftClickDragInput(BaseModel): + """Press at `from`, drag to `target`, release. Both must be coordinate targets.""" + + from_: BrowserCoordinateTarget = FieldInfo(alias="from") + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + target: BrowserCoordinateTarget + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_left_click_drag_tool_use_block.py b/src/anthropic/types/browser_left_click_drag_tool_use_block.py new file mode 100644 index 000000000..2ddb45654 --- /dev/null +++ b/src/anthropic/types/browser_left_click_drag_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_left_click_drag_input import BrowserLeftClickDragInput + +__all__ = ["BrowserLeftClickDragToolUseBlock"] + + +class BrowserLeftClickDragToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserLeftClickDragInput + """Press at `from`, drag to `target`, release. Both must be coordinate targets.""" + + name: Literal["left_click_drag"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_left_click_input.py b/src/anthropic/types/browser_left_click_input.py new file mode 100644 index 000000000..4041a1d72 --- /dev/null +++ b/src/anthropic/types/browser_left_click_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_click_target import BrowserClickTarget + +__all__ = ["BrowserLeftClickInput"] + + +class BrowserLeftClickInput(BaseModel): + """Left-click at a viewport coordinate or on an element by reference.""" + + target: BrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + modifiers: Optional[str] = None + """Optional modifier key chord to hold for the duration of this action (e.g. + + "shift", "ctrl+shift", "cmd+alt"). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_left_click_tool_use_block.py b/src/anthropic/types/browser_left_click_tool_use_block.py new file mode 100644 index 000000000..ac9546e5d --- /dev/null +++ b/src/anthropic/types/browser_left_click_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_left_click_input import BrowserLeftClickInput + +__all__ = ["BrowserLeftClickToolUseBlock"] + + +class BrowserLeftClickToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserLeftClickInput + """Left-click at a viewport coordinate or on an element by reference.""" + + name: Literal["left_click"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_left_mouse_down_input.py b/src/anthropic/types/browser_left_mouse_down_input.py new file mode 100644 index 000000000..b43268c78 --- /dev/null +++ b/src/anthropic/types/browser_left_mouse_down_input.py @@ -0,0 +1,23 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_coordinate_target import BrowserCoordinateTarget + +__all__ = ["BrowserLeftMouseDownInput"] + + +class BrowserLeftMouseDownInput(BaseModel): + """Press and hold the left mouse button at a viewport coordinate. + + Pair with + left_mouse_up to perform a custom drag. + """ + + target: BrowserCoordinateTarget + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_left_mouse_down_tool_use_block.py b/src/anthropic/types/browser_left_mouse_down_tool_use_block.py new file mode 100644 index 000000000..393d785ef --- /dev/null +++ b/src/anthropic/types/browser_left_mouse_down_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_left_mouse_down_input import BrowserLeftMouseDownInput + +__all__ = ["BrowserLeftMouseDownToolUseBlock"] + + +class BrowserLeftMouseDownToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserLeftMouseDownInput + """Press and hold the left mouse button at a viewport coordinate. + + Pair with left_mouse_up to perform a custom drag. + """ + + name: Literal["left_mouse_down"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_left_mouse_up_input.py b/src/anthropic/types/browser_left_mouse_up_input.py new file mode 100644 index 000000000..a46d20f79 --- /dev/null +++ b/src/anthropic/types/browser_left_mouse_up_input.py @@ -0,0 +1,19 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_coordinate_target import BrowserCoordinateTarget + +__all__ = ["BrowserLeftMouseUpInput"] + + +class BrowserLeftMouseUpInput(BaseModel): + """Release the left mouse button at a viewport coordinate.""" + + target: BrowserCoordinateTarget + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_left_mouse_up_tool_use_block.py b/src/anthropic/types/browser_left_mouse_up_tool_use_block.py new file mode 100644 index 000000000..9669ac300 --- /dev/null +++ b/src/anthropic/types/browser_left_mouse_up_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_left_mouse_up_input import BrowserLeftMouseUpInput + +__all__ = ["BrowserLeftMouseUpToolUseBlock"] + + +class BrowserLeftMouseUpToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserLeftMouseUpInput + """Release the left mouse button at a viewport coordinate.""" + + name: Literal["left_mouse_up"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_list_tabs_input.py b/src/anthropic/types/browser_list_tabs_input.py new file mode 100644 index 000000000..721b3ea64 --- /dev/null +++ b/src/anthropic/types/browser_list_tabs_input.py @@ -0,0 +1,9 @@ +from .._models import BaseModel + +__all__ = ["BrowserListTabsInput"] + + +class BrowserListTabsInput(BaseModel): + """List all open tabs with each tab's tab_id, title, and URL.""" + + pass diff --git a/src/anthropic/types/browser_list_tabs_tool_use_block.py b/src/anthropic/types/browser_list_tabs_tool_use_block.py new file mode 100644 index 000000000..278bb4675 --- /dev/null +++ b/src/anthropic/types/browser_list_tabs_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_list_tabs_input import BrowserListTabsInput + +__all__ = ["BrowserListTabsToolUseBlock"] + + +class BrowserListTabsToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserListTabsInput + """List all open tabs with each tab's tab_id, title, and URL.""" + + name: Literal["list_tabs"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_member_input.py b/src/anthropic/types/browser_member_input.py new file mode 100644 index 000000000..a020df954 --- /dev/null +++ b/src/anthropic/types/browser_member_input.py @@ -0,0 +1,70 @@ +from typing import Union +from typing_extensions import TypeAlias + +from .browser_key_input import BrowserKeyInput +from .browser_find_input import BrowserFindInput +from .browser_type_input import BrowserTypeInput +from .browser_wait_input import BrowserWaitInput +from .browser_zoom_input import BrowserZoomInput +from .browser_hover_input import BrowserHoverInput +from .browser_scroll_input import BrowserScrollInput +from .browser_new_tab_input import BrowserNewTabInput +from .browser_hold_key_input import BrowserHoldKeyInput +from .browser_navigate_input import BrowserNavigateInput +from .browser_close_tab_input import BrowserCloseTabInput +from .browser_list_tabs_input import BrowserListTabsInput +from .browser_read_page_input import BrowserReadPageInput +from .browser_scroll_to_input import BrowserScrollToInput +from .browser_form_input_input import BrowserFormInputInput +from .browser_left_click_input import BrowserLeftClickInput +from .browser_mouse_move_input import BrowserMouseMoveInput +from .browser_screenshot_input import BrowserScreenshotInput +from .browser_switch_tab_input import BrowserSwitchTabInput +from .browser_file_upload_input import BrowserFileUploadInput +from .browser_right_click_input import BrowserRightClickInput +from .browser_double_click_input import BrowserDoubleClickInput +from .browser_middle_click_input import BrowserMiddleClickInput +from .browser_read_console_input import BrowserReadConsoleInput +from .browser_read_network_input import BrowserReadNetworkInput +from .browser_triple_click_input import BrowserTripleClickInput +from .browser_get_page_text_input import BrowserGetPageTextInput +from .browser_left_mouse_up_input import BrowserLeftMouseUpInput +from .browser_javascript_exec_input import BrowserJavascriptExecInput +from .browser_left_click_drag_input import BrowserLeftClickDragInput +from .browser_left_mouse_down_input import BrowserLeftMouseDownInput + +__all__ = ["BrowserMemberInput"] + +BrowserMemberInput: TypeAlias = Union[ + BrowserNavigateInput, + BrowserListTabsInput, + BrowserNewTabInput, + BrowserSwitchTabInput, + BrowserCloseTabInput, + BrowserReadPageInput, + BrowserGetPageTextInput, + BrowserReadConsoleInput, + BrowserReadNetworkInput, + BrowserFindInput, + BrowserFormInputInput, + BrowserFileUploadInput, + BrowserScrollToInput, + BrowserScreenshotInput, + BrowserZoomInput, + BrowserLeftClickInput, + BrowserRightClickInput, + BrowserMiddleClickInput, + BrowserDoubleClickInput, + BrowserTripleClickInput, + BrowserHoverInput, + BrowserLeftClickDragInput, + BrowserLeftMouseDownInput, + BrowserLeftMouseUpInput, + BrowserMouseMoveInput, + BrowserScrollInput, + BrowserTypeInput, + BrowserKeyInput, + BrowserHoldKeyInput, + BrowserWaitInput, + BrowserJavascriptExecInput, +] diff --git a/src/anthropic/types/browser_member_name.py b/src/anthropic/types/browser_member_name.py new file mode 100644 index 000000000..03e83e522 --- /dev/null +++ b/src/anthropic/types/browser_member_name.py @@ -0,0 +1,72 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["BrowserMemberName", "BROWSER_MEMBER_NAME_VALUES"] + +BrowserMemberName: TypeAlias = Literal[ + "navigate", + "list_tabs", + "new_tab", + "switch_tab", + "close_tab", + "read_page", + "get_page_text", + "read_console", + "read_network", + "find", + "form_input", + "file_upload", + "scroll_to", + "screenshot", + "zoom", + "left_click", + "right_click", + "middle_click", + "double_click", + "triple_click", + "hover", + "left_click_drag", + "left_mouse_down", + "left_mouse_up", + "mouse_move", + "scroll", + "type", + "key", + "hold_key", + "wait", + "javascript_exec", +] + +BROWSER_MEMBER_NAME_VALUES: tuple[BrowserMemberName, ...] = ( + "navigate", + "list_tabs", + "new_tab", + "switch_tab", + "close_tab", + "read_page", + "get_page_text", + "read_console", + "read_network", + "find", + "form_input", + "file_upload", + "scroll_to", + "screenshot", + "zoom", + "left_click", + "right_click", + "middle_click", + "double_click", + "triple_click", + "hover", + "left_click_drag", + "left_mouse_down", + "left_mouse_up", + "mouse_move", + "scroll", + "type", + "key", + "hold_key", + "wait", + "javascript_exec", +) +"""The values of `BrowserMemberName`.""" diff --git a/src/anthropic/types/browser_middle_click_input.py b/src/anthropic/types/browser_middle_click_input.py new file mode 100644 index 000000000..54167bd83 --- /dev/null +++ b/src/anthropic/types/browser_middle_click_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_click_target import BrowserClickTarget + +__all__ = ["BrowserMiddleClickInput"] + + +class BrowserMiddleClickInput(BaseModel): + """Middle-click at a viewport coordinate or on an element by reference.""" + + target: BrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + modifiers: Optional[str] = None + """Optional modifier key chord to hold for the duration of this action (e.g. + + "shift", "ctrl+shift", "cmd+alt"). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_middle_click_tool_use_block.py b/src/anthropic/types/browser_middle_click_tool_use_block.py new file mode 100644 index 000000000..c116da3ce --- /dev/null +++ b/src/anthropic/types/browser_middle_click_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_middle_click_input import BrowserMiddleClickInput + +__all__ = ["BrowserMiddleClickToolUseBlock"] + + +class BrowserMiddleClickToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserMiddleClickInput + """Middle-click at a viewport coordinate or on an element by reference.""" + + name: Literal["middle_click"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_mouse_move_input.py b/src/anthropic/types/browser_mouse_move_input.py new file mode 100644 index 000000000..9742d88a6 --- /dev/null +++ b/src/anthropic/types/browser_mouse_move_input.py @@ -0,0 +1,19 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_coordinate_target import BrowserCoordinateTarget + +__all__ = ["BrowserMouseMoveInput"] + + +class BrowserMouseMoveInput(BaseModel): + """Move the pointer to a viewport coordinate without clicking.""" + + target: BrowserCoordinateTarget + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_mouse_move_tool_use_block.py b/src/anthropic/types/browser_mouse_move_tool_use_block.py new file mode 100644 index 000000000..1ae0d16bb --- /dev/null +++ b/src/anthropic/types/browser_mouse_move_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_mouse_move_input import BrowserMouseMoveInput + +__all__ = ["BrowserMouseMoveToolUseBlock"] + + +class BrowserMouseMoveToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserMouseMoveInput + """Move the pointer to a viewport coordinate without clicking.""" + + name: Literal["mouse_move"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_navigate_input.py b/src/anthropic/types/browser_navigate_input.py new file mode 100644 index 000000000..ce0ab0454 --- /dev/null +++ b/src/anthropic/types/browser_navigate_input.py @@ -0,0 +1,21 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserNavigateInput"] + + +class BrowserNavigateInput(BaseModel): + """Navigate to a URL, or go back/forward/reload in history. + + The protocol may be + omitted (defaults to https://). + """ + + url: str + """ + The URL to navigate to, or "back" / "forward" / "reload" for history navigation. + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_navigate_tool_use_block.py b/src/anthropic/types/browser_navigate_tool_use_block.py new file mode 100644 index 000000000..fd59504b9 --- /dev/null +++ b/src/anthropic/types/browser_navigate_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_navigate_input import BrowserNavigateInput + +__all__ = ["BrowserNavigateToolUseBlock"] + + +class BrowserNavigateToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserNavigateInput + """Navigate to a URL, or go back/forward/reload in history. + + The protocol may be omitted (defaults to https://). + """ + + name: Literal["navigate"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_new_tab_input.py b/src/anthropic/types/browser_new_tab_input.py new file mode 100644 index 000000000..1a2688de7 --- /dev/null +++ b/src/anthropic/types/browser_new_tab_input.py @@ -0,0 +1,9 @@ +from .._models import BaseModel + +__all__ = ["BrowserNewTabInput"] + + +class BrowserNewTabInput(BaseModel): + """Open a new empty tab and return its tab_id.""" + + pass diff --git a/src/anthropic/types/browser_new_tab_tool_use_block.py b/src/anthropic/types/browser_new_tab_tool_use_block.py new file mode 100644 index 000000000..f1991c7d9 --- /dev/null +++ b/src/anthropic/types/browser_new_tab_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_new_tab_input import BrowserNewTabInput + +__all__ = ["BrowserNewTabToolUseBlock"] + + +class BrowserNewTabToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserNewTabInput + """Open a new empty tab and return its tab_id.""" + + name: Literal["new_tab"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_read_console_input.py b/src/anthropic/types/browser_read_console_input.py new file mode 100644 index 000000000..a330d04cc --- /dev/null +++ b/src/anthropic/types/browser_read_console_input.py @@ -0,0 +1,16 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserReadConsoleInput"] + + +class BrowserReadConsoleInput(BaseModel): + """ + Return console output (log entries, errors, warnings) accumulated since the + driver attached to the tab and since the last read, one line per entry. An empty + result does not mean no traffic for a tab that predates attach. + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_read_console_tool_use_block.py b/src/anthropic/types/browser_read_console_tool_use_block.py new file mode 100644 index 000000000..6b1a3221a --- /dev/null +++ b/src/anthropic/types/browser_read_console_tool_use_block.py @@ -0,0 +1,30 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_read_console_input import BrowserReadConsoleInput + +__all__ = ["BrowserReadConsoleToolUseBlock"] + + +class BrowserReadConsoleToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserReadConsoleInput + """ + Return console output (log entries, errors, warnings) accumulated since the + driver attached to the tab and since the last read, one line per entry. An empty + result does not mean no traffic for a tab that predates attach. + """ + + name: Literal["read_console"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_read_network_input.py b/src/anthropic/types/browser_read_network_input.py new file mode 100644 index 000000000..55f426705 --- /dev/null +++ b/src/anthropic/types/browser_read_network_input.py @@ -0,0 +1,16 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserReadNetworkInput"] + + +class BrowserReadNetworkInput(BaseModel): + """ + Return the network requests (method, URL, status, MIME type, timing) recorded + since the driver attached to the tab and since the last read, one line per entry. + An empty result does not mean no traffic for a tab that predates attach. + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_read_network_tool_use_block.py b/src/anthropic/types/browser_read_network_tool_use_block.py new file mode 100644 index 000000000..dc289d014 --- /dev/null +++ b/src/anthropic/types/browser_read_network_tool_use_block.py @@ -0,0 +1,30 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_read_network_input import BrowserReadNetworkInput + +__all__ = ["BrowserReadNetworkToolUseBlock"] + + +class BrowserReadNetworkToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserReadNetworkInput + """ + Return the network requests (method, URL, status, MIME type, timing) recorded + since the driver attached to the tab and since the last read, one line per + entry. An empty result does not mean no traffic for a tab that predates attach. + """ + + name: Literal["read_network"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_read_page_filter.py b/src/anthropic/types/browser_read_page_filter.py new file mode 100644 index 000000000..6e70287bd --- /dev/null +++ b/src/anthropic/types/browser_read_page_filter.py @@ -0,0 +1,5 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["BrowserReadPageFilter"] + +BrowserReadPageFilter: TypeAlias = Literal["all", "interactive"] diff --git a/src/anthropic/types/browser_read_page_input.py b/src/anthropic/types/browser_read_page_input.py new file mode 100644 index 000000000..f63bf9e08 --- /dev/null +++ b/src/anthropic/types/browser_read_page_input.py @@ -0,0 +1,31 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_read_page_filter import BrowserReadPageFilter + +__all__ = ["BrowserReadPageInput"] + + +class BrowserReadPageInput(BaseModel): + """ + Return a structured accessibility tree of the page (or the subtree rooted at + `ref`), with element references like [ref_7] that can be used as targets on later + actions. Output is capped at 50,000 characters — narrow with `ref` or a smaller + `depth` when exceeded. + """ + + depth: Optional[int] = None + """Maximum tree depth. Default 15.""" + + filter: Optional[BrowserReadPageFilter] = None + """Which elements to include. + + Omitted: every visible element. "interactive": interactive elements only. "all": + additionally includes off-viewport elements. + """ + + ref: Optional[str] = None + """Element reference to read a subtree from. Omit to read from the page root.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_read_page_tool_use_block.py b/src/anthropic/types/browser_read_page_tool_use_block.py new file mode 100644 index 000000000..09cde5ccd --- /dev/null +++ b/src/anthropic/types/browser_read_page_tool_use_block.py @@ -0,0 +1,31 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_read_page_input import BrowserReadPageInput + +__all__ = ["BrowserReadPageToolUseBlock"] + + +class BrowserReadPageToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserReadPageInput + """ + Return a structured accessibility tree of the page (or the subtree rooted at + `ref`), with element references like [ref_7] that can be used as targets on + later actions. Output is capped at 50,000 characters — narrow with `ref` or a + smaller `depth` when exceeded. + """ + + name: Literal["read_page"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_ref_target.py b/src/anthropic/types/browser_ref_target.py new file mode 100644 index 000000000..a4247a2d8 --- /dev/null +++ b/src/anthropic/types/browser_ref_target.py @@ -0,0 +1,21 @@ +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["BrowserRefTarget"] + + +class BrowserRefTarget(BaseModel): + """ + An element on the page, identified by a reference from a prior `read_page` or + `find` result. References are scoped to the tab that produced them and become + stale after navigation or a major re-render. + """ + + ref: str + """An element reference (e.g. + + "ref_7") returned by a prior `read_page` or `find` result. + """ + + type: Literal["ref"] diff --git a/src/anthropic/types/browser_right_click_input.py b/src/anthropic/types/browser_right_click_input.py new file mode 100644 index 000000000..a4966727a --- /dev/null +++ b/src/anthropic/types/browser_right_click_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_click_target import BrowserClickTarget + +__all__ = ["BrowserRightClickInput"] + + +class BrowserRightClickInput(BaseModel): + """Right-click at a viewport coordinate or on an element by reference.""" + + target: BrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + modifiers: Optional[str] = None + """Optional modifier key chord to hold for the duration of this action (e.g. + + "shift", "ctrl+shift", "cmd+alt"). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_right_click_tool_use_block.py b/src/anthropic/types/browser_right_click_tool_use_block.py new file mode 100644 index 000000000..112ac8c9c --- /dev/null +++ b/src/anthropic/types/browser_right_click_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_right_click_input import BrowserRightClickInput + +__all__ = ["BrowserRightClickToolUseBlock"] + + +class BrowserRightClickToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserRightClickInput + """Right-click at a viewport coordinate or on an element by reference.""" + + name: Literal["right_click"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_screenshot_input.py b/src/anthropic/types/browser_screenshot_input.py new file mode 100644 index 000000000..22ede2752 --- /dev/null +++ b/src/anthropic/types/browser_screenshot_input.py @@ -0,0 +1,12 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserScreenshotInput"] + + +class BrowserScreenshotInput(BaseModel): + """Capture the current browser viewport.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_screenshot_tool_use_block.py b/src/anthropic/types/browser_screenshot_tool_use_block.py new file mode 100644 index 000000000..1b97886df --- /dev/null +++ b/src/anthropic/types/browser_screenshot_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_screenshot_input import BrowserScreenshotInput + +__all__ = ["BrowserScreenshotToolUseBlock"] + + +class BrowserScreenshotToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserScreenshotInput + """Capture the current browser viewport.""" + + name: Literal["screenshot"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_scroll_direction.py b/src/anthropic/types/browser_scroll_direction.py new file mode 100644 index 000000000..b7358f41d --- /dev/null +++ b/src/anthropic/types/browser_scroll_direction.py @@ -0,0 +1,5 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["BrowserScrollDirection"] + +BrowserScrollDirection: TypeAlias = Literal["up", "down", "left", "right"] diff --git a/src/anthropic/types/browser_scroll_input.py b/src/anthropic/types/browser_scroll_input.py new file mode 100644 index 000000000..ee69ce5dc --- /dev/null +++ b/src/anthropic/types/browser_scroll_input.py @@ -0,0 +1,25 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_scroll_direction import BrowserScrollDirection +from .browser_coordinate_target import BrowserCoordinateTarget + +__all__ = ["BrowserScrollInput"] + + +class BrowserScrollInput(BaseModel): + """Scroll at a viewport position. `target` must be a coordinate target.""" + + scroll_direction: BrowserScrollDirection + + target: BrowserCoordinateTarget + """ + A point in the browser viewport, in viewport pixels (the same frame as a + full-viewport screenshot). + """ + + scroll_amount: Optional[int] = None + """Scroll-wheel notches (1–10). Default 3.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_scroll_to_input.py b/src/anthropic/types/browser_scroll_to_input.py new file mode 100644 index 000000000..f49d3cd61 --- /dev/null +++ b/src/anthropic/types/browser_scroll_to_input.py @@ -0,0 +1,20 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_ref_target import BrowserRefTarget + +__all__ = ["BrowserScrollToInput"] + + +class BrowserScrollToInput(BaseModel): + """Scroll an element into view.""" + + target: BrowserRefTarget + """ + An element on the page, identified by a reference from a prior `read_page` or + `find` result. References are scoped to the tab that produced them and become + stale after navigation or a major re-render. + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_scroll_to_tool_use_block.py b/src/anthropic/types/browser_scroll_to_tool_use_block.py new file mode 100644 index 000000000..713a5d1dc --- /dev/null +++ b/src/anthropic/types/browser_scroll_to_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_scroll_to_input import BrowserScrollToInput + +__all__ = ["BrowserScrollToToolUseBlock"] + + +class BrowserScrollToToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserScrollToInput + """Scroll an element into view.""" + + name: Literal["scroll_to"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_scroll_tool_use_block.py b/src/anthropic/types/browser_scroll_tool_use_block.py new file mode 100644 index 000000000..fa628ebab --- /dev/null +++ b/src/anthropic/types/browser_scroll_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_scroll_input import BrowserScrollInput + +__all__ = ["BrowserScrollToolUseBlock"] + + +class BrowserScrollToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserScrollInput + """Scroll at a viewport position. `target` must be a coordinate target.""" + + name: Literal["scroll"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_switch_tab_input.py b/src/anthropic/types/browser_switch_tab_input.py new file mode 100644 index 000000000..5486807e6 --- /dev/null +++ b/src/anthropic/types/browser_switch_tab_input.py @@ -0,0 +1,13 @@ +from .._models import BaseModel + +__all__ = ["BrowserSwitchTabInput"] + + +class BrowserSwitchTabInput(BaseModel): + """ + Make the tab with the given tab_id the active tab — the tab that actions without + a tab_id apply to. + """ + + tab_id: str + """The tab to switch to.""" diff --git a/src/anthropic/types/browser_switch_tab_tool_use_block.py b/src/anthropic/types/browser_switch_tab_tool_use_block.py new file mode 100644 index 000000000..4b5279439 --- /dev/null +++ b/src/anthropic/types/browser_switch_tab_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_switch_tab_input import BrowserSwitchTabInput + +__all__ = ["BrowserSwitchTabToolUseBlock"] + + +class BrowserSwitchTabToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserSwitchTabInput + """ + Make the tab with the given tab_id the active tab — the tab that actions without + a tab_id apply to. + """ + + name: Literal["switch_tab"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_tool_use_block.py b/src/anthropic/types/browser_tool_use_block.py new file mode 100644 index 000000000..8dbbe1cce --- /dev/null +++ b/src/anthropic/types/browser_tool_use_block.py @@ -0,0 +1,74 @@ +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from .._models import UnionDiscriminator +from .browser_key_tool_use_block import BrowserKeyToolUseBlock +from .browser_find_tool_use_block import BrowserFindToolUseBlock +from .browser_type_tool_use_block import BrowserTypeToolUseBlock +from .browser_wait_tool_use_block import BrowserWaitToolUseBlock +from .browser_zoom_tool_use_block import BrowserZoomToolUseBlock +from .browser_hover_tool_use_block import BrowserHoverToolUseBlock +from .browser_scroll_tool_use_block import BrowserScrollToolUseBlock +from .browser_new_tab_tool_use_block import BrowserNewTabToolUseBlock +from .browser_hold_key_tool_use_block import BrowserHoldKeyToolUseBlock +from .browser_navigate_tool_use_block import BrowserNavigateToolUseBlock +from .browser_close_tab_tool_use_block import BrowserCloseTabToolUseBlock +from .browser_list_tabs_tool_use_block import BrowserListTabsToolUseBlock +from .browser_read_page_tool_use_block import BrowserReadPageToolUseBlock +from .browser_scroll_to_tool_use_block import BrowserScrollToToolUseBlock +from .browser_form_input_tool_use_block import BrowserFormInputToolUseBlock +from .browser_left_click_tool_use_block import BrowserLeftClickToolUseBlock +from .browser_mouse_move_tool_use_block import BrowserMouseMoveToolUseBlock +from .browser_screenshot_tool_use_block import BrowserScreenshotToolUseBlock +from .browser_switch_tab_tool_use_block import BrowserSwitchTabToolUseBlock +from .browser_file_upload_tool_use_block import BrowserFileUploadToolUseBlock +from .browser_right_click_tool_use_block import BrowserRightClickToolUseBlock +from .browser_double_click_tool_use_block import BrowserDoubleClickToolUseBlock +from .browser_middle_click_tool_use_block import BrowserMiddleClickToolUseBlock +from .browser_read_console_tool_use_block import BrowserReadConsoleToolUseBlock +from .browser_read_network_tool_use_block import BrowserReadNetworkToolUseBlock +from .browser_triple_click_tool_use_block import BrowserTripleClickToolUseBlock +from .browser_get_page_text_tool_use_block import BrowserGetPageTextToolUseBlock +from .browser_left_mouse_up_tool_use_block import BrowserLeftMouseUpToolUseBlock +from .browser_javascript_exec_tool_use_block import BrowserJavascriptExecToolUseBlock +from .browser_left_click_drag_tool_use_block import BrowserLeftClickDragToolUseBlock +from .browser_left_mouse_down_tool_use_block import BrowserLeftMouseDownToolUseBlock + +__all__ = ["BrowserToolUseBlock"] + +BrowserToolUseBlock: TypeAlias = Annotated[ + Union[ + BrowserNavigateToolUseBlock, + BrowserListTabsToolUseBlock, + BrowserNewTabToolUseBlock, + BrowserSwitchTabToolUseBlock, + BrowserCloseTabToolUseBlock, + BrowserReadPageToolUseBlock, + BrowserGetPageTextToolUseBlock, + BrowserReadConsoleToolUseBlock, + BrowserReadNetworkToolUseBlock, + BrowserFindToolUseBlock, + BrowserFormInputToolUseBlock, + BrowserFileUploadToolUseBlock, + BrowserScrollToToolUseBlock, + BrowserScreenshotToolUseBlock, + BrowserZoomToolUseBlock, + BrowserLeftClickToolUseBlock, + BrowserRightClickToolUseBlock, + BrowserMiddleClickToolUseBlock, + BrowserDoubleClickToolUseBlock, + BrowserTripleClickToolUseBlock, + BrowserHoverToolUseBlock, + BrowserLeftClickDragToolUseBlock, + BrowserLeftMouseDownToolUseBlock, + BrowserLeftMouseUpToolUseBlock, + BrowserMouseMoveToolUseBlock, + BrowserScrollToolUseBlock, + BrowserTypeToolUseBlock, + BrowserKeyToolUseBlock, + BrowserHoldKeyToolUseBlock, + BrowserWaitToolUseBlock, + BrowserJavascriptExecToolUseBlock, + ], + UnionDiscriminator("name"), +] diff --git a/src/anthropic/types/browser_triple_click_input.py b/src/anthropic/types/browser_triple_click_input.py new file mode 100644 index 000000000..596c2fe4f --- /dev/null +++ b/src/anthropic/types/browser_triple_click_input.py @@ -0,0 +1,25 @@ +from typing import Optional + +from .._models import BaseModel +from .browser_click_target import BrowserClickTarget + +__all__ = ["BrowserTripleClickInput"] + + +class BrowserTripleClickInput(BaseModel): + """ + Triple left-click at a viewport coordinate or on an element by reference + (typically selects a line or paragraph). + """ + + target: BrowserClickTarget + """Where to act: either a viewport coordinate or an element reference.""" + + modifiers: Optional[str] = None + """Optional modifier key chord to hold for the duration of this action (e.g. + + "shift", "ctrl+shift", "cmd+alt"). + """ + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_triple_click_tool_use_block.py b/src/anthropic/types/browser_triple_click_tool_use_block.py new file mode 100644 index 000000000..ce79217fb --- /dev/null +++ b/src/anthropic/types/browser_triple_click_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_triple_click_input import BrowserTripleClickInput + +__all__ = ["BrowserTripleClickToolUseBlock"] + + +class BrowserTripleClickToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserTripleClickInput + """ + Triple left-click at a viewport coordinate or on an element by reference + (typically selects a line or paragraph). + """ + + name: Literal["triple_click"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_type_input.py b/src/anthropic/types/browser_type_input.py new file mode 100644 index 000000000..7360a3d2f --- /dev/null +++ b/src/anthropic/types/browser_type_input.py @@ -0,0 +1,15 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserTypeInput"] + + +class BrowserTypeInput(BaseModel): + """Type a literal string at the current focus.""" + + text: str + """The text to type.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_type_tool_use_block.py b/src/anthropic/types/browser_type_tool_use_block.py new file mode 100644 index 000000000..27956866b --- /dev/null +++ b/src/anthropic/types/browser_type_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_type_input import BrowserTypeInput + +__all__ = ["BrowserTypeToolUseBlock"] + + +class BrowserTypeToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserTypeInput + """Type a literal string at the current focus.""" + + name: Literal["type"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_wait_input.py b/src/anthropic/types/browser_wait_input.py new file mode 100644 index 000000000..24fa2e091 --- /dev/null +++ b/src/anthropic/types/browser_wait_input.py @@ -0,0 +1,15 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["BrowserWaitInput"] + + +class BrowserWaitInput(BaseModel): + """Pause for the given duration.""" + + duration: float + """Seconds to wait (maximum 30).""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_wait_tool_use_block.py b/src/anthropic/types/browser_wait_tool_use_block.py new file mode 100644 index 000000000..f6b79a024 --- /dev/null +++ b/src/anthropic/types/browser_wait_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_wait_input import BrowserWaitInput + +__all__ = ["BrowserWaitToolUseBlock"] + + +class BrowserWaitToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserWaitInput + """Pause for the given duration.""" + + name: Literal["wait"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/browser_zoom_input.py b/src/anthropic/types/browser_zoom_input.py new file mode 100644 index 000000000..dbc149915 --- /dev/null +++ b/src/anthropic/types/browser_zoom_input.py @@ -0,0 +1,19 @@ +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["BrowserZoomInput"] + + +class BrowserZoomInput(BaseModel): + """ + Return a cropped screenshot of the given viewport region, scaled up for closer + inspection — useful for small icons, buttons, or text. Coordinates are in the + same viewport-pixel space as a full screenshot. + """ + + region: List[int] + """[x0, y0, x1, y1] in viewport pixels.""" + + tab_id: Optional[str] = None + """Tab to act on. Defaults to the active tab when omitted.""" diff --git a/src/anthropic/types/browser_zoom_tool_use_block.py b/src/anthropic/types/browser_zoom_tool_use_block.py new file mode 100644 index 000000000..5f176631c --- /dev/null +++ b/src/anthropic/types/browser_zoom_tool_use_block.py @@ -0,0 +1,30 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .browser_zoom_input import BrowserZoomInput + +__all__ = ["BrowserZoomToolUseBlock"] + + +class BrowserZoomToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: BrowserZoomInput + """ + Return a cropped screenshot of the given viewport region, scaled up for closer + inspection — useful for small icons, buttons, or text. Coordinates are in the + same viewport-pixel space as a full screenshot. + """ + + name: Literal["zoom"] + + toolset_name: Literal["browser"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_cursor_position_input.py b/src/anthropic/types/computer_cursor_position_input.py new file mode 100644 index 000000000..b6991a0eb --- /dev/null +++ b/src/anthropic/types/computer_cursor_position_input.py @@ -0,0 +1,9 @@ +from .._models import BaseModel + +__all__ = ["ComputerCursorPositionInput"] + + +class ComputerCursorPositionInput(BaseModel): + """Get the current (x, y) pixel coordinate of the cursor.""" + + pass diff --git a/src/anthropic/types/computer_cursor_position_tool_use_block.py b/src/anthropic/types/computer_cursor_position_tool_use_block.py new file mode 100644 index 000000000..a0f2dad6f --- /dev/null +++ b/src/anthropic/types/computer_cursor_position_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_cursor_position_input import ComputerCursorPositionInput + +__all__ = ["ComputerCursorPositionToolUseBlock"] + + +class ComputerCursorPositionToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerCursorPositionInput + """Get the current (x, y) pixel coordinate of the cursor.""" + + name: Literal["cursor_position"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_double_click_input.py b/src/anthropic/types/computer_double_click_input.py new file mode 100644 index 000000000..d7c00b326 --- /dev/null +++ b/src/anthropic/types/computer_double_click_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["ComputerDoubleClickInput"] + + +class ComputerDoubleClickInput(BaseModel): + """ + Double-click the left mouse button at the specified (x, y) pixel coordinate, or + the current cursor position if `coordinate` is omitted. + """ + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/computer_double_click_tool_use_block.py b/src/anthropic/types/computer_double_click_tool_use_block.py new file mode 100644 index 000000000..49c62bd6b --- /dev/null +++ b/src/anthropic/types/computer_double_click_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_double_click_input import ComputerDoubleClickInput + +__all__ = ["ComputerDoubleClickToolUseBlock"] + + +class ComputerDoubleClickToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerDoubleClickInput + """ + Double-click the left mouse button at the specified (x, y) pixel coordinate, or + the current cursor position if `coordinate` is omitted. + """ + + name: Literal["double_click"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_hold_key_input.py b/src/anthropic/types/computer_hold_key_input.py new file mode 100644 index 000000000..bef9d4250 --- /dev/null +++ b/src/anthropic/types/computer_hold_key_input.py @@ -0,0 +1,17 @@ +from .._models import BaseModel + +__all__ = ["ComputerHoldKeyInput"] + + +class ComputerHoldKeyInput(BaseModel): + """Hold down a key or key-combination for a specified duration. + + Uses the same key + syntax as `key`. + """ + + duration: int + """Duration to hold the key, in seconds.""" + + text: str + """The key or key-combination to hold.""" diff --git a/src/anthropic/types/computer_hold_key_tool_use_block.py b/src/anthropic/types/computer_hold_key_tool_use_block.py new file mode 100644 index 000000000..d7c18fb95 --- /dev/null +++ b/src/anthropic/types/computer_hold_key_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_hold_key_input import ComputerHoldKeyInput + +__all__ = ["ComputerHoldKeyToolUseBlock"] + + +class ComputerHoldKeyToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerHoldKeyInput + """Hold down a key or key-combination for a specified duration. + + Uses the same key syntax as `key`. + """ + + name: Literal["hold_key"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_key_input.py b/src/anthropic/types/computer_key_input.py new file mode 100644 index 000000000..af31500b5 --- /dev/null +++ b/src/anthropic/types/computer_key_input.py @@ -0,0 +1,22 @@ +from typing import Optional + +from .._models import BaseModel + +__all__ = ["ComputerKeyInput"] + + +class ComputerKeyInput(BaseModel): + """Press a key or key-combination on the keyboard. + + Use "+" to combine modifiers with + a key (e.g. "ctrl+s", "alt+Tab", "ctrl+shift+Escape"). Key names are + case-insensitive; common names like "Return", "Tab", "Escape", "Up", "Down", + "Left", "Right", "Home", "End", "Page_Up", "Page_Down", "Delete", "BackSpace" are + supported. + """ + + text: str + """The key or key-combination to press.""" + + repeat: Optional[int] = None + """Number of times to repeat the key press. Default is 1.""" diff --git a/src/anthropic/types/computer_key_tool_use_block.py b/src/anthropic/types/computer_key_tool_use_block.py new file mode 100644 index 000000000..9e9d90896 --- /dev/null +++ b/src/anthropic/types/computer_key_tool_use_block.py @@ -0,0 +1,32 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_key_input import ComputerKeyInput + +__all__ = ["ComputerKeyToolUseBlock"] + + +class ComputerKeyToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerKeyInput + """Press a key or key-combination on the keyboard. + + Use "+" to combine modifiers with a key (e.g. "ctrl+s", "alt+Tab", + "ctrl+shift+Escape"). Key names are case-insensitive; common names like + "Return", "Tab", "Escape", "Up", "Down", "Left", "Right", "Home", "End", + "Page_Up", "Page_Down", "Delete", "BackSpace" are supported. + """ + + name: Literal["key"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_left_click_drag_input.py b/src/anthropic/types/computer_left_click_drag_input.py new file mode 100644 index 000000000..a832169fe --- /dev/null +++ b/src/anthropic/types/computer_left_click_drag_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["ComputerLeftClickDragInput"] + + +class ComputerLeftClickDragInput(BaseModel): + """Click and drag the cursor from `start_coordinate` to `coordinate`.""" + + coordinate: List[int] + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + start_coordinate: List[int] + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/computer_left_click_drag_tool_use_block.py b/src/anthropic/types/computer_left_click_drag_tool_use_block.py new file mode 100644 index 000000000..cbcce6794 --- /dev/null +++ b/src/anthropic/types/computer_left_click_drag_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_left_click_drag_input import ComputerLeftClickDragInput + +__all__ = ["ComputerLeftClickDragToolUseBlock"] + + +class ComputerLeftClickDragToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerLeftClickDragInput + """Click and drag the cursor from `start_coordinate` to `coordinate`.""" + + name: Literal["left_click_drag"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_left_click_input.py b/src/anthropic/types/computer_left_click_input.py new file mode 100644 index 000000000..ecee701b2 --- /dev/null +++ b/src/anthropic/types/computer_left_click_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["ComputerLeftClickInput"] + + +class ComputerLeftClickInput(BaseModel): + """ + Click the left mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/computer_left_click_tool_use_block.py b/src/anthropic/types/computer_left_click_tool_use_block.py new file mode 100644 index 000000000..f15074446 --- /dev/null +++ b/src/anthropic/types/computer_left_click_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_left_click_input import ComputerLeftClickInput + +__all__ = ["ComputerLeftClickToolUseBlock"] + + +class ComputerLeftClickToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerLeftClickInput + """ + Click the left mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + name: Literal["left_click"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_left_mouse_down_input.py b/src/anthropic/types/computer_left_mouse_down_input.py new file mode 100644 index 000000000..bcc6643eb --- /dev/null +++ b/src/anthropic/types/computer_left_mouse_down_input.py @@ -0,0 +1,9 @@ +from .._models import BaseModel + +__all__ = ["ComputerLeftMouseDownInput"] + + +class ComputerLeftMouseDownInput(BaseModel): + """Press and hold the left mouse button at the current cursor position.""" + + pass diff --git a/src/anthropic/types/computer_left_mouse_down_tool_use_block.py b/src/anthropic/types/computer_left_mouse_down_tool_use_block.py new file mode 100644 index 000000000..17dd88438 --- /dev/null +++ b/src/anthropic/types/computer_left_mouse_down_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_left_mouse_down_input import ComputerLeftMouseDownInput + +__all__ = ["ComputerLeftMouseDownToolUseBlock"] + + +class ComputerLeftMouseDownToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerLeftMouseDownInput + """Press and hold the left mouse button at the current cursor position.""" + + name: Literal["left_mouse_down"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_left_mouse_up_input.py b/src/anthropic/types/computer_left_mouse_up_input.py new file mode 100644 index 000000000..cca75d4b0 --- /dev/null +++ b/src/anthropic/types/computer_left_mouse_up_input.py @@ -0,0 +1,9 @@ +from .._models import BaseModel + +__all__ = ["ComputerLeftMouseUpInput"] + + +class ComputerLeftMouseUpInput(BaseModel): + """Release the left mouse button.""" + + pass diff --git a/src/anthropic/types/computer_left_mouse_up_tool_use_block.py b/src/anthropic/types/computer_left_mouse_up_tool_use_block.py new file mode 100644 index 000000000..8cd50c4d3 --- /dev/null +++ b/src/anthropic/types/computer_left_mouse_up_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_left_mouse_up_input import ComputerLeftMouseUpInput + +__all__ = ["ComputerLeftMouseUpToolUseBlock"] + + +class ComputerLeftMouseUpToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerLeftMouseUpInput + """Release the left mouse button.""" + + name: Literal["left_mouse_up"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_member_input.py b/src/anthropic/types/computer_member_input.py new file mode 100644 index 000000000..1bface916 --- /dev/null +++ b/src/anthropic/types/computer_member_input.py @@ -0,0 +1,42 @@ +from typing import Union +from typing_extensions import TypeAlias + +from .computer_key_input import ComputerKeyInput +from .computer_type_input import ComputerTypeInput +from .computer_wait_input import ComputerWaitInput +from .computer_zoom_input import ComputerZoomInput +from .computer_scroll_input import ComputerScrollInput +from .computer_hold_key_input import ComputerHoldKeyInput +from .computer_left_click_input import ComputerLeftClickInput +from .computer_mouse_move_input import ComputerMouseMoveInput +from .computer_screenshot_input import ComputerScreenshotInput +from .computer_right_click_input import ComputerRightClickInput +from .computer_double_click_input import ComputerDoubleClickInput +from .computer_middle_click_input import ComputerMiddleClickInput +from .computer_triple_click_input import ComputerTripleClickInput +from .computer_left_mouse_up_input import ComputerLeftMouseUpInput +from .computer_cursor_position_input import ComputerCursorPositionInput +from .computer_left_click_drag_input import ComputerLeftClickDragInput +from .computer_left_mouse_down_input import ComputerLeftMouseDownInput + +__all__ = ["ComputerMemberInput"] + +ComputerMemberInput: TypeAlias = Union[ + ComputerKeyInput, + ComputerHoldKeyInput, + ComputerTypeInput, + ComputerCursorPositionInput, + ComputerMouseMoveInput, + ComputerLeftMouseDownInput, + ComputerLeftMouseUpInput, + ComputerLeftClickInput, + ComputerLeftClickDragInput, + ComputerRightClickInput, + ComputerMiddleClickInput, + ComputerDoubleClickInput, + ComputerTripleClickInput, + ComputerScrollInput, + ComputerWaitInput, + ComputerScreenshotInput, + ComputerZoomInput, +] diff --git a/src/anthropic/types/computer_member_name.py b/src/anthropic/types/computer_member_name.py new file mode 100644 index 000000000..2f13ec6f3 --- /dev/null +++ b/src/anthropic/types/computer_member_name.py @@ -0,0 +1,44 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["ComputerMemberName", "COMPUTER_MEMBER_NAME_VALUES"] + +ComputerMemberName: TypeAlias = Literal[ + "key", + "hold_key", + "type", + "cursor_position", + "mouse_move", + "left_mouse_down", + "left_mouse_up", + "left_click", + "left_click_drag", + "right_click", + "middle_click", + "double_click", + "triple_click", + "scroll", + "wait", + "screenshot", + "zoom", +] + +COMPUTER_MEMBER_NAME_VALUES: tuple[ComputerMemberName, ...] = ( + "key", + "hold_key", + "type", + "cursor_position", + "mouse_move", + "left_mouse_down", + "left_mouse_up", + "left_click", + "left_click_drag", + "right_click", + "middle_click", + "double_click", + "triple_click", + "scroll", + "wait", + "screenshot", + "zoom", +) +"""The values of `ComputerMemberName`.""" diff --git a/src/anthropic/types/computer_middle_click_input.py b/src/anthropic/types/computer_middle_click_input.py new file mode 100644 index 000000000..c47686f0e --- /dev/null +++ b/src/anthropic/types/computer_middle_click_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["ComputerMiddleClickInput"] + + +class ComputerMiddleClickInput(BaseModel): + """ + Click the middle mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/computer_middle_click_tool_use_block.py b/src/anthropic/types/computer_middle_click_tool_use_block.py new file mode 100644 index 000000000..a6450fa1f --- /dev/null +++ b/src/anthropic/types/computer_middle_click_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_middle_click_input import ComputerMiddleClickInput + +__all__ = ["ComputerMiddleClickToolUseBlock"] + + +class ComputerMiddleClickToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerMiddleClickInput + """ + Click the middle mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + name: Literal["middle_click"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_mouse_move_input.py b/src/anthropic/types/computer_mouse_move_input.py new file mode 100644 index 000000000..f241b027e --- /dev/null +++ b/src/anthropic/types/computer_mouse_move_input.py @@ -0,0 +1,16 @@ +from typing import List + +from .._models import BaseModel + +__all__ = ["ComputerMouseMoveInput"] + + +class ComputerMouseMoveInput(BaseModel): + """Move the cursor to a specified (x, y) pixel coordinate. + + Use this ONLY to hover + without clicking; otherwise use a click action directly. + """ + + coordinate: List[int] + """(x, y): x pixels from the left edge, y pixels from the top edge.""" diff --git a/src/anthropic/types/computer_mouse_move_tool_use_block.py b/src/anthropic/types/computer_mouse_move_tool_use_block.py new file mode 100644 index 000000000..56e695d78 --- /dev/null +++ b/src/anthropic/types/computer_mouse_move_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_mouse_move_input import ComputerMouseMoveInput + +__all__ = ["ComputerMouseMoveToolUseBlock"] + + +class ComputerMouseMoveToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerMouseMoveInput + """Move the cursor to a specified (x, y) pixel coordinate. + + Use this ONLY to hover without clicking; otherwise use a click action directly. + """ + + name: Literal["mouse_move"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_right_click_input.py b/src/anthropic/types/computer_right_click_input.py new file mode 100644 index 000000000..9fa6cc3b4 --- /dev/null +++ b/src/anthropic/types/computer_right_click_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["ComputerRightClickInput"] + + +class ComputerRightClickInput(BaseModel): + """ + Click the right mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/computer_right_click_tool_use_block.py b/src/anthropic/types/computer_right_click_tool_use_block.py new file mode 100644 index 000000000..273901037 --- /dev/null +++ b/src/anthropic/types/computer_right_click_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_right_click_input import ComputerRightClickInput + +__all__ = ["ComputerRightClickToolUseBlock"] + + +class ComputerRightClickToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerRightClickInput + """ + Click the right mouse button at the specified (x, y) pixel coordinate, or the + current cursor position if `coordinate` is omitted. + """ + + name: Literal["right_click"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_screenshot_input.py b/src/anthropic/types/computer_screenshot_input.py new file mode 100644 index 000000000..17fdffd42 --- /dev/null +++ b/src/anthropic/types/computer_screenshot_input.py @@ -0,0 +1,9 @@ +from .._models import BaseModel + +__all__ = ["ComputerScreenshotInput"] + + +class ComputerScreenshotInput(BaseModel): + """Take a screenshot of the screen.""" + + pass diff --git a/src/anthropic/types/computer_screenshot_tool_use_block.py b/src/anthropic/types/computer_screenshot_tool_use_block.py new file mode 100644 index 000000000..a5dba3845 --- /dev/null +++ b/src/anthropic/types/computer_screenshot_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_screenshot_input import ComputerScreenshotInput + +__all__ = ["ComputerScreenshotToolUseBlock"] + + +class ComputerScreenshotToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerScreenshotInput + """Take a screenshot of the screen.""" + + name: Literal["screenshot"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_scroll_direction.py b/src/anthropic/types/computer_scroll_direction.py new file mode 100644 index 000000000..895f63a1e --- /dev/null +++ b/src/anthropic/types/computer_scroll_direction.py @@ -0,0 +1,5 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["ComputerScrollDirection"] + +ComputerScrollDirection: TypeAlias = Literal["up", "down", "left", "right"] diff --git a/src/anthropic/types/computer_scroll_input.py b/src/anthropic/types/computer_scroll_input.py new file mode 100644 index 000000000..d36d1fa31 --- /dev/null +++ b/src/anthropic/types/computer_scroll_input.py @@ -0,0 +1,27 @@ +from typing import List, Optional + +from .._models import BaseModel +from .computer_scroll_direction import ComputerScrollDirection + +__all__ = ["ComputerScrollInput"] + + +class ComputerScrollInput(BaseModel): + """ + Scroll the screen at the specified (x, y) pixel coordinate, or the current cursor + position if `coordinate` is omitted. Do NOT use PageUp/PageDown to scroll. + """ + + scroll_amount: int + """Number of 'clicks' of the scroll wheel.""" + + scroll_direction: ComputerScrollDirection + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/computer_scroll_tool_use_block.py b/src/anthropic/types/computer_scroll_tool_use_block.py new file mode 100644 index 000000000..3e0ac605f --- /dev/null +++ b/src/anthropic/types/computer_scroll_tool_use_block.py @@ -0,0 +1,30 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_scroll_input import ComputerScrollInput + +__all__ = ["ComputerScrollToolUseBlock"] + + +class ComputerScrollToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerScrollInput + """ + Scroll the screen at the specified (x, y) pixel coordinate, or the current + cursor position if `coordinate` is omitted. Do NOT use PageUp/PageDown to + scroll. + """ + + name: Literal["scroll"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_tool_use_block.py b/src/anthropic/types/computer_tool_use_block.py new file mode 100644 index 000000000..6e818ad8b --- /dev/null +++ b/src/anthropic/types/computer_tool_use_block.py @@ -0,0 +1,46 @@ +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from .._models import UnionDiscriminator +from .computer_key_tool_use_block import ComputerKeyToolUseBlock +from .computer_type_tool_use_block import ComputerTypeToolUseBlock +from .computer_wait_tool_use_block import ComputerWaitToolUseBlock +from .computer_zoom_tool_use_block import ComputerZoomToolUseBlock +from .computer_scroll_tool_use_block import ComputerScrollToolUseBlock +from .computer_hold_key_tool_use_block import ComputerHoldKeyToolUseBlock +from .computer_left_click_tool_use_block import ComputerLeftClickToolUseBlock +from .computer_mouse_move_tool_use_block import ComputerMouseMoveToolUseBlock +from .computer_screenshot_tool_use_block import ComputerScreenshotToolUseBlock +from .computer_right_click_tool_use_block import ComputerRightClickToolUseBlock +from .computer_double_click_tool_use_block import ComputerDoubleClickToolUseBlock +from .computer_middle_click_tool_use_block import ComputerMiddleClickToolUseBlock +from .computer_triple_click_tool_use_block import ComputerTripleClickToolUseBlock +from .computer_left_mouse_up_tool_use_block import ComputerLeftMouseUpToolUseBlock +from .computer_cursor_position_tool_use_block import ComputerCursorPositionToolUseBlock +from .computer_left_click_drag_tool_use_block import ComputerLeftClickDragToolUseBlock +from .computer_left_mouse_down_tool_use_block import ComputerLeftMouseDownToolUseBlock + +__all__ = ["ComputerToolUseBlock"] + +ComputerToolUseBlock: TypeAlias = Annotated[ + Union[ + ComputerKeyToolUseBlock, + ComputerHoldKeyToolUseBlock, + ComputerTypeToolUseBlock, + ComputerCursorPositionToolUseBlock, + ComputerMouseMoveToolUseBlock, + ComputerLeftMouseDownToolUseBlock, + ComputerLeftMouseUpToolUseBlock, + ComputerLeftClickToolUseBlock, + ComputerLeftClickDragToolUseBlock, + ComputerRightClickToolUseBlock, + ComputerMiddleClickToolUseBlock, + ComputerDoubleClickToolUseBlock, + ComputerTripleClickToolUseBlock, + ComputerScrollToolUseBlock, + ComputerWaitToolUseBlock, + ComputerScreenshotToolUseBlock, + ComputerZoomToolUseBlock, + ], + UnionDiscriminator("name"), +] diff --git a/src/anthropic/types/computer_triple_click_input.py b/src/anthropic/types/computer_triple_click_input.py new file mode 100644 index 000000000..a8eda3145 --- /dev/null +++ b/src/anthropic/types/computer_triple_click_input.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from .._models import BaseModel + +__all__ = ["ComputerTripleClickInput"] + + +class ComputerTripleClickInput(BaseModel): + """ + Triple-click the left mouse button at the specified (x, y) pixel coordinate, or + the current cursor position if `coordinate` is omitted. + """ + + coordinate: Optional[List[int]] = None + """(x, y): x pixels from the left edge, y pixels from the top edge.""" + + text: Optional[str] = None + """Optional key combination to hold down during this action (e.g. + + "ctrl", "shift", "ctrl+shift"). + """ diff --git a/src/anthropic/types/computer_triple_click_tool_use_block.py b/src/anthropic/types/computer_triple_click_tool_use_block.py new file mode 100644 index 000000000..42d219626 --- /dev/null +++ b/src/anthropic/types/computer_triple_click_tool_use_block.py @@ -0,0 +1,29 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_triple_click_input import ComputerTripleClickInput + +__all__ = ["ComputerTripleClickToolUseBlock"] + + +class ComputerTripleClickToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerTripleClickInput + """ + Triple-click the left mouse button at the specified (x, y) pixel coordinate, or + the current cursor position if `coordinate` is omitted. + """ + + name: Literal["triple_click"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_type_input.py b/src/anthropic/types/computer_type_input.py new file mode 100644 index 000000000..959601479 --- /dev/null +++ b/src/anthropic/types/computer_type_input.py @@ -0,0 +1,10 @@ +from .._models import BaseModel + +__all__ = ["ComputerTypeInput"] + + +class ComputerTypeInput(BaseModel): + """Type a string of text on the keyboard.""" + + text: str + """The text to type.""" diff --git a/src/anthropic/types/computer_type_tool_use_block.py b/src/anthropic/types/computer_type_tool_use_block.py new file mode 100644 index 000000000..da799d343 --- /dev/null +++ b/src/anthropic/types/computer_type_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_type_input import ComputerTypeInput + +__all__ = ["ComputerTypeToolUseBlock"] + + +class ComputerTypeToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerTypeInput + """Type a string of text on the keyboard.""" + + name: Literal["type"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_wait_input.py b/src/anthropic/types/computer_wait_input.py new file mode 100644 index 000000000..22cde2a04 --- /dev/null +++ b/src/anthropic/types/computer_wait_input.py @@ -0,0 +1,10 @@ +from .._models import BaseModel + +__all__ = ["ComputerWaitInput"] + + +class ComputerWaitInput(BaseModel): + """Wait for a specified duration.""" + + duration: int + """Duration to wait, in seconds.""" diff --git a/src/anthropic/types/computer_wait_tool_use_block.py b/src/anthropic/types/computer_wait_tool_use_block.py new file mode 100644 index 000000000..d976a8583 --- /dev/null +++ b/src/anthropic/types/computer_wait_tool_use_block.py @@ -0,0 +1,26 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_wait_input import ComputerWaitInput + +__all__ = ["ComputerWaitToolUseBlock"] + + +class ComputerWaitToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerWaitInput + """Wait for a specified duration.""" + + name: Literal["wait"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/computer_zoom_input.py b/src/anthropic/types/computer_zoom_input.py new file mode 100644 index 000000000..8e34821ff --- /dev/null +++ b/src/anthropic/types/computer_zoom_input.py @@ -0,0 +1,17 @@ +from typing import List + +from .._models import BaseModel + +__all__ = ["ComputerZoomInput"] + + +class ComputerZoomInput(BaseModel): + """Take a screenshot of a rectangular region. + + Region coordinates are in the + full-screenshot space (not physical display pixels). The crop is scaled up to + fill the image budget so fine details become legible. + """ + + region: List[int] + """(x0, y0, x1, y1): The region to capture.""" diff --git a/src/anthropic/types/computer_zoom_tool_use_block.py b/src/anthropic/types/computer_zoom_tool_use_block.py new file mode 100644 index 000000000..cbe302d23 --- /dev/null +++ b/src/anthropic/types/computer_zoom_tool_use_block.py @@ -0,0 +1,31 @@ +from typing_extensions import Literal + +from .._models import BaseModel +from .tool_use_caller import ToolUseCaller +from .computer_zoom_input import ComputerZoomInput + +__all__ = ["ComputerZoomToolUseBlock"] + + +class ComputerZoomToolUseBlock(BaseModel): + id: str + + caller: ToolUseCaller + """ + Which party invoked the tool call: the model directly, or a server tool on its + behalf. + """ + + input: ComputerZoomInput + """Take a screenshot of a rectangular region. + + Region coordinates are in the full-screenshot space (not physical display + pixels). The crop is scaled up to fill the image budget so fine details become + legible. + """ + + name: Literal["zoom"] + + toolset_name: Literal["computer"] + + type: Literal["tool_use"] diff --git a/src/anthropic/types/model.py b/src/anthropic/types/model.py index 0e0c0f1d9..28351440b 100644 --- a/src/anthropic/types/model.py +++ b/src/anthropic/types/model.py @@ -5,6 +5,7 @@ Model: TypeAlias = Union[ Literal[ + "claude-haiku-5-5", "claude-sonnet-5-5", "claude-fable-5-1", "claude-opus-5-5", diff --git a/src/anthropic/types/model_capabilities.py b/src/anthropic/types/model_capabilities.py index 33cd99fba..b2499bc2e 100644 --- a/src/anthropic/types/model_capabilities.py +++ b/src/anthropic/types/model_capabilities.py @@ -2,6 +2,7 @@ from .effort_capability import EffortCapability from .capability_support import CapabilitySupport from .thinking_capability import ThinkingCapability +from .server_tools_capability import ServerToolsCapability from .context_management_capability import ContextManagementCapability __all__ = ["ModelCapabilities"] @@ -17,7 +18,12 @@ class ModelCapabilities(BaseModel): """Whether the model supports citation generation.""" code_execution: CapabilitySupport - """Whether the model supports code execution tools.""" + """ + Whether code that the model runs in the code execution tool can call the + request's other tools, as in programmatic tool calling and dynamic filtering for + web search and web fetch. Support for the code execution tool itself is in + `server_tools.code_execution`. + """ context_management: ContextManagementCapability """Context management support and available strategies.""" @@ -31,6 +37,14 @@ class ModelCapabilities(BaseModel): pdf_input: CapabilitySupport """Whether the model accepts PDF content blocks.""" + server_tools: ServerToolsCapability + """Whether this model supports the web search and code execution server tools. + + `supported` is true when the model supports at least one of the tools. A + supported tool can still be rejected for your organization, for example when an + admin has turned web search off. + """ + structured_outputs: CapabilitySupport """Whether the model supports structured output / JSON mode / strict tool schemas.""" diff --git a/src/anthropic/types/model_info.py b/src/anthropic/types/model_info.py index 0fb9c4346..69a8c815a 100644 --- a/src/anthropic/types/model_info.py +++ b/src/anthropic/types/model_info.py @@ -3,6 +3,7 @@ from typing_extensions import Literal from .._models import BaseModel +from .model_line import ModelLine from .model_capabilities import ModelCapabilities __all__ = ["ModelInfo"] @@ -24,15 +25,50 @@ class ModelInfo(BaseModel): May be set to an epoch value if the release date is unknown. """ + deprecated_at: Optional[datetime] = None + """ + RFC 3339 datetime string representing the time of the model's most recent + deprecation. Populated for `deprecated` and `retired` models; `null` while the + model is `active`. + """ + display_name: str """A human-readable name for the model.""" + lifecycle: Literal["active", "deprecated", "retired"] + """The model's current lifecycle stage. + + - `active`: The model is available for use, open to new adopters, and not + scheduled for retirement. + - `deprecated`: The model remains callable for organizations with existing + access, but is headed for retirement and closed to new adopters. + - `retired`: The model is no longer available for use; inference requests naming + it fail. It remains in the catalogue as the historical record of its + retirement. + """ + + line: Optional[ModelLine] = None + """ + The model line this model belongs to, such as `opus` for both Claude Opus 4.5 + and Claude Opus 4.6. More lines may be added. `null` when the model belongs to + no line; do not infer a line from the `id`. + """ + max_input_tokens: Optional[int] = None """Maximum input context window size in tokens for this model.""" max_tokens: Optional[int] = None """Maximum value for the `max_tokens` parameter when using this model.""" + retires_at: Optional[datetime] = None + """ + RFC 3339 datetime string representing the model's currently scheduled retirement + date. The schedule can be revised until retirement occurs; `null` while the + model is `active` or while no retirement is scheduled. A past date on a + `deprecated` model means retirement is overdue, not that it has occurred: + `lifecycle` is the retirement signal. + """ + type: Literal["model"] """Object type. diff --git a/src/anthropic/types/model_line.py b/src/anthropic/types/model_line.py new file mode 100644 index 000000000..b6d41b8a2 --- /dev/null +++ b/src/anthropic/types/model_line.py @@ -0,0 +1,5 @@ +from typing_extensions import Literal, TypeAlias + +__all__ = ["ModelLine"] + +ModelLine: TypeAlias = Literal["haiku", "sonnet", "opus", "fable", "mythos"] diff --git a/src/anthropic/types/model_list_params.py b/src/anthropic/types/model_list_params.py index de3ea8141..3c559a0de 100644 --- a/src/anthropic/types/model_list_params.py +++ b/src/anthropic/types/model_list_params.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import List -from typing_extensions import TypedDict +from typing_extensions import Literal, TypedDict from .anthropic_beta_param import AnthropicBetaParam @@ -21,6 +21,14 @@ class ModelListParams(TypedDict, total=False): When provided, returns the page of results immediately before this object. """ + lifecycle: List[Literal["active", "deprecated", "retired"]] + """ + Filter the list to models in any of the given lifecycle stages (`active`, + `deprecated`, or `retired`). Up to 3 values. When omitted, the list contains the + `active` and `deprecated` models; `retired` models appear only when `retired` is + requested explicitly. + """ + limit: int """Number of items to return per page. diff --git a/src/anthropic/types/model_param.py b/src/anthropic/types/model_param.py index 3c53b6285..c0223d630 100644 --- a/src/anthropic/types/model_param.py +++ b/src/anthropic/types/model_param.py @@ -7,6 +7,7 @@ ModelParam: TypeAlias = Union[ Literal[ + "claude-haiku-5-5", "claude-sonnet-5-5", "claude-fable-5-1", "claude-opus-5-5", diff --git a/src/anthropic/types/organization/federation/federation_rule.py b/src/anthropic/types/organization/federation/federation_rule.py index 4b1f72229..c3351d928 100644 --- a/src/anthropic/types/organization/federation/federation_rule.py +++ b/src/anthropic/types/organization/federation/federation_rule.py @@ -65,9 +65,10 @@ class FederationRule(BaseModel): """Space-separated OAuth scopes granted on the minted token.""" target: ServiceAccountTarget - """Identity that tokens minted via this rule act as. + """What this rule targets. - Currently always a `service_account` target. + Check `type` before reading the other fields. Tokens minted via a rule whose + target `type` is `service_account` act as that service account. """ token_lifetime_seconds: int diff --git a/src/anthropic/types/organization/workspace_list_params.py b/src/anthropic/types/organization/workspace_list_params.py index 8194a9a7e..368d537ff 100644 --- a/src/anthropic/types/organization/workspace_list_params.py +++ b/src/anthropic/types/organization/workspace_list_params.py @@ -21,6 +21,9 @@ class WorkspaceListParams(TypedDict, total=False): include_archived: bool """Whether to include Workspaces that have been archived in the response""" + include_default: bool + """Whether to include the organization's default Workspace in the response""" + limit: int """Number of items to return per page. diff --git a/src/anthropic/types/response_browser_close_tab_tool_use_block.py b/src/anthropic/types/response_browser_close_tab_tool_use_block.py new file mode 100644 index 000000000..5be03b8e2 --- /dev/null +++ b/src/anthropic/types/response_browser_close_tab_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_close_tab_tool_use_block import BrowserCloseTabToolUseBlock + +__all__ = ["ResponseBrowserCloseTabToolUseBlock"] + +ResponseBrowserCloseTabToolUseBlock = BrowserCloseTabToolUseBlock +""" +ResponseBrowserCloseTabToolUseBlock has been renamed to BrowserCloseTabToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_double_click_tool_use_block.py b/src/anthropic/types/response_browser_double_click_tool_use_block.py new file mode 100644 index 000000000..cf3fdbfd2 --- /dev/null +++ b/src/anthropic/types/response_browser_double_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_double_click_tool_use_block import BrowserDoubleClickToolUseBlock + +__all__ = ["ResponseBrowserDoubleClickToolUseBlock"] + +ResponseBrowserDoubleClickToolUseBlock = BrowserDoubleClickToolUseBlock +""" +ResponseBrowserDoubleClickToolUseBlock has been renamed to BrowserDoubleClickToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_file_upload_tool_use_block.py b/src/anthropic/types/response_browser_file_upload_tool_use_block.py new file mode 100644 index 000000000..d0d7fae11 --- /dev/null +++ b/src/anthropic/types/response_browser_file_upload_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_file_upload_tool_use_block import BrowserFileUploadToolUseBlock + +__all__ = ["ResponseBrowserFileUploadToolUseBlock"] + +ResponseBrowserFileUploadToolUseBlock = BrowserFileUploadToolUseBlock +""" +ResponseBrowserFileUploadToolUseBlock has been renamed to BrowserFileUploadToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_find_tool_use_block.py b/src/anthropic/types/response_browser_find_tool_use_block.py new file mode 100644 index 000000000..765eabdcc --- /dev/null +++ b/src/anthropic/types/response_browser_find_tool_use_block.py @@ -0,0 +1,6 @@ +from .browser_find_tool_use_block import BrowserFindToolUseBlock + +__all__ = ["ResponseBrowserFindToolUseBlock"] + +ResponseBrowserFindToolUseBlock = BrowserFindToolUseBlock +"""ResponseBrowserFindToolUseBlock has been renamed to BrowserFindToolUseBlock""" diff --git a/src/anthropic/types/response_browser_form_input_tool_use_block.py b/src/anthropic/types/response_browser_form_input_tool_use_block.py new file mode 100644 index 000000000..23ee74821 --- /dev/null +++ b/src/anthropic/types/response_browser_form_input_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_form_input_tool_use_block import BrowserFormInputToolUseBlock + +__all__ = ["ResponseBrowserFormInputToolUseBlock"] + +ResponseBrowserFormInputToolUseBlock = BrowserFormInputToolUseBlock +""" +ResponseBrowserFormInputToolUseBlock has been renamed to BrowserFormInputToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_get_page_text_tool_use_block.py b/src/anthropic/types/response_browser_get_page_text_tool_use_block.py new file mode 100644 index 000000000..56087659d --- /dev/null +++ b/src/anthropic/types/response_browser_get_page_text_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_get_page_text_tool_use_block import BrowserGetPageTextToolUseBlock + +__all__ = ["ResponseBrowserGetPageTextToolUseBlock"] + +ResponseBrowserGetPageTextToolUseBlock = BrowserGetPageTextToolUseBlock +""" +ResponseBrowserGetPageTextToolUseBlock has been renamed to BrowserGetPageTextToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_hold_key_tool_use_block.py b/src/anthropic/types/response_browser_hold_key_tool_use_block.py new file mode 100644 index 000000000..f7d28e2e8 --- /dev/null +++ b/src/anthropic/types/response_browser_hold_key_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_hold_key_tool_use_block import BrowserHoldKeyToolUseBlock + +__all__ = ["ResponseBrowserHoldKeyToolUseBlock"] + +ResponseBrowserHoldKeyToolUseBlock = BrowserHoldKeyToolUseBlock +""" +ResponseBrowserHoldKeyToolUseBlock has been renamed to BrowserHoldKeyToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_hover_tool_use_block.py b/src/anthropic/types/response_browser_hover_tool_use_block.py new file mode 100644 index 000000000..ba1e90bba --- /dev/null +++ b/src/anthropic/types/response_browser_hover_tool_use_block.py @@ -0,0 +1,6 @@ +from .browser_hover_tool_use_block import BrowserHoverToolUseBlock + +__all__ = ["ResponseBrowserHoverToolUseBlock"] + +ResponseBrowserHoverToolUseBlock = BrowserHoverToolUseBlock +"""ResponseBrowserHoverToolUseBlock has been renamed to BrowserHoverToolUseBlock""" diff --git a/src/anthropic/types/response_browser_javascript_exec_tool_use_block.py b/src/anthropic/types/response_browser_javascript_exec_tool_use_block.py new file mode 100644 index 000000000..5042bf475 --- /dev/null +++ b/src/anthropic/types/response_browser_javascript_exec_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_javascript_exec_tool_use_block import BrowserJavascriptExecToolUseBlock + +__all__ = ["ResponseBrowserJavascriptExecToolUseBlock"] + +ResponseBrowserJavascriptExecToolUseBlock = BrowserJavascriptExecToolUseBlock +""" +ResponseBrowserJavascriptExecToolUseBlock has been renamed to BrowserJavascriptExecToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_key_tool_use_block.py b/src/anthropic/types/response_browser_key_tool_use_block.py new file mode 100644 index 000000000..9b5d1cd6f --- /dev/null +++ b/src/anthropic/types/response_browser_key_tool_use_block.py @@ -0,0 +1,6 @@ +from .browser_key_tool_use_block import BrowserKeyToolUseBlock + +__all__ = ["ResponseBrowserKeyToolUseBlock"] + +ResponseBrowserKeyToolUseBlock = BrowserKeyToolUseBlock +"""ResponseBrowserKeyToolUseBlock has been renamed to BrowserKeyToolUseBlock""" diff --git a/src/anthropic/types/response_browser_left_click_drag_tool_use_block.py b/src/anthropic/types/response_browser_left_click_drag_tool_use_block.py new file mode 100644 index 000000000..4809c249f --- /dev/null +++ b/src/anthropic/types/response_browser_left_click_drag_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_left_click_drag_tool_use_block import BrowserLeftClickDragToolUseBlock + +__all__ = ["ResponseBrowserLeftClickDragToolUseBlock"] + +ResponseBrowserLeftClickDragToolUseBlock = BrowserLeftClickDragToolUseBlock +""" +ResponseBrowserLeftClickDragToolUseBlock has been renamed to BrowserLeftClickDragToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_left_click_tool_use_block.py b/src/anthropic/types/response_browser_left_click_tool_use_block.py new file mode 100644 index 000000000..d102041f3 --- /dev/null +++ b/src/anthropic/types/response_browser_left_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_left_click_tool_use_block import BrowserLeftClickToolUseBlock + +__all__ = ["ResponseBrowserLeftClickToolUseBlock"] + +ResponseBrowserLeftClickToolUseBlock = BrowserLeftClickToolUseBlock +""" +ResponseBrowserLeftClickToolUseBlock has been renamed to BrowserLeftClickToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_left_mouse_down_tool_use_block.py b/src/anthropic/types/response_browser_left_mouse_down_tool_use_block.py new file mode 100644 index 000000000..6bf27642f --- /dev/null +++ b/src/anthropic/types/response_browser_left_mouse_down_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_left_mouse_down_tool_use_block import BrowserLeftMouseDownToolUseBlock + +__all__ = ["ResponseBrowserLeftMouseDownToolUseBlock"] + +ResponseBrowserLeftMouseDownToolUseBlock = BrowserLeftMouseDownToolUseBlock +""" +ResponseBrowserLeftMouseDownToolUseBlock has been renamed to BrowserLeftMouseDownToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_left_mouse_up_tool_use_block.py b/src/anthropic/types/response_browser_left_mouse_up_tool_use_block.py new file mode 100644 index 000000000..823977e05 --- /dev/null +++ b/src/anthropic/types/response_browser_left_mouse_up_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_left_mouse_up_tool_use_block import BrowserLeftMouseUpToolUseBlock + +__all__ = ["ResponseBrowserLeftMouseUpToolUseBlock"] + +ResponseBrowserLeftMouseUpToolUseBlock = BrowserLeftMouseUpToolUseBlock +""" +ResponseBrowserLeftMouseUpToolUseBlock has been renamed to BrowserLeftMouseUpToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_list_tabs_tool_use_block.py b/src/anthropic/types/response_browser_list_tabs_tool_use_block.py new file mode 100644 index 000000000..4d087cbd5 --- /dev/null +++ b/src/anthropic/types/response_browser_list_tabs_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_list_tabs_tool_use_block import BrowserListTabsToolUseBlock + +__all__ = ["ResponseBrowserListTabsToolUseBlock"] + +ResponseBrowserListTabsToolUseBlock = BrowserListTabsToolUseBlock +""" +ResponseBrowserListTabsToolUseBlock has been renamed to BrowserListTabsToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_middle_click_tool_use_block.py b/src/anthropic/types/response_browser_middle_click_tool_use_block.py new file mode 100644 index 000000000..005c5498c --- /dev/null +++ b/src/anthropic/types/response_browser_middle_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_middle_click_tool_use_block import BrowserMiddleClickToolUseBlock + +__all__ = ["ResponseBrowserMiddleClickToolUseBlock"] + +ResponseBrowserMiddleClickToolUseBlock = BrowserMiddleClickToolUseBlock +""" +ResponseBrowserMiddleClickToolUseBlock has been renamed to BrowserMiddleClickToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_mouse_move_tool_use_block.py b/src/anthropic/types/response_browser_mouse_move_tool_use_block.py new file mode 100644 index 000000000..d14b8f8f4 --- /dev/null +++ b/src/anthropic/types/response_browser_mouse_move_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_mouse_move_tool_use_block import BrowserMouseMoveToolUseBlock + +__all__ = ["ResponseBrowserMouseMoveToolUseBlock"] + +ResponseBrowserMouseMoveToolUseBlock = BrowserMouseMoveToolUseBlock +""" +ResponseBrowserMouseMoveToolUseBlock has been renamed to BrowserMouseMoveToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_navigate_tool_use_block.py b/src/anthropic/types/response_browser_navigate_tool_use_block.py new file mode 100644 index 000000000..c377a6481 --- /dev/null +++ b/src/anthropic/types/response_browser_navigate_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_navigate_tool_use_block import BrowserNavigateToolUseBlock + +__all__ = ["ResponseBrowserNavigateToolUseBlock"] + +ResponseBrowserNavigateToolUseBlock = BrowserNavigateToolUseBlock +""" +ResponseBrowserNavigateToolUseBlock has been renamed to BrowserNavigateToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_new_tab_tool_use_block.py b/src/anthropic/types/response_browser_new_tab_tool_use_block.py new file mode 100644 index 000000000..756b9f38a --- /dev/null +++ b/src/anthropic/types/response_browser_new_tab_tool_use_block.py @@ -0,0 +1,6 @@ +from .browser_new_tab_tool_use_block import BrowserNewTabToolUseBlock + +__all__ = ["ResponseBrowserNewTabToolUseBlock"] + +ResponseBrowserNewTabToolUseBlock = BrowserNewTabToolUseBlock +"""ResponseBrowserNewTabToolUseBlock has been renamed to BrowserNewTabToolUseBlock""" diff --git a/src/anthropic/types/response_browser_read_console_tool_use_block.py b/src/anthropic/types/response_browser_read_console_tool_use_block.py new file mode 100644 index 000000000..d7f2d6f15 --- /dev/null +++ b/src/anthropic/types/response_browser_read_console_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_read_console_tool_use_block import BrowserReadConsoleToolUseBlock + +__all__ = ["ResponseBrowserReadConsoleToolUseBlock"] + +ResponseBrowserReadConsoleToolUseBlock = BrowserReadConsoleToolUseBlock +""" +ResponseBrowserReadConsoleToolUseBlock has been renamed to BrowserReadConsoleToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_read_network_tool_use_block.py b/src/anthropic/types/response_browser_read_network_tool_use_block.py new file mode 100644 index 000000000..2f37215a3 --- /dev/null +++ b/src/anthropic/types/response_browser_read_network_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_read_network_tool_use_block import BrowserReadNetworkToolUseBlock + +__all__ = ["ResponseBrowserReadNetworkToolUseBlock"] + +ResponseBrowserReadNetworkToolUseBlock = BrowserReadNetworkToolUseBlock +""" +ResponseBrowserReadNetworkToolUseBlock has been renamed to BrowserReadNetworkToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_read_page_tool_use_block.py b/src/anthropic/types/response_browser_read_page_tool_use_block.py new file mode 100644 index 000000000..0093cf5af --- /dev/null +++ b/src/anthropic/types/response_browser_read_page_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_read_page_tool_use_block import BrowserReadPageToolUseBlock + +__all__ = ["ResponseBrowserReadPageToolUseBlock"] + +ResponseBrowserReadPageToolUseBlock = BrowserReadPageToolUseBlock +""" +ResponseBrowserReadPageToolUseBlock has been renamed to BrowserReadPageToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_right_click_tool_use_block.py b/src/anthropic/types/response_browser_right_click_tool_use_block.py new file mode 100644 index 000000000..5d89ef8d7 --- /dev/null +++ b/src/anthropic/types/response_browser_right_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_right_click_tool_use_block import BrowserRightClickToolUseBlock + +__all__ = ["ResponseBrowserRightClickToolUseBlock"] + +ResponseBrowserRightClickToolUseBlock = BrowserRightClickToolUseBlock +""" +ResponseBrowserRightClickToolUseBlock has been renamed to BrowserRightClickToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_screenshot_tool_use_block.py b/src/anthropic/types/response_browser_screenshot_tool_use_block.py new file mode 100644 index 000000000..ba243a243 --- /dev/null +++ b/src/anthropic/types/response_browser_screenshot_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_screenshot_tool_use_block import BrowserScreenshotToolUseBlock + +__all__ = ["ResponseBrowserScreenshotToolUseBlock"] + +ResponseBrowserScreenshotToolUseBlock = BrowserScreenshotToolUseBlock +""" +ResponseBrowserScreenshotToolUseBlock has been renamed to BrowserScreenshotToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_scroll_to_tool_use_block.py b/src/anthropic/types/response_browser_scroll_to_tool_use_block.py new file mode 100644 index 000000000..655974128 --- /dev/null +++ b/src/anthropic/types/response_browser_scroll_to_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_scroll_to_tool_use_block import BrowserScrollToToolUseBlock + +__all__ = ["ResponseBrowserScrollToToolUseBlock"] + +ResponseBrowserScrollToToolUseBlock = BrowserScrollToToolUseBlock +""" +ResponseBrowserScrollToToolUseBlock has been renamed to BrowserScrollToToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_scroll_tool_use_block.py b/src/anthropic/types/response_browser_scroll_tool_use_block.py new file mode 100644 index 000000000..84f8466b2 --- /dev/null +++ b/src/anthropic/types/response_browser_scroll_tool_use_block.py @@ -0,0 +1,6 @@ +from .browser_scroll_tool_use_block import BrowserScrollToolUseBlock + +__all__ = ["ResponseBrowserScrollToolUseBlock"] + +ResponseBrowserScrollToolUseBlock = BrowserScrollToolUseBlock +"""ResponseBrowserScrollToolUseBlock has been renamed to BrowserScrollToolUseBlock""" diff --git a/src/anthropic/types/response_browser_switch_tab_tool_use_block.py b/src/anthropic/types/response_browser_switch_tab_tool_use_block.py new file mode 100644 index 000000000..4d8401266 --- /dev/null +++ b/src/anthropic/types/response_browser_switch_tab_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_switch_tab_tool_use_block import BrowserSwitchTabToolUseBlock + +__all__ = ["ResponseBrowserSwitchTabToolUseBlock"] + +ResponseBrowserSwitchTabToolUseBlock = BrowserSwitchTabToolUseBlock +""" +ResponseBrowserSwitchTabToolUseBlock has been renamed to BrowserSwitchTabToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_tool_use_block.py b/src/anthropic/types/response_browser_tool_use_block.py new file mode 100644 index 000000000..2835ebb95 --- /dev/null +++ b/src/anthropic/types/response_browser_tool_use_block.py @@ -0,0 +1,6 @@ +from .browser_tool_use_block import BrowserToolUseBlock + +__all__ = ["ResponseBrowserToolUseBlock"] + +ResponseBrowserToolUseBlock = BrowserToolUseBlock +"""ResponseBrowserToolUseBlock has been renamed to BrowserToolUseBlock""" diff --git a/src/anthropic/types/response_browser_triple_click_tool_use_block.py b/src/anthropic/types/response_browser_triple_click_tool_use_block.py new file mode 100644 index 000000000..a3fda58aa --- /dev/null +++ b/src/anthropic/types/response_browser_triple_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .browser_triple_click_tool_use_block import BrowserTripleClickToolUseBlock + +__all__ = ["ResponseBrowserTripleClickToolUseBlock"] + +ResponseBrowserTripleClickToolUseBlock = BrowserTripleClickToolUseBlock +""" +ResponseBrowserTripleClickToolUseBlock has been renamed to BrowserTripleClickToolUseBlock +""" diff --git a/src/anthropic/types/response_browser_type_tool_use_block.py b/src/anthropic/types/response_browser_type_tool_use_block.py new file mode 100644 index 000000000..436961113 --- /dev/null +++ b/src/anthropic/types/response_browser_type_tool_use_block.py @@ -0,0 +1,6 @@ +from .browser_type_tool_use_block import BrowserTypeToolUseBlock + +__all__ = ["ResponseBrowserTypeToolUseBlock"] + +ResponseBrowserTypeToolUseBlock = BrowserTypeToolUseBlock +"""ResponseBrowserTypeToolUseBlock has been renamed to BrowserTypeToolUseBlock""" diff --git a/src/anthropic/types/response_browser_wait_tool_use_block.py b/src/anthropic/types/response_browser_wait_tool_use_block.py new file mode 100644 index 000000000..be6967d10 --- /dev/null +++ b/src/anthropic/types/response_browser_wait_tool_use_block.py @@ -0,0 +1,6 @@ +from .browser_wait_tool_use_block import BrowserWaitToolUseBlock + +__all__ = ["ResponseBrowserWaitToolUseBlock"] + +ResponseBrowserWaitToolUseBlock = BrowserWaitToolUseBlock +"""ResponseBrowserWaitToolUseBlock has been renamed to BrowserWaitToolUseBlock""" diff --git a/src/anthropic/types/response_browser_zoom_tool_use_block.py b/src/anthropic/types/response_browser_zoom_tool_use_block.py new file mode 100644 index 000000000..53b5a380c --- /dev/null +++ b/src/anthropic/types/response_browser_zoom_tool_use_block.py @@ -0,0 +1,6 @@ +from .browser_zoom_tool_use_block import BrowserZoomToolUseBlock + +__all__ = ["ResponseBrowserZoomToolUseBlock"] + +ResponseBrowserZoomToolUseBlock = BrowserZoomToolUseBlock +"""ResponseBrowserZoomToolUseBlock has been renamed to BrowserZoomToolUseBlock""" diff --git a/src/anthropic/types/response_computer_cursor_position_tool_use_block.py b/src/anthropic/types/response_computer_cursor_position_tool_use_block.py new file mode 100644 index 000000000..34f2e334b --- /dev/null +++ b/src/anthropic/types/response_computer_cursor_position_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_cursor_position_tool_use_block import ComputerCursorPositionToolUseBlock + +__all__ = ["ResponseComputerCursorPositionToolUseBlock"] + +ResponseComputerCursorPositionToolUseBlock = ComputerCursorPositionToolUseBlock +""" +ResponseComputerCursorPositionToolUseBlock has been renamed to ComputerCursorPositionToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_double_click_tool_use_block.py b/src/anthropic/types/response_computer_double_click_tool_use_block.py new file mode 100644 index 000000000..d41890aba --- /dev/null +++ b/src/anthropic/types/response_computer_double_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_double_click_tool_use_block import ComputerDoubleClickToolUseBlock + +__all__ = ["ResponseComputerDoubleClickToolUseBlock"] + +ResponseComputerDoubleClickToolUseBlock = ComputerDoubleClickToolUseBlock +""" +ResponseComputerDoubleClickToolUseBlock has been renamed to ComputerDoubleClickToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_hold_key_tool_use_block.py b/src/anthropic/types/response_computer_hold_key_tool_use_block.py new file mode 100644 index 000000000..12e4a5575 --- /dev/null +++ b/src/anthropic/types/response_computer_hold_key_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_hold_key_tool_use_block import ComputerHoldKeyToolUseBlock + +__all__ = ["ResponseComputerHoldKeyToolUseBlock"] + +ResponseComputerHoldKeyToolUseBlock = ComputerHoldKeyToolUseBlock +""" +ResponseComputerHoldKeyToolUseBlock has been renamed to ComputerHoldKeyToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_key_tool_use_block.py b/src/anthropic/types/response_computer_key_tool_use_block.py new file mode 100644 index 000000000..f0c603a56 --- /dev/null +++ b/src/anthropic/types/response_computer_key_tool_use_block.py @@ -0,0 +1,6 @@ +from .computer_key_tool_use_block import ComputerKeyToolUseBlock + +__all__ = ["ResponseComputerKeyToolUseBlock"] + +ResponseComputerKeyToolUseBlock = ComputerKeyToolUseBlock +"""ResponseComputerKeyToolUseBlock has been renamed to ComputerKeyToolUseBlock""" diff --git a/src/anthropic/types/response_computer_left_click_drag_tool_use_block.py b/src/anthropic/types/response_computer_left_click_drag_tool_use_block.py new file mode 100644 index 000000000..84f01ce2c --- /dev/null +++ b/src/anthropic/types/response_computer_left_click_drag_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_left_click_drag_tool_use_block import ComputerLeftClickDragToolUseBlock + +__all__ = ["ResponseComputerLeftClickDragToolUseBlock"] + +ResponseComputerLeftClickDragToolUseBlock = ComputerLeftClickDragToolUseBlock +""" +ResponseComputerLeftClickDragToolUseBlock has been renamed to ComputerLeftClickDragToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_left_click_tool_use_block.py b/src/anthropic/types/response_computer_left_click_tool_use_block.py new file mode 100644 index 000000000..0b5b85ae5 --- /dev/null +++ b/src/anthropic/types/response_computer_left_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_left_click_tool_use_block import ComputerLeftClickToolUseBlock + +__all__ = ["ResponseComputerLeftClickToolUseBlock"] + +ResponseComputerLeftClickToolUseBlock = ComputerLeftClickToolUseBlock +""" +ResponseComputerLeftClickToolUseBlock has been renamed to ComputerLeftClickToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_left_mouse_down_tool_use_block.py b/src/anthropic/types/response_computer_left_mouse_down_tool_use_block.py new file mode 100644 index 000000000..b074968a5 --- /dev/null +++ b/src/anthropic/types/response_computer_left_mouse_down_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_left_mouse_down_tool_use_block import ComputerLeftMouseDownToolUseBlock + +__all__ = ["ResponseComputerLeftMouseDownToolUseBlock"] + +ResponseComputerLeftMouseDownToolUseBlock = ComputerLeftMouseDownToolUseBlock +""" +ResponseComputerLeftMouseDownToolUseBlock has been renamed to ComputerLeftMouseDownToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_left_mouse_up_tool_use_block.py b/src/anthropic/types/response_computer_left_mouse_up_tool_use_block.py new file mode 100644 index 000000000..88e5c7834 --- /dev/null +++ b/src/anthropic/types/response_computer_left_mouse_up_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_left_mouse_up_tool_use_block import ComputerLeftMouseUpToolUseBlock + +__all__ = ["ResponseComputerLeftMouseUpToolUseBlock"] + +ResponseComputerLeftMouseUpToolUseBlock = ComputerLeftMouseUpToolUseBlock +""" +ResponseComputerLeftMouseUpToolUseBlock has been renamed to ComputerLeftMouseUpToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_middle_click_tool_use_block.py b/src/anthropic/types/response_computer_middle_click_tool_use_block.py new file mode 100644 index 000000000..82a01e97c --- /dev/null +++ b/src/anthropic/types/response_computer_middle_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_middle_click_tool_use_block import ComputerMiddleClickToolUseBlock + +__all__ = ["ResponseComputerMiddleClickToolUseBlock"] + +ResponseComputerMiddleClickToolUseBlock = ComputerMiddleClickToolUseBlock +""" +ResponseComputerMiddleClickToolUseBlock has been renamed to ComputerMiddleClickToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_mouse_move_tool_use_block.py b/src/anthropic/types/response_computer_mouse_move_tool_use_block.py new file mode 100644 index 000000000..67b0d6e80 --- /dev/null +++ b/src/anthropic/types/response_computer_mouse_move_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_mouse_move_tool_use_block import ComputerMouseMoveToolUseBlock + +__all__ = ["ResponseComputerMouseMoveToolUseBlock"] + +ResponseComputerMouseMoveToolUseBlock = ComputerMouseMoveToolUseBlock +""" +ResponseComputerMouseMoveToolUseBlock has been renamed to ComputerMouseMoveToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_right_click_tool_use_block.py b/src/anthropic/types/response_computer_right_click_tool_use_block.py new file mode 100644 index 000000000..e5fbb3e92 --- /dev/null +++ b/src/anthropic/types/response_computer_right_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_right_click_tool_use_block import ComputerRightClickToolUseBlock + +__all__ = ["ResponseComputerRightClickToolUseBlock"] + +ResponseComputerRightClickToolUseBlock = ComputerRightClickToolUseBlock +""" +ResponseComputerRightClickToolUseBlock has been renamed to ComputerRightClickToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_screenshot_tool_use_block.py b/src/anthropic/types/response_computer_screenshot_tool_use_block.py new file mode 100644 index 000000000..c7674acb7 --- /dev/null +++ b/src/anthropic/types/response_computer_screenshot_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_screenshot_tool_use_block import ComputerScreenshotToolUseBlock + +__all__ = ["ResponseComputerScreenshotToolUseBlock"] + +ResponseComputerScreenshotToolUseBlock = ComputerScreenshotToolUseBlock +""" +ResponseComputerScreenshotToolUseBlock has been renamed to ComputerScreenshotToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_scroll_tool_use_block.py b/src/anthropic/types/response_computer_scroll_tool_use_block.py new file mode 100644 index 000000000..89adde9df --- /dev/null +++ b/src/anthropic/types/response_computer_scroll_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_scroll_tool_use_block import ComputerScrollToolUseBlock + +__all__ = ["ResponseComputerScrollToolUseBlock"] + +ResponseComputerScrollToolUseBlock = ComputerScrollToolUseBlock +""" +ResponseComputerScrollToolUseBlock has been renamed to ComputerScrollToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_tool_use_block.py b/src/anthropic/types/response_computer_tool_use_block.py new file mode 100644 index 000000000..d6b84e5b3 --- /dev/null +++ b/src/anthropic/types/response_computer_tool_use_block.py @@ -0,0 +1,6 @@ +from .computer_tool_use_block import ComputerToolUseBlock + +__all__ = ["ResponseComputerToolUseBlock"] + +ResponseComputerToolUseBlock = ComputerToolUseBlock +"""ResponseComputerToolUseBlock has been renamed to ComputerToolUseBlock""" diff --git a/src/anthropic/types/response_computer_triple_click_tool_use_block.py b/src/anthropic/types/response_computer_triple_click_tool_use_block.py new file mode 100644 index 000000000..c49b6165d --- /dev/null +++ b/src/anthropic/types/response_computer_triple_click_tool_use_block.py @@ -0,0 +1,8 @@ +from .computer_triple_click_tool_use_block import ComputerTripleClickToolUseBlock + +__all__ = ["ResponseComputerTripleClickToolUseBlock"] + +ResponseComputerTripleClickToolUseBlock = ComputerTripleClickToolUseBlock +""" +ResponseComputerTripleClickToolUseBlock has been renamed to ComputerTripleClickToolUseBlock +""" diff --git a/src/anthropic/types/response_computer_type_tool_use_block.py b/src/anthropic/types/response_computer_type_tool_use_block.py new file mode 100644 index 000000000..417586cf4 --- /dev/null +++ b/src/anthropic/types/response_computer_type_tool_use_block.py @@ -0,0 +1,6 @@ +from .computer_type_tool_use_block import ComputerTypeToolUseBlock + +__all__ = ["ResponseComputerTypeToolUseBlock"] + +ResponseComputerTypeToolUseBlock = ComputerTypeToolUseBlock +"""ResponseComputerTypeToolUseBlock has been renamed to ComputerTypeToolUseBlock""" diff --git a/src/anthropic/types/response_computer_wait_tool_use_block.py b/src/anthropic/types/response_computer_wait_tool_use_block.py new file mode 100644 index 000000000..e7ae12283 --- /dev/null +++ b/src/anthropic/types/response_computer_wait_tool_use_block.py @@ -0,0 +1,6 @@ +from .computer_wait_tool_use_block import ComputerWaitToolUseBlock + +__all__ = ["ResponseComputerWaitToolUseBlock"] + +ResponseComputerWaitToolUseBlock = ComputerWaitToolUseBlock +"""ResponseComputerWaitToolUseBlock has been renamed to ComputerWaitToolUseBlock""" diff --git a/src/anthropic/types/response_computer_zoom_tool_use_block.py b/src/anthropic/types/response_computer_zoom_tool_use_block.py new file mode 100644 index 000000000..bf00f6fa1 --- /dev/null +++ b/src/anthropic/types/response_computer_zoom_tool_use_block.py @@ -0,0 +1,6 @@ +from .computer_zoom_tool_use_block import ComputerZoomToolUseBlock + +__all__ = ["ResponseComputerZoomToolUseBlock"] + +ResponseComputerZoomToolUseBlock = ComputerZoomToolUseBlock +"""ResponseComputerZoomToolUseBlock has been renamed to ComputerZoomToolUseBlock""" diff --git a/src/anthropic/types/response_tool_use_block_union.py b/src/anthropic/types/response_tool_use_block_union.py new file mode 100644 index 000000000..c87d2994e --- /dev/null +++ b/src/anthropic/types/response_tool_use_block_union.py @@ -0,0 +1,6 @@ +from .toolset_tool_use_block import ToolsetToolUseBlock + +__all__ = ["ResponseToolUseBlockUnion"] + +ResponseToolUseBlockUnion = ToolsetToolUseBlock +"""ResponseToolUseBlockUnion has been renamed to ToolsetToolUseBlock""" diff --git a/src/anthropic/types/server_tools_capability.py b/src/anthropic/types/server_tools_capability.py new file mode 100644 index 000000000..0991891d3 --- /dev/null +++ b/src/anthropic/types/server_tools_capability.py @@ -0,0 +1,23 @@ +from .._models import BaseModel +from .capability_support import CapabilitySupport + +__all__ = ["ServerToolsCapability"] + + +class ServerToolsCapability(BaseModel): + """Web search and code execution tool support, with one entry per tool.""" + + code_execution: CapabilitySupport + """ + Whether the model supports the code execution tool: true when the model supports + at least one version of the tool, not necessarily every version. + """ + + supported: bool + """Whether this capability is supported by the model.""" + + web_search: CapabilitySupport + """ + Whether the model supports the web search tool: true when the model supports at + least one version of the tool, not necessarily every version. + """ diff --git a/src/anthropic/types/thinking_types.py b/src/anthropic/types/thinking_types.py index 8ea42bd61..d11ec52a9 100644 --- a/src/anthropic/types/thinking_types.py +++ b/src/anthropic/types/thinking_types.py @@ -5,10 +5,26 @@ class ThinkingTypes(BaseModel): - """Supported thinking type configurations.""" + """Which `thinking.type` values the model accepts on requests. + + Read each key on its own: for example, `enabled` can be false while `disabled` is true. + """ adaptive: CapabilitySupport - """Whether the model supports thinking with type 'adaptive' (auto).""" + """ + Whether the model accepts thinking with type 'adaptive' (the model decides + whether and how much to think). + """ + + disabled: CapabilitySupport + """Whether the model accepts thinking with type 'disabled' (thinking turned off). + + False exactly when a request that sends it gets a 400 from this model. True on a + model that does not support thinking. + """ enabled: CapabilitySupport - """Whether the model supports thinking with type 'enabled'.""" + """ + Whether the model accepts thinking with type 'enabled' (extended thinking with a + caller-set `budget_tokens`). + """ diff --git a/src/anthropic/types/tool_use_caller.py b/src/anthropic/types/tool_use_caller.py new file mode 100644 index 000000000..d6a48cd05 --- /dev/null +++ b/src/anthropic/types/tool_use_caller.py @@ -0,0 +1,13 @@ +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from .._models import UnionDiscriminator +from .direct_caller import DirectCaller +from .server_tool_caller import ServerToolCaller +from .server_tool_caller_20260120 import ServerToolCaller20260120 + +__all__ = ["ToolUseCaller"] + +ToolUseCaller: TypeAlias = Annotated[ + Union[DirectCaller, ServerToolCaller, ServerToolCaller20260120], UnionDiscriminator("type") +] diff --git a/src/anthropic/types/toolset_tool_use_block.py b/src/anthropic/types/toolset_tool_use_block.py new file mode 100644 index 000000000..57e3208b8 --- /dev/null +++ b/src/anthropic/types/toolset_tool_use_block.py @@ -0,0 +1,10 @@ +from typing import Union +from typing_extensions import TypeAlias + +from .tool_use_block import ToolUseBlock +from .browser_tool_use_block import BrowserToolUseBlock +from .computer_tool_use_block import ComputerToolUseBlock + +__all__ = ["ToolsetToolUseBlock"] + +ToolsetToolUseBlock: TypeAlias = Union[BrowserToolUseBlock, ComputerToolUseBlock, ToolUseBlock] diff --git a/tests/api_resources/beta/agents/test_versions.py b/tests/api_resources/beta/agents/test_versions.py index 5c063602b..3285d1d3f 100644 --- a/tests/api_resources/beta/agents/test_versions.py +++ b/tests/api_resources/beta/agents/test_versions.py @@ -16,7 +16,6 @@ class TestVersions: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: version = client.beta.agents.versions.list( @@ -24,7 +23,6 @@ def test_method_list(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsAgent], version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: version = client.beta.agents.versions.list( @@ -36,7 +34,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsAgent], version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.agents.versions.with_raw_response.list( @@ -48,7 +45,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: version = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsAgent], version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.agents.versions.with_streaming_response.list( @@ -62,7 +58,6 @@ def test_streaming_response_list(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_list(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): @@ -76,7 +71,6 @@ class TestAsyncVersions: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: version = await async_client.beta.agents.versions.list( @@ -84,7 +78,6 @@ async def test_method_list(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsAgent], version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: version = await async_client.beta.agents.versions.list( @@ -96,7 +89,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsAgent], version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.agents.versions.with_raw_response.list( @@ -108,7 +100,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: version = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsAgent], version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.agents.versions.with_streaming_response.list( @@ -122,7 +113,6 @@ async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> No assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): diff --git a/tests/api_resources/beta/environments/test_work.py b/tests/api_resources/beta/environments/test_work.py index d29952a84..d42d1b1ed 100644 --- a/tests/api_resources/beta/environments/test_work.py +++ b/tests/api_resources/beta/environments/test_work.py @@ -142,7 +142,6 @@ def test_path_params_update(self, client: Anthropic) -> None: metadata={"foo": "string"}, ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: work = client.beta.environments.work.list( @@ -150,7 +149,6 @@ def test_method_list(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaSelfHostedWork], work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: work = client.beta.environments.work.list( @@ -161,7 +159,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaSelfHostedWork], work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.environments.work.with_raw_response.list( @@ -173,7 +170,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: work = response.parse() assert_matches_type(SyncPageCursor[BetaSelfHostedWork], work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.environments.work.with_streaming_response.list( @@ -187,7 +183,6 @@ def test_streaming_response_list(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_list(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `environment_id` but received ''"): @@ -360,7 +355,6 @@ def test_path_params_poll(self, client: Anthropic) -> None: environment_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_stats(self, client: Anthropic) -> None: work = client.beta.environments.work.stats( @@ -368,7 +362,6 @@ def test_method_stats(self, client: Anthropic) -> None: ) assert_matches_type(BetaSelfHostedWorkQueueStats, work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_stats_with_all_params(self, client: Anthropic) -> None: work = client.beta.environments.work.stats( @@ -378,7 +371,6 @@ def test_method_stats_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(BetaSelfHostedWorkQueueStats, work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_stats(self, client: Anthropic) -> None: response = client.beta.environments.work.with_raw_response.stats( @@ -390,7 +382,6 @@ def test_raw_response_stats(self, client: Anthropic) -> None: work = response.parse() assert_matches_type(BetaSelfHostedWorkQueueStats, work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_stats(self, client: Anthropic) -> None: with client.beta.environments.work.with_streaming_response.stats( @@ -404,7 +395,6 @@ def test_streaming_response_stats(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_stats(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `environment_id` but received ''"): @@ -599,7 +589,6 @@ async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: metadata={"foo": "string"}, ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: work = await async_client.beta.environments.work.list( @@ -607,7 +596,6 @@ async def test_method_list(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(AsyncPageCursor[BetaSelfHostedWork], work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: work = await async_client.beta.environments.work.list( @@ -618,7 +606,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaSelfHostedWork], work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.environments.work.with_raw_response.list( @@ -630,7 +617,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: work = await response.parse() assert_matches_type(AsyncPageCursor[BetaSelfHostedWork], work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.environments.work.with_streaming_response.list( @@ -644,7 +630,6 @@ async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> No assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `environment_id` but received ''"): @@ -817,7 +802,6 @@ async def test_path_params_poll(self, async_client: AsyncAnthropic) -> None: environment_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_stats(self, async_client: AsyncAnthropic) -> None: work = await async_client.beta.environments.work.stats( @@ -825,7 +809,6 @@ async def test_method_stats(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(BetaSelfHostedWorkQueueStats, work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_stats_with_all_params(self, async_client: AsyncAnthropic) -> None: work = await async_client.beta.environments.work.stats( @@ -835,7 +818,6 @@ async def test_method_stats_with_all_params(self, async_client: AsyncAnthropic) ) assert_matches_type(BetaSelfHostedWorkQueueStats, work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_stats(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.environments.work.with_raw_response.stats( @@ -847,7 +829,6 @@ async def test_raw_response_stats(self, async_client: AsyncAnthropic) -> None: work = await response.parse() assert_matches_type(BetaSelfHostedWorkQueueStats, work, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_stats(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.environments.work.with_streaming_response.stats( @@ -861,7 +842,6 @@ async def test_streaming_response_stats(self, async_client: AsyncAnthropic) -> N assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_stats(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `environment_id` but received ''"): diff --git a/tests/api_resources/beta/memory_stores/test_memories.py b/tests/api_resources/beta/memory_stores/test_memories.py index f911fce78..151a7ddac 100644 --- a/tests/api_resources/beta/memory_stores/test_memories.py +++ b/tests/api_resources/beta/memory_stores/test_memories.py @@ -202,7 +202,6 @@ def test_path_params_update(self, client: Anthropic) -> None: memory_store_id="memory_store_id", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: memory = client.beta.memory_stores.memories.list( @@ -210,7 +209,6 @@ def test_method_list(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsMemoryListItem], memory, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: memory = client.beta.memory_stores.memories.list( @@ -225,7 +223,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsMemoryListItem], memory, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.memory_stores.memories.with_raw_response.list( @@ -237,7 +234,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: memory = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsMemoryListItem], memory, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.memory_stores.memories.with_streaming_response.list( @@ -251,7 +247,6 @@ def test_streaming_response_list(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_list(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `memory_store_id` but received ''"): @@ -506,7 +501,6 @@ async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: memory_store_id="memory_store_id", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: memory = await async_client.beta.memory_stores.memories.list( @@ -514,7 +508,6 @@ async def test_method_list(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsMemoryListItem], memory, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: memory = await async_client.beta.memory_stores.memories.list( @@ -529,7 +522,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsMemoryListItem], memory, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.memory_stores.memories.with_raw_response.list( @@ -541,7 +533,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: memory = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsMemoryListItem], memory, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.memory_stores.memories.with_streaming_response.list( @@ -555,7 +546,6 @@ async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> No assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `memory_store_id` but received ''"): diff --git a/tests/api_resources/beta/memory_stores/test_memory_versions.py b/tests/api_resources/beta/memory_stores/test_memory_versions.py index c312b91e2..9b169b522 100644 --- a/tests/api_resources/beta/memory_stores/test_memory_versions.py +++ b/tests/api_resources/beta/memory_stores/test_memory_versions.py @@ -78,7 +78,6 @@ def test_path_params_retrieve(self, client: Anthropic) -> None: memory_store_id="memory_store_id", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: memory_version = client.beta.memory_stores.memory_versions.list( @@ -86,7 +85,6 @@ def test_method_list(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsMemoryVersion], memory_version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: memory_version = client.beta.memory_stores.memory_versions.list( @@ -106,7 +104,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsMemoryVersion], memory_version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.memory_stores.memory_versions.with_raw_response.list( @@ -118,7 +115,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: memory_version = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsMemoryVersion], memory_version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.memory_stores.memory_versions.with_streaming_response.list( @@ -132,7 +128,6 @@ def test_streaming_response_list(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_list(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `memory_store_id` but received ''"): @@ -263,7 +258,6 @@ async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: memory_store_id="memory_store_id", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: memory_version = await async_client.beta.memory_stores.memory_versions.list( @@ -271,7 +265,6 @@ async def test_method_list(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsMemoryVersion], memory_version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: memory_version = await async_client.beta.memory_stores.memory_versions.list( @@ -291,7 +284,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsMemoryVersion], memory_version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.memory_stores.memory_versions.with_raw_response.list( @@ -303,7 +295,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: memory_version = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsMemoryVersion], memory_version, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.memory_stores.memory_versions.with_streaming_response.list( @@ -317,7 +308,6 @@ async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> No assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `memory_store_id` but received ''"): diff --git a/tests/api_resources/beta/messages/test_batches.py b/tests/api_resources/beta/messages/test_batches.py index e0102efff..04931d680 100644 --- a/tests/api_resources/beta/messages/test_batches.py +++ b/tests/api_resources/beta/messages/test_batches.py @@ -20,6 +20,20 @@ base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +def mock_results(respx_mock: MockRouter) -> None: + # the mock server's example results_url points at the production API + respx_mock.get("/v1/messages/batches/message_batch_id?beta=true").mock( + return_value=httpx2.Response( + 200, json={"results_url": "/v1/messages/batches/message_batch_id/results?beta=true"} + ) + ) + respx_mock.get("/v1/messages/batches/message_batch_id/results?beta=true").mock( + return_value=httpx2.Response( + 200, content=json.dumps({"custom_id": "my-custom-id-1", "result": {"type": "canceled"}}) + ) + ) + + class TestBatches: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) @@ -458,9 +472,10 @@ def test_path_params_results(self, client: Anthropic) -> None: message_batch_id="", ) - @parametrize - @pytest.mark.skip(reason="somehow hitting prod endpoint") - def test_raw_response_results(self, client: Anthropic) -> None: + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("client", [False], indirect=True) + def test_raw_response_results(self, client: Anthropic, respx_mock: MockRouter) -> None: + mock_results(respx_mock) response = client.beta.messages.batches.with_raw_response.results( message_batch_id="message_batch_id", ) @@ -470,9 +485,10 @@ def test_raw_response_results(self, client: Anthropic) -> None: for item in stream: assert_matches_type(BetaMessageBatchIndividualResponse, item, path=["line"]) - @parametrize - @pytest.mark.skip(reason="somehow hitting prod endpoint") - def test_streaming_response_results(self, client: Anthropic) -> None: + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("client", [False], indirect=True) + def test_streaming_response_results(self, client: Anthropic, respx_mock: MockRouter) -> None: + mock_results(respx_mock) with client.beta.messages.batches.with_streaming_response.results( message_batch_id="message_batch_id", ) as response: @@ -927,9 +943,10 @@ async def test_path_params_results(self, async_client: AsyncAnthropic) -> None: message_batch_id="", ) - @parametrize - @pytest.mark.skip(reason="somehow hitting prod endpoint") - async def test_raw_response_results(self, async_client: AsyncAnthropic) -> None: + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("async_client", [False], indirect=True) + async def test_raw_response_results(self, async_client: AsyncAnthropic, respx_mock: MockRouter) -> None: + mock_results(respx_mock) response = await async_client.beta.messages.batches.with_raw_response.results( message_batch_id="message_batch_id", ) @@ -938,3 +955,19 @@ async def test_raw_response_results(self, async_client: AsyncAnthropic) -> None: stream = await response.parse() async for item in stream: assert_matches_type(BetaMessageBatchIndividualResponse, item, path=["line"]) + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("async_client", [False], indirect=True) + async def test_streaming_response_results(self, async_client: AsyncAnthropic, respx_mock: MockRouter) -> None: + mock_results(respx_mock) + async with async_client.beta.messages.batches.with_streaming_response.results( + message_batch_id="message_batch_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + stream = await response.parse() + async for item in stream: + assert_matches_type(BetaMessageBatchIndividualResponse, item, path=["item"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/beta/organization/test_spend_limits.py b/tests/api_resources/beta/organization/test_spend_limits.py index 1d5b186c3..4e6912a13 100644 --- a/tests/api_resources/beta/organization/test_spend_limits.py +++ b/tests/api_resources/beta/organization/test_spend_limits.py @@ -150,6 +150,7 @@ def test_method_set_with_all_params(self, client: Anthropic) -> None: "user_id": "user_01WCz1FkmYMm4gnmykNKUu3Q", }, period="monthly", + betas=["message-batches-2024-09-24"], ) assert_matches_type(BetaSpendLimit, spend_limit, path=["response"]) @@ -322,6 +323,7 @@ async def test_method_set_with_all_params(self, async_client: AsyncAnthropic) -> "user_id": "user_01WCz1FkmYMm4gnmykNKUu3Q", }, period="monthly", + betas=["message-batches-2024-09-24"], ) assert_matches_type(BetaSpendLimit, spend_limit, path=["response"]) diff --git a/tests/api_resources/beta/organization/test_workspaces.py b/tests/api_resources/beta/organization/test_workspaces.py index 62cabe22f..f4602cb2f 100644 --- a/tests/api_resources/beta/organization/test_workspaces.py +++ b/tests/api_resources/beta/organization/test_workspaces.py @@ -173,6 +173,7 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: after_id="after_id", before_id="before_id", include_archived=True, + include_default=True, limit=1, ) assert_matches_type(SyncPage[BetaWorkspace], workspace, path=["response"]) @@ -396,6 +397,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - after_id="after_id", before_id="before_id", include_archived=True, + include_default=True, limit=1, ) assert_matches_type(AsyncPage[BetaWorkspace], workspace, path=["response"]) diff --git a/tests/api_resources/beta/sessions/test_events.py b/tests/api_resources/beta/sessions/test_events.py index 393351c18..5b5e95db4 100644 --- a/tests/api_resources/beta/sessions/test_events.py +++ b/tests/api_resources/beta/sessions/test_events.py @@ -20,7 +20,6 @@ class TestEvents: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: event = client.beta.sessions.events.list( @@ -28,7 +27,6 @@ def test_method_list(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: event = client.beta.sessions.events.list( @@ -46,7 +44,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.sessions.events.with_raw_response.list( @@ -58,7 +55,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: event = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.sessions.events.with_streaming_response.list( @@ -72,7 +68,6 @@ def test_streaming_response_list(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_list(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -235,7 +230,6 @@ class TestAsyncEvents: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: event = await async_client.beta.sessions.events.list( @@ -243,7 +237,6 @@ async def test_method_list(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: event = await async_client.beta.sessions.events.list( @@ -261,7 +254,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.events.with_raw_response.list( @@ -273,7 +265,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: event = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.events.with_streaming_response.list( @@ -287,7 +278,6 @@ async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> No assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): diff --git a/tests/api_resources/beta/sessions/test_resources.py b/tests/api_resources/beta/sessions/test_resources.py index 10a209b37..a4aa29df7 100644 --- a/tests/api_resources/beta/sessions/test_resources.py +++ b/tests/api_resources/beta/sessions/test_resources.py @@ -22,7 +22,6 @@ class TestResources: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_retrieve(self, client: Anthropic) -> None: resource = client.beta.sessions.resources.retrieve( @@ -31,7 +30,6 @@ def test_method_retrieve(self, client: Anthropic) -> None: ) assert_matches_type(ResourceRetrieveResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: resource = client.beta.sessions.resources.retrieve( @@ -42,7 +40,6 @@ def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(ResourceRetrieveResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_raw_response_retrieve(self, client: Anthropic) -> None: response = client.beta.sessions.resources.with_raw_response.retrieve( @@ -55,7 +52,6 @@ def test_raw_response_retrieve(self, client: Anthropic) -> None: resource = response.parse() assert_matches_type(ResourceRetrieveResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_streaming_response_retrieve(self, client: Anthropic) -> None: with client.beta.sessions.resources.with_streaming_response.retrieve( @@ -70,7 +66,6 @@ def test_streaming_response_retrieve(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_path_params_retrieve(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -85,7 +80,6 @@ def test_path_params_retrieve(self, client: Anthropic) -> None: session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_update(self, client: Anthropic) -> None: resource = client.beta.sessions.resources.update( @@ -95,7 +89,6 @@ def test_method_update(self, client: Anthropic) -> None: ) assert_matches_type(ResourceUpdateResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_update_with_all_params(self, client: Anthropic) -> None: resource = client.beta.sessions.resources.update( @@ -107,7 +100,6 @@ def test_method_update_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(ResourceUpdateResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_raw_response_update(self, client: Anthropic) -> None: response = client.beta.sessions.resources.with_raw_response.update( @@ -121,7 +113,6 @@ def test_raw_response_update(self, client: Anthropic) -> None: resource = response.parse() assert_matches_type(ResourceUpdateResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_streaming_response_update(self, client: Anthropic) -> None: with client.beta.sessions.resources.with_streaming_response.update( @@ -137,7 +128,6 @@ def test_streaming_response_update(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_path_params_update(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -154,7 +144,6 @@ def test_path_params_update(self, client: Anthropic) -> None: authorization_token="ghp_exampletoken", ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_list(self, client: Anthropic) -> None: resource = client.beta.sessions.resources.list( @@ -162,7 +151,6 @@ def test_method_list(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionResource], resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: resource = client.beta.sessions.resources.list( @@ -174,7 +162,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionResource], resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.sessions.resources.with_raw_response.list( @@ -186,7 +173,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: resource = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionResource], resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.sessions.resources.with_streaming_response.list( @@ -200,7 +186,6 @@ def test_streaming_response_list(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_path_params_list(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -208,7 +193,6 @@ def test_path_params_list(self, client: Anthropic) -> None: session_id="", ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_delete(self, client: Anthropic) -> None: resource = client.beta.sessions.resources.delete( @@ -217,7 +201,6 @@ def test_method_delete(self, client: Anthropic) -> None: ) assert_matches_type(BetaManagedAgentsDeleteSessionResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_delete_with_all_params(self, client: Anthropic) -> None: resource = client.beta.sessions.resources.delete( @@ -228,7 +211,6 @@ def test_method_delete_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(BetaManagedAgentsDeleteSessionResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_raw_response_delete(self, client: Anthropic) -> None: response = client.beta.sessions.resources.with_raw_response.delete( @@ -241,7 +223,6 @@ def test_raw_response_delete(self, client: Anthropic) -> None: resource = response.parse() assert_matches_type(BetaManagedAgentsDeleteSessionResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_streaming_response_delete(self, client: Anthropic) -> None: with client.beta.sessions.resources.with_streaming_response.delete( @@ -256,7 +237,6 @@ def test_streaming_response_delete(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_path_params_delete(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -271,7 +251,6 @@ def test_path_params_delete(self, client: Anthropic) -> None: session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_add(self, client: Anthropic) -> None: resource = client.beta.sessions.resources.add( @@ -281,7 +260,6 @@ def test_method_add(self, client: Anthropic) -> None: ) assert_matches_type(BetaManagedAgentsFileResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_add_with_all_params(self, client: Anthropic) -> None: resource = client.beta.sessions.resources.add( @@ -294,7 +272,6 @@ def test_method_add_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(BetaManagedAgentsFileResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_raw_response_add(self, client: Anthropic) -> None: response = client.beta.sessions.resources.with_raw_response.add( @@ -308,7 +285,6 @@ def test_raw_response_add(self, client: Anthropic) -> None: resource = response.parse() assert_matches_type(BetaManagedAgentsFileResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_streaming_response_add(self, client: Anthropic) -> None: with client.beta.sessions.resources.with_streaming_response.add( @@ -324,7 +300,6 @@ def test_streaming_response_add(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_path_params_add(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -340,7 +315,6 @@ class TestAsyncResources: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: resource = await async_client.beta.sessions.resources.retrieve( @@ -349,7 +323,6 @@ async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(ResourceRetrieveResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropic) -> None: resource = await async_client.beta.sessions.resources.retrieve( @@ -360,7 +333,6 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropi ) assert_matches_type(ResourceRetrieveResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.resources.with_raw_response.retrieve( @@ -373,7 +345,6 @@ async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None resource = await response.parse() assert_matches_type(ResourceRetrieveResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.resources.with_streaming_response.retrieve( @@ -388,7 +359,6 @@ async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) - assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -403,7 +373,6 @@ async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_update(self, async_client: AsyncAnthropic) -> None: resource = await async_client.beta.sessions.resources.update( @@ -413,7 +382,6 @@ async def test_method_update(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(ResourceUpdateResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_update_with_all_params(self, async_client: AsyncAnthropic) -> None: resource = await async_client.beta.sessions.resources.update( @@ -425,7 +393,6 @@ async def test_method_update_with_all_params(self, async_client: AsyncAnthropic) ) assert_matches_type(ResourceUpdateResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_raw_response_update(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.resources.with_raw_response.update( @@ -439,7 +406,6 @@ async def test_raw_response_update(self, async_client: AsyncAnthropic) -> None: resource = await response.parse() assert_matches_type(ResourceUpdateResponse, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_streaming_response_update(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.resources.with_streaming_response.update( @@ -455,7 +421,6 @@ async def test_streaming_response_update(self, async_client: AsyncAnthropic) -> assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -472,7 +437,6 @@ async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: authorization_token="ghp_exampletoken", ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: resource = await async_client.beta.sessions.resources.list( @@ -480,7 +444,6 @@ async def test_method_list(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionResource], resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: resource = await async_client.beta.sessions.resources.list( @@ -492,7 +455,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionResource], resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.resources.with_raw_response.list( @@ -504,7 +466,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: resource = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionResource], resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.resources.with_streaming_response.list( @@ -518,7 +479,6 @@ async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> No assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -526,7 +486,6 @@ async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: session_id="", ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_delete(self, async_client: AsyncAnthropic) -> None: resource = await async_client.beta.sessions.resources.delete( @@ -535,7 +494,6 @@ async def test_method_delete(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(BetaManagedAgentsDeleteSessionResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_delete_with_all_params(self, async_client: AsyncAnthropic) -> None: resource = await async_client.beta.sessions.resources.delete( @@ -546,7 +504,6 @@ async def test_method_delete_with_all_params(self, async_client: AsyncAnthropic) ) assert_matches_type(BetaManagedAgentsDeleteSessionResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_raw_response_delete(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.resources.with_raw_response.delete( @@ -559,7 +516,6 @@ async def test_raw_response_delete(self, async_client: AsyncAnthropic) -> None: resource = await response.parse() assert_matches_type(BetaManagedAgentsDeleteSessionResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_streaming_response_delete(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.resources.with_streaming_response.delete( @@ -574,7 +530,6 @@ async def test_streaming_response_delete(self, async_client: AsyncAnthropic) -> assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_path_params_delete(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -589,7 +544,6 @@ async def test_path_params_delete(self, async_client: AsyncAnthropic) -> None: session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_add(self, async_client: AsyncAnthropic) -> None: resource = await async_client.beta.sessions.resources.add( @@ -599,7 +553,6 @@ async def test_method_add(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(BetaManagedAgentsFileResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_add_with_all_params(self, async_client: AsyncAnthropic) -> None: resource = await async_client.beta.sessions.resources.add( @@ -612,7 +565,6 @@ async def test_method_add_with_all_params(self, async_client: AsyncAnthropic) -> ) assert_matches_type(BetaManagedAgentsFileResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_raw_response_add(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.resources.with_raw_response.add( @@ -626,7 +578,6 @@ async def test_raw_response_add(self, async_client: AsyncAnthropic) -> None: resource = await response.parse() assert_matches_type(BetaManagedAgentsFileResource, resource, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_streaming_response_add(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.resources.with_streaming_response.add( @@ -642,7 +593,6 @@ async def test_streaming_response_add(self, async_client: AsyncAnthropic) -> Non assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_path_params_add(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): diff --git a/tests/api_resources/beta/sessions/test_threads.py b/tests/api_resources/beta/sessions/test_threads.py index ed307548e..dae316fee 100644 --- a/tests/api_resources/beta/sessions/test_threads.py +++ b/tests/api_resources/beta/sessions/test_threads.py @@ -19,7 +19,7 @@ class TestThreads: @parametrize def test_method_retrieve(self, client: Anthropic) -> None: thread = client.beta.sessions.threads.retrieve( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) assert_matches_type(BetaManagedAgentsSessionThread, thread, path=["response"]) @@ -27,7 +27,7 @@ def test_method_retrieve(self, client: Anthropic) -> None: @parametrize def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: thread = client.beta.sessions.threads.retrieve( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -37,7 +37,7 @@ def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: @parametrize def test_raw_response_retrieve(self, client: Anthropic) -> None: response = client.beta.sessions.threads.with_raw_response.retrieve( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) @@ -49,7 +49,7 @@ def test_raw_response_retrieve(self, client: Anthropic) -> None: @parametrize def test_streaming_response_retrieve(self, client: Anthropic) -> None: with client.beta.sessions.threads.with_streaming_response.retrieve( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) as response: assert not response.is_closed @@ -64,7 +64,7 @@ def test_streaming_response_retrieve(self, client: Anthropic) -> None: def test_path_params_retrieve(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): client.beta.sessions.threads.with_raw_response.retrieve( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="", ) @@ -74,7 +74,6 @@ def test_path_params_retrieve(self, client: Anthropic) -> None: session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: thread = client.beta.sessions.threads.list( @@ -82,7 +81,6 @@ def test_method_list(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionThread], thread, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: thread = client.beta.sessions.threads.list( @@ -94,7 +92,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionThread], thread, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.sessions.threads.with_raw_response.list( @@ -106,7 +103,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: thread = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionThread], thread, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.sessions.threads.with_streaming_response.list( @@ -120,7 +116,6 @@ def test_streaming_response_list(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_list(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -131,7 +126,7 @@ def test_path_params_list(self, client: Anthropic) -> None: @parametrize def test_method_archive(self, client: Anthropic) -> None: thread = client.beta.sessions.threads.archive( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) assert_matches_type(BetaManagedAgentsSessionThread, thread, path=["response"]) @@ -139,7 +134,7 @@ def test_method_archive(self, client: Anthropic) -> None: @parametrize def test_method_archive_with_all_params(self, client: Anthropic) -> None: thread = client.beta.sessions.threads.archive( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -149,7 +144,7 @@ def test_method_archive_with_all_params(self, client: Anthropic) -> None: @parametrize def test_raw_response_archive(self, client: Anthropic) -> None: response = client.beta.sessions.threads.with_raw_response.archive( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) @@ -161,7 +156,7 @@ def test_raw_response_archive(self, client: Anthropic) -> None: @parametrize def test_streaming_response_archive(self, client: Anthropic) -> None: with client.beta.sessions.threads.with_streaming_response.archive( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) as response: assert not response.is_closed @@ -176,7 +171,7 @@ def test_streaming_response_archive(self, client: Anthropic) -> None: def test_path_params_archive(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): client.beta.sessions.threads.with_raw_response.archive( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="", ) @@ -195,7 +190,7 @@ class TestAsyncThreads: @parametrize async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: thread = await async_client.beta.sessions.threads.retrieve( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) assert_matches_type(BetaManagedAgentsSessionThread, thread, path=["response"]) @@ -203,7 +198,7 @@ async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropic) -> None: thread = await async_client.beta.sessions.threads.retrieve( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -213,7 +208,7 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropi @parametrize async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.threads.with_raw_response.retrieve( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) @@ -225,7 +220,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.threads.with_streaming_response.retrieve( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) as response: assert not response.is_closed @@ -240,7 +235,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) - async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): await async_client.beta.sessions.threads.with_raw_response.retrieve( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="", ) @@ -250,7 +245,6 @@ async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: thread = await async_client.beta.sessions.threads.list( @@ -258,7 +252,6 @@ async def test_method_list(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionThread], thread, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: thread = await async_client.beta.sessions.threads.list( @@ -270,7 +263,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionThread], thread, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.threads.with_raw_response.list( @@ -282,7 +274,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: thread = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionThread], thread, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.threads.with_streaming_response.list( @@ -296,7 +287,6 @@ async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> No assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): @@ -307,7 +297,7 @@ async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_archive(self, async_client: AsyncAnthropic) -> None: thread = await async_client.beta.sessions.threads.archive( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) assert_matches_type(BetaManagedAgentsSessionThread, thread, path=["response"]) @@ -315,7 +305,7 @@ async def test_method_archive(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_archive_with_all_params(self, async_client: AsyncAnthropic) -> None: thread = await async_client.beta.sessions.threads.archive( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -325,7 +315,7 @@ async def test_method_archive_with_all_params(self, async_client: AsyncAnthropic @parametrize async def test_raw_response_archive(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.threads.with_raw_response.archive( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) @@ -337,7 +327,7 @@ async def test_raw_response_archive(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_streaming_response_archive(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.threads.with_streaming_response.archive( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) as response: assert not response.is_closed @@ -352,7 +342,7 @@ async def test_streaming_response_archive(self, async_client: AsyncAnthropic) -> async def test_path_params_archive(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): await async_client.beta.sessions.threads.with_raw_response.archive( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="", ) diff --git a/tests/api_resources/beta/sessions/threads/test_events.py b/tests/api_resources/beta/sessions/threads/test_events.py index dd44f64e4..a63310d18 100644 --- a/tests/api_resources/beta/sessions/threads/test_events.py +++ b/tests/api_resources/beta/sessions/threads/test_events.py @@ -16,20 +16,18 @@ class TestEvents: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: event = client.beta.sessions.threads.events.list( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: event = client.beta.sessions.threads.events.list( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", limit=0, page="page", @@ -38,11 +36,10 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.sessions.threads.events.with_raw_response.list( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) @@ -51,11 +48,10 @@ def test_raw_response_list(self, client: Anthropic) -> None: event = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.sessions.threads.events.with_streaming_response.list( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) as response: assert not response.is_closed @@ -66,12 +62,11 @@ def test_streaming_response_list(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_list(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): client.beta.sessions.threads.events.with_raw_response.list( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="", ) @@ -84,7 +79,7 @@ def test_path_params_list(self, client: Anthropic) -> None: @parametrize def test_method_stream(self, client: Anthropic) -> None: event_stream = client.beta.sessions.threads.events.stream( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) event_stream.response.close() @@ -92,7 +87,7 @@ def test_method_stream(self, client: Anthropic) -> None: @parametrize def test_method_stream_with_all_params(self, client: Anthropic) -> None: event_stream = client.beta.sessions.threads.events.stream( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", event_deltas=["agent.message"], betas=["message-batches-2024-09-24"], @@ -103,7 +98,7 @@ def test_method_stream_with_all_params(self, client: Anthropic) -> None: @parametrize def test_raw_response_stream(self, client: Anthropic) -> None: response = client.beta.sessions.threads.events.with_raw_response.stream( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) @@ -114,7 +109,7 @@ def test_raw_response_stream(self, client: Anthropic) -> None: @parametrize def test_streaming_response_stream(self, client: Anthropic) -> None: with client.beta.sessions.threads.events.with_streaming_response.stream( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) as response: assert not response.is_closed @@ -129,7 +124,7 @@ def test_streaming_response_stream(self, client: Anthropic) -> None: def test_path_params_stream(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): client.beta.sessions.threads.events.with_raw_response.stream( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="", ) @@ -145,20 +140,18 @@ class TestAsyncEvents: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: event = await async_client.beta.sessions.threads.events.list( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: event = await async_client.beta.sessions.threads.events.list( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", limit=0, page="page", @@ -167,11 +160,10 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.threads.events.with_raw_response.list( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) @@ -180,11 +172,10 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: event = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsSessionEvent], event, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.threads.events.with_streaming_response.list( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) as response: assert not response.is_closed @@ -195,12 +186,11 @@ async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> No assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): await async_client.beta.sessions.threads.events.with_raw_response.list( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="", ) @@ -213,7 +203,7 @@ async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_stream(self, async_client: AsyncAnthropic) -> None: event_stream = await async_client.beta.sessions.threads.events.stream( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) await event_stream.response.aclose() @@ -221,7 +211,7 @@ async def test_method_stream(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_stream_with_all_params(self, async_client: AsyncAnthropic) -> None: event_stream = await async_client.beta.sessions.threads.events.stream( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", event_deltas=["agent.message"], betas=["message-batches-2024-09-24"], @@ -232,7 +222,7 @@ async def test_method_stream_with_all_params(self, async_client: AsyncAnthropic) @parametrize async def test_raw_response_stream(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.threads.events.with_raw_response.stream( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) @@ -243,7 +233,7 @@ async def test_raw_response_stream(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_streaming_response_stream(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.threads.events.with_streaming_response.stream( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="sesn_011CZkZAtmR3yMPDzynEDxu7", ) as response: assert not response.is_closed @@ -258,7 +248,7 @@ async def test_streaming_response_stream(self, async_client: AsyncAnthropic) -> async def test_path_params_stream(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `session_id` but received ''"): await async_client.beta.sessions.threads.events.with_raw_response.stream( - thread_id="sthr_011CZkZVWa6oIjw0rgXZpnBt", + thread_id="sthr_011CZkZVWa6oJjw1rgXZpnBt", session_id="", ) diff --git a/tests/api_resources/beta/test_agents.py b/tests/api_resources/beta/test_agents.py index 73e33e01f..c05faf56f 100644 --- a/tests/api_resources/beta/test_agents.py +++ b/tests/api_resources/beta/test_agents.py @@ -101,7 +101,6 @@ def test_streaming_response_create(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_retrieve(self, client: Anthropic) -> None: agent = client.beta.agents.retrieve( @@ -109,7 +108,6 @@ def test_method_retrieve(self, client: Anthropic) -> None: ) assert_matches_type(BetaManagedAgentsAgent, agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: agent = client.beta.agents.retrieve( @@ -120,7 +118,6 @@ def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(BetaManagedAgentsAgent, agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_retrieve(self, client: Anthropic) -> None: response = client.beta.agents.with_raw_response.retrieve( @@ -132,7 +129,6 @@ def test_raw_response_retrieve(self, client: Anthropic) -> None: agent = response.parse() assert_matches_type(BetaManagedAgentsAgent, agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_retrieve(self, client: Anthropic) -> None: with client.beta.agents.with_streaming_response.retrieve( @@ -146,7 +142,6 @@ def test_streaming_response_retrieve(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_retrieve(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): @@ -247,13 +242,11 @@ def test_path_params_update(self, client: Anthropic) -> None: agent_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: agent = client.beta.agents.list() assert_matches_type(SyncPageCursor[BetaManagedAgentsAgent], agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: agent = client.beta.agents.list( @@ -267,7 +260,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsAgent], agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.agents.with_raw_response.list() @@ -277,7 +269,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: agent = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsAgent], agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.agents.with_streaming_response.list() as response: @@ -424,7 +415,6 @@ async def test_streaming_response_create(self, async_client: AsyncAnthropic) -> assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: agent = await async_client.beta.agents.retrieve( @@ -432,7 +422,6 @@ async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(BetaManagedAgentsAgent, agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropic) -> None: agent = await async_client.beta.agents.retrieve( @@ -443,7 +432,6 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropi ) assert_matches_type(BetaManagedAgentsAgent, agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.agents.with_raw_response.retrieve( @@ -455,7 +443,6 @@ async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None agent = await response.parse() assert_matches_type(BetaManagedAgentsAgent, agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.agents.with_streaming_response.retrieve( @@ -469,7 +456,6 @@ async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) - assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): @@ -570,13 +556,11 @@ async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: agent_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: agent = await async_client.beta.agents.list() assert_matches_type(AsyncPageCursor[BetaManagedAgentsAgent], agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: agent = await async_client.beta.agents.list( @@ -590,7 +574,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsAgent], agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.agents.with_raw_response.list() @@ -600,7 +583,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: agent = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsAgent], agent, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.agents.with_streaming_response.list() as response: diff --git a/tests/api_resources/beta/test_deployment_runs.py b/tests/api_resources/beta/test_deployment_runs.py index 84e4b9037..0e148ccf8 100644 --- a/tests/api_resources/beta/test_deployment_runs.py +++ b/tests/api_resources/beta/test_deployment_runs.py @@ -19,7 +19,6 @@ class TestDeploymentRuns: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_retrieve(self, client: Anthropic) -> None: deployment_run = client.beta.deployment_runs.retrieve( @@ -27,7 +26,6 @@ def test_method_retrieve(self, client: Anthropic) -> None: ) assert_matches_type(BetaManagedAgentsDeploymentRun, deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: deployment_run = client.beta.deployment_runs.retrieve( @@ -37,7 +35,6 @@ def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(BetaManagedAgentsDeploymentRun, deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_retrieve(self, client: Anthropic) -> None: response = client.beta.deployment_runs.with_raw_response.retrieve( @@ -49,7 +46,6 @@ def test_raw_response_retrieve(self, client: Anthropic) -> None: deployment_run = response.parse() assert_matches_type(BetaManagedAgentsDeploymentRun, deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_retrieve(self, client: Anthropic) -> None: with client.beta.deployment_runs.with_streaming_response.retrieve( @@ -63,7 +59,6 @@ def test_streaming_response_retrieve(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_retrieve(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_run_id` but received ''"): @@ -71,13 +66,11 @@ def test_path_params_retrieve(self, client: Anthropic) -> None: deployment_run_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: deployment_run = client.beta.deployment_runs.list() assert_matches_type(SyncPageCursor[BetaManagedAgentsDeploymentRun], deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: deployment_run = client.beta.deployment_runs.list( @@ -95,7 +88,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsDeploymentRun], deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.deployment_runs.with_raw_response.list() @@ -105,7 +97,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: deployment_run = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsDeploymentRun], deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.deployment_runs.with_streaming_response.list() as response: @@ -123,7 +114,6 @@ class TestAsyncDeploymentRuns: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: deployment_run = await async_client.beta.deployment_runs.retrieve( @@ -131,7 +121,6 @@ async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(BetaManagedAgentsDeploymentRun, deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropic) -> None: deployment_run = await async_client.beta.deployment_runs.retrieve( @@ -141,7 +130,6 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropi ) assert_matches_type(BetaManagedAgentsDeploymentRun, deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.deployment_runs.with_raw_response.retrieve( @@ -153,7 +141,6 @@ async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None deployment_run = await response.parse() assert_matches_type(BetaManagedAgentsDeploymentRun, deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.deployment_runs.with_streaming_response.retrieve( @@ -167,7 +154,6 @@ async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) - assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_run_id` but received ''"): @@ -175,13 +161,11 @@ async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: deployment_run_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: deployment_run = await async_client.beta.deployment_runs.list() assert_matches_type(AsyncPageCursor[BetaManagedAgentsDeploymentRun], deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: deployment_run = await async_client.beta.deployment_runs.list( @@ -199,7 +183,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsDeploymentRun], deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.deployment_runs.with_raw_response.list() @@ -209,7 +192,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: deployment_run = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsDeploymentRun], deployment_run, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.deployment_runs.with_streaming_response.list() as response: diff --git a/tests/api_resources/beta/test_deployments.py b/tests/api_resources/beta/test_deployments.py index 555dfea3b..9812f94cc 100644 --- a/tests/api_resources/beta/test_deployments.py +++ b/tests/api_resources/beta/test_deployments.py @@ -134,7 +134,6 @@ def test_streaming_response_create(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_retrieve(self, client: Anthropic) -> None: deployment = client.beta.deployments.retrieve( @@ -142,7 +141,6 @@ def test_method_retrieve(self, client: Anthropic) -> None: ) assert_matches_type(BetaManagedAgentsDeployment, deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: deployment = client.beta.deployments.retrieve( @@ -152,7 +150,6 @@ def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(BetaManagedAgentsDeployment, deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_retrieve(self, client: Anthropic) -> None: response = client.beta.deployments.with_raw_response.retrieve( @@ -164,7 +161,6 @@ def test_raw_response_retrieve(self, client: Anthropic) -> None: deployment = response.parse() assert_matches_type(BetaManagedAgentsDeployment, deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_retrieve(self, client: Anthropic) -> None: with client.beta.deployments.with_streaming_response.retrieve( @@ -178,7 +174,6 @@ def test_streaming_response_retrieve(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_retrieve(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): @@ -269,13 +264,11 @@ def test_path_params_update(self, client: Anthropic) -> None: deployment_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: deployment = client.beta.deployments.list() assert_matches_type(SyncPageCursor[BetaManagedAgentsDeployment], deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: deployment = client.beta.deployments.list( @@ -291,7 +284,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsDeployment], deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.deployments.with_raw_response.list() @@ -301,7 +293,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: deployment = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsDeployment], deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.deployments.with_streaming_response.list() as response: @@ -621,7 +612,6 @@ async def test_streaming_response_create(self, async_client: AsyncAnthropic) -> assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: deployment = await async_client.beta.deployments.retrieve( @@ -629,7 +619,6 @@ async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(BetaManagedAgentsDeployment, deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropic) -> None: deployment = await async_client.beta.deployments.retrieve( @@ -639,7 +628,6 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropi ) assert_matches_type(BetaManagedAgentsDeployment, deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.deployments.with_raw_response.retrieve( @@ -651,7 +639,6 @@ async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None deployment = await response.parse() assert_matches_type(BetaManagedAgentsDeployment, deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.deployments.with_streaming_response.retrieve( @@ -665,7 +652,6 @@ async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) - assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): @@ -756,13 +742,11 @@ async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: deployment_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: deployment = await async_client.beta.deployments.list() assert_matches_type(AsyncPageCursor[BetaManagedAgentsDeployment], deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: deployment = await async_client.beta.deployments.list( @@ -778,7 +762,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsDeployment], deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.deployments.with_raw_response.list() @@ -788,7 +771,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: deployment = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsDeployment], deployment, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.deployments.with_streaming_response.list() as response: diff --git a/tests/api_resources/beta/test_memory_stores.py b/tests/api_resources/beta/test_memory_stores.py index d65277cc7..def202d8e 100644 --- a/tests/api_resources/beta/test_memory_stores.py +++ b/tests/api_resources/beta/test_memory_stores.py @@ -159,13 +159,11 @@ def test_path_params_update(self, client: Anthropic) -> None: memory_store_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: memory_store = client.beta.memory_stores.list() assert_matches_type(SyncPageCursor[BetaManagedAgentsMemoryStore], memory_store, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: memory_store = client.beta.memory_stores.list( @@ -179,7 +177,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsMemoryStore], memory_store, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.memory_stores.with_raw_response.list() @@ -189,7 +186,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: memory_store = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsMemoryStore], memory_store, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.memory_stores.with_streaming_response.list() as response: @@ -440,13 +436,11 @@ async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: memory_store_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: memory_store = await async_client.beta.memory_stores.list() assert_matches_type(AsyncPageCursor[BetaManagedAgentsMemoryStore], memory_store, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: memory_store = await async_client.beta.memory_stores.list( @@ -460,7 +454,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsMemoryStore], memory_store, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.memory_stores.with_raw_response.list() @@ -470,7 +463,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: memory_store = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsMemoryStore], memory_store, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.memory_stores.with_streaming_response.list() as response: diff --git a/tests/api_resources/beta/test_models.py b/tests/api_resources/beta/test_models.py index 77b57741a..c9b773967 100644 --- a/tests/api_resources/beta/test_models.py +++ b/tests/api_resources/beta/test_models.py @@ -73,6 +73,7 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: model = client.beta.models.list( after_id="after_id", before_id="before_id", + lifecycle=["active"], limit=1, betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -162,6 +163,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - model = await async_client.beta.models.list( after_id="after_id", before_id="before_id", + lifecycle=["active"], limit=1, betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", diff --git a/tests/api_resources/beta/test_sessions.py b/tests/api_resources/beta/test_sessions.py index 37020eea0..31f193115 100644 --- a/tests/api_resources/beta/test_sessions.py +++ b/tests/api_resources/beta/test_sessions.py @@ -222,13 +222,11 @@ def test_path_params_update(self, client: Anthropic) -> None: session_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: session = client.beta.sessions.list() assert_matches_type(SyncBidirectionalPageCursor[BetaManagedAgentsSession], session, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: session = client.beta.sessions.list( @@ -250,7 +248,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncBidirectionalPageCursor[BetaManagedAgentsSession], session, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.sessions.with_raw_response.list() @@ -260,7 +257,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: session = response.parse() assert_matches_type(SyncBidirectionalPageCursor[BetaManagedAgentsSession], session, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.sessions.with_streaming_response.list() as response: @@ -574,13 +570,11 @@ async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: session_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: session = await async_client.beta.sessions.list() assert_matches_type(AsyncBidirectionalPageCursor[BetaManagedAgentsSession], session, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: session = await async_client.beta.sessions.list( @@ -602,7 +596,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncBidirectionalPageCursor[BetaManagedAgentsSession], session, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.sessions.with_raw_response.list() @@ -612,7 +605,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: session = await response.parse() assert_matches_type(AsyncBidirectionalPageCursor[BetaManagedAgentsSession], session, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.sessions.with_streaming_response.list() as response: diff --git a/tests/api_resources/beta/test_tunnels.py b/tests/api_resources/beta/test_tunnels.py index cdd7b7d60..626307d79 100644 --- a/tests/api_resources/beta/test_tunnels.py +++ b/tests/api_resources/beta/test_tunnels.py @@ -53,7 +53,6 @@ def test_streaming_response_create(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_retrieve(self, client: Anthropic) -> None: tunnel = client.beta.tunnels.retrieve( @@ -61,7 +60,6 @@ def test_method_retrieve(self, client: Anthropic) -> None: ) assert_matches_type(BetaTunnel, tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: tunnel = client.beta.tunnels.retrieve( @@ -71,7 +69,6 @@ def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(BetaTunnel, tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_retrieve(self, client: Anthropic) -> None: response = client.beta.tunnels.with_raw_response.retrieve( @@ -83,7 +80,6 @@ def test_raw_response_retrieve(self, client: Anthropic) -> None: tunnel = response.parse() assert_matches_type(BetaTunnel, tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_retrieve(self, client: Anthropic) -> None: with client.beta.tunnels.with_streaming_response.retrieve( @@ -97,7 +93,6 @@ def test_streaming_response_retrieve(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_retrieve(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `tunnel_id` but received ''"): @@ -105,13 +100,11 @@ def test_path_params_retrieve(self, client: Anthropic) -> None: tunnel_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: tunnel = client.beta.tunnels.list() assert_matches_type(SyncPageCursor[BetaTunnel], tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: tunnel = client.beta.tunnels.list( @@ -123,7 +116,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaTunnel], tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.tunnels.with_raw_response.list() @@ -133,7 +125,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: tunnel = response.parse() assert_matches_type(SyncPageCursor[BetaTunnel], tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.tunnels.with_streaming_response.list() as response: @@ -327,7 +318,6 @@ async def test_streaming_response_create(self, async_client: AsyncAnthropic) -> assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: tunnel = await async_client.beta.tunnels.retrieve( @@ -335,7 +325,6 @@ async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(BetaTunnel, tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropic) -> None: tunnel = await async_client.beta.tunnels.retrieve( @@ -345,7 +334,6 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropi ) assert_matches_type(BetaTunnel, tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.tunnels.with_raw_response.retrieve( @@ -357,7 +345,6 @@ async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None tunnel = await response.parse() assert_matches_type(BetaTunnel, tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.tunnels.with_streaming_response.retrieve( @@ -371,7 +358,6 @@ async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) - assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `tunnel_id` but received ''"): @@ -379,13 +365,11 @@ async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: tunnel_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: tunnel = await async_client.beta.tunnels.list() assert_matches_type(AsyncPageCursor[BetaTunnel], tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: tunnel = await async_client.beta.tunnels.list( @@ -397,7 +381,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaTunnel], tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.tunnels.with_raw_response.list() @@ -407,7 +390,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: tunnel = await response.parse() assert_matches_type(AsyncPageCursor[BetaTunnel], tunnel, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.tunnels.with_streaming_response.list() as response: diff --git a/tests/api_resources/beta/test_vaults.py b/tests/api_resources/beta/test_vaults.py index b06160f3c..f15403da9 100644 --- a/tests/api_resources/beta/test_vaults.py +++ b/tests/api_resources/beta/test_vaults.py @@ -156,13 +156,11 @@ def test_path_params_update(self, client: Anthropic) -> None: vault_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: vault = client.beta.vaults.list() assert_matches_type(SyncPageCursor[BetaManagedAgentsVault], vault, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: vault = client.beta.vaults.list( @@ -174,7 +172,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsVault], vault, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.vaults.with_raw_response.list() @@ -184,7 +181,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: vault = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsVault], vault, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.vaults.with_streaming_response.list() as response: @@ -433,13 +429,11 @@ async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: vault_id="", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: vault = await async_client.beta.vaults.list() assert_matches_type(AsyncPageCursor[BetaManagedAgentsVault], vault, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: vault = await async_client.beta.vaults.list( @@ -451,7 +445,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsVault], vault, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.vaults.with_raw_response.list() @@ -461,7 +454,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: vault = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsVault], vault, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.vaults.with_streaming_response.list() as response: diff --git a/tests/api_resources/beta/tunnels/test_certificates.py b/tests/api_resources/beta/tunnels/test_certificates.py index 9d053169a..1ae2d05e6 100644 --- a/tests/api_resources/beta/tunnels/test_certificates.py +++ b/tests/api_resources/beta/tunnels/test_certificates.py @@ -68,7 +68,6 @@ def test_path_params_create(self, client: Anthropic) -> None: ca_certificate_pem="ca_certificate_pem", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_retrieve(self, client: Anthropic) -> None: certificate = client.beta.tunnels.certificates.retrieve( @@ -77,7 +76,6 @@ def test_method_retrieve(self, client: Anthropic) -> None: ) assert_matches_type(BetaTunnelCertificate, certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: certificate = client.beta.tunnels.certificates.retrieve( @@ -88,7 +86,6 @@ def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(BetaTunnelCertificate, certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_retrieve(self, client: Anthropic) -> None: response = client.beta.tunnels.certificates.with_raw_response.retrieve( @@ -101,7 +98,6 @@ def test_raw_response_retrieve(self, client: Anthropic) -> None: certificate = response.parse() assert_matches_type(BetaTunnelCertificate, certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_retrieve(self, client: Anthropic) -> None: with client.beta.tunnels.certificates.with_streaming_response.retrieve( @@ -116,7 +112,6 @@ def test_streaming_response_retrieve(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_retrieve(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `tunnel_id` but received ''"): @@ -131,7 +126,6 @@ def test_path_params_retrieve(self, client: Anthropic) -> None: tunnel_id="tunnel_id", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: certificate = client.beta.tunnels.certificates.list( @@ -139,7 +133,6 @@ def test_method_list(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaTunnelCertificate], certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: certificate = client.beta.tunnels.certificates.list( @@ -152,7 +145,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaTunnelCertificate], certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.tunnels.certificates.with_raw_response.list( @@ -164,7 +156,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: certificate = response.parse() assert_matches_type(SyncPageCursor[BetaTunnelCertificate], certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.tunnels.certificates.with_streaming_response.list( @@ -178,7 +169,6 @@ def test_streaming_response_list(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_list(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `tunnel_id` but received ''"): @@ -302,7 +292,6 @@ async def test_path_params_create(self, async_client: AsyncAnthropic) -> None: ca_certificate_pem="ca_certificate_pem", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: certificate = await async_client.beta.tunnels.certificates.retrieve( @@ -311,7 +300,6 @@ async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(BetaTunnelCertificate, certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropic) -> None: certificate = await async_client.beta.tunnels.certificates.retrieve( @@ -322,7 +310,6 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropi ) assert_matches_type(BetaTunnelCertificate, certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.tunnels.certificates.with_raw_response.retrieve( @@ -335,7 +322,6 @@ async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None certificate = await response.parse() assert_matches_type(BetaTunnelCertificate, certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.tunnels.certificates.with_streaming_response.retrieve( @@ -350,7 +336,6 @@ async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) - assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `tunnel_id` but received ''"): @@ -365,7 +350,6 @@ async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: tunnel_id="tunnel_id", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: certificate = await async_client.beta.tunnels.certificates.list( @@ -373,7 +357,6 @@ async def test_method_list(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(AsyncPageCursor[BetaTunnelCertificate], certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: certificate = await async_client.beta.tunnels.certificates.list( @@ -386,7 +369,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaTunnelCertificate], certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.tunnels.certificates.with_raw_response.list( @@ -398,7 +380,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: certificate = await response.parse() assert_matches_type(AsyncPageCursor[BetaTunnelCertificate], certificate, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.tunnels.certificates.with_streaming_response.list( @@ -412,7 +393,6 @@ async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> No assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `tunnel_id` but received ''"): diff --git a/tests/api_resources/beta/vaults/test_credentials.py b/tests/api_resources/beta/vaults/test_credentials.py index af5d68115..f2f89fc0c 100644 --- a/tests/api_resources/beta/vaults/test_credentials.py +++ b/tests/api_resources/beta/vaults/test_credentials.py @@ -98,7 +98,7 @@ def test_path_params_create(self, client: Anthropic) -> None: @parametrize def test_method_retrieve(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.retrieve( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) assert_matches_type(BetaManagedAgentsCredential, credential, path=["response"]) @@ -106,7 +106,7 @@ def test_method_retrieve(self, client: Anthropic) -> None: @parametrize def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.retrieve( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -116,7 +116,7 @@ def test_method_retrieve_with_all_params(self, client: Anthropic) -> None: @parametrize def test_raw_response_retrieve(self, client: Anthropic) -> None: response = client.beta.vaults.credentials.with_raw_response.retrieve( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) @@ -128,7 +128,7 @@ def test_raw_response_retrieve(self, client: Anthropic) -> None: @parametrize def test_streaming_response_retrieve(self, client: Anthropic) -> None: with client.beta.vaults.credentials.with_streaming_response.retrieve( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) as response: assert not response.is_closed @@ -143,7 +143,7 @@ def test_streaming_response_retrieve(self, client: Anthropic) -> None: def test_path_params_retrieve(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): client.beta.vaults.credentials.with_raw_response.retrieve( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="", ) @@ -156,7 +156,7 @@ def test_path_params_retrieve(self, client: Anthropic) -> None: @parametrize def test_method_update(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.update( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) assert_matches_type(BetaManagedAgentsCredential, credential, path=["response"]) @@ -164,7 +164,7 @@ def test_method_update(self, client: Anthropic) -> None: @parametrize def test_method_update_with_all_params(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.update( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", auth={ "type": "mcp_oauth", @@ -189,7 +189,7 @@ def test_method_update_with_all_params(self, client: Anthropic) -> None: @parametrize def test_raw_response_update(self, client: Anthropic) -> None: response = client.beta.vaults.credentials.with_raw_response.update( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) @@ -201,7 +201,7 @@ def test_raw_response_update(self, client: Anthropic) -> None: @parametrize def test_streaming_response_update(self, client: Anthropic) -> None: with client.beta.vaults.credentials.with_streaming_response.update( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) as response: assert not response.is_closed @@ -216,7 +216,7 @@ def test_streaming_response_update(self, client: Anthropic) -> None: def test_path_params_update(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): client.beta.vaults.credentials.with_raw_response.update( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="", ) @@ -226,7 +226,6 @@ def test_path_params_update(self, client: Anthropic) -> None: vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.list( @@ -234,7 +233,6 @@ def test_method_list(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsCredential], credential, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_method_list_with_all_params(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.list( @@ -247,7 +245,6 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: ) assert_matches_type(SyncPageCursor[BetaManagedAgentsCredential], credential, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_raw_response_list(self, client: Anthropic) -> None: response = client.beta.vaults.credentials.with_raw_response.list( @@ -259,7 +256,6 @@ def test_raw_response_list(self, client: Anthropic) -> None: credential = response.parse() assert_matches_type(SyncPageCursor[BetaManagedAgentsCredential], credential, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_streaming_response_list(self, client: Anthropic) -> None: with client.beta.vaults.credentials.with_streaming_response.list( @@ -273,7 +269,6 @@ def test_streaming_response_list(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize def test_path_params_list(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): @@ -284,7 +279,7 @@ def test_path_params_list(self, client: Anthropic) -> None: @parametrize def test_method_delete(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.delete( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) assert_matches_type(BetaManagedAgentsDeletedCredential, credential, path=["response"]) @@ -292,7 +287,7 @@ def test_method_delete(self, client: Anthropic) -> None: @parametrize def test_method_delete_with_all_params(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.delete( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -302,7 +297,7 @@ def test_method_delete_with_all_params(self, client: Anthropic) -> None: @parametrize def test_raw_response_delete(self, client: Anthropic) -> None: response = client.beta.vaults.credentials.with_raw_response.delete( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) @@ -314,7 +309,7 @@ def test_raw_response_delete(self, client: Anthropic) -> None: @parametrize def test_streaming_response_delete(self, client: Anthropic) -> None: with client.beta.vaults.credentials.with_streaming_response.delete( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) as response: assert not response.is_closed @@ -329,7 +324,7 @@ def test_streaming_response_delete(self, client: Anthropic) -> None: def test_path_params_delete(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): client.beta.vaults.credentials.with_raw_response.delete( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="", ) @@ -342,7 +337,7 @@ def test_path_params_delete(self, client: Anthropic) -> None: @parametrize def test_method_archive(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.archive( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) assert_matches_type(BetaManagedAgentsCredential, credential, path=["response"]) @@ -350,7 +345,7 @@ def test_method_archive(self, client: Anthropic) -> None: @parametrize def test_method_archive_with_all_params(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.archive( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -360,7 +355,7 @@ def test_method_archive_with_all_params(self, client: Anthropic) -> None: @parametrize def test_raw_response_archive(self, client: Anthropic) -> None: response = client.beta.vaults.credentials.with_raw_response.archive( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) @@ -372,7 +367,7 @@ def test_raw_response_archive(self, client: Anthropic) -> None: @parametrize def test_streaming_response_archive(self, client: Anthropic) -> None: with client.beta.vaults.credentials.with_streaming_response.archive( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) as response: assert not response.is_closed @@ -387,7 +382,7 @@ def test_streaming_response_archive(self, client: Anthropic) -> None: def test_path_params_archive(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): client.beta.vaults.credentials.with_raw_response.archive( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="", ) @@ -397,31 +392,28 @@ def test_path_params_archive(self, client: Anthropic) -> None: vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_mcp_oauth_validate(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.mcp_oauth_validate( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) assert_matches_type(BetaManagedAgentsCredentialValidation, credential, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_method_mcp_oauth_validate_with_all_params(self, client: Anthropic) -> None: credential = client.beta.vaults.credentials.mcp_oauth_validate( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", ) assert_matches_type(BetaManagedAgentsCredentialValidation, credential, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_raw_response_mcp_oauth_validate(self, client: Anthropic) -> None: response = client.beta.vaults.credentials.with_raw_response.mcp_oauth_validate( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) @@ -430,11 +422,10 @@ def test_raw_response_mcp_oauth_validate(self, client: Anthropic) -> None: credential = response.parse() assert_matches_type(BetaManagedAgentsCredentialValidation, credential, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_streaming_response_mcp_oauth_validate(self, client: Anthropic) -> None: with client.beta.vaults.credentials.with_streaming_response.mcp_oauth_validate( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) as response: assert not response.is_closed @@ -445,12 +436,11 @@ def test_streaming_response_mcp_oauth_validate(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize def test_path_params_mcp_oauth_validate(self, client: Anthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): client.beta.vaults.credentials.with_raw_response.mcp_oauth_validate( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="", ) @@ -543,7 +533,7 @@ async def test_path_params_create(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.retrieve( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) assert_matches_type(BetaManagedAgentsCredential, credential, path=["response"]) @@ -551,7 +541,7 @@ async def test_method_retrieve(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.retrieve( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -561,7 +551,7 @@ async def test_method_retrieve_with_all_params(self, async_client: AsyncAnthropi @parametrize async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.vaults.credentials.with_raw_response.retrieve( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) @@ -573,7 +563,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncAnthropic) -> None @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.vaults.credentials.with_streaming_response.retrieve( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) as response: assert not response.is_closed @@ -588,7 +578,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncAnthropic) - async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): await async_client.beta.vaults.credentials.with_raw_response.retrieve( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="", ) @@ -601,7 +591,7 @@ async def test_path_params_retrieve(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_update(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.update( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) assert_matches_type(BetaManagedAgentsCredential, credential, path=["response"]) @@ -609,7 +599,7 @@ async def test_method_update(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_update_with_all_params(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.update( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", auth={ "type": "mcp_oauth", @@ -634,7 +624,7 @@ async def test_method_update_with_all_params(self, async_client: AsyncAnthropic) @parametrize async def test_raw_response_update(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.vaults.credentials.with_raw_response.update( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) @@ -646,7 +636,7 @@ async def test_raw_response_update(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_streaming_response_update(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.vaults.credentials.with_streaming_response.update( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) as response: assert not response.is_closed @@ -661,7 +651,7 @@ async def test_streaming_response_update(self, async_client: AsyncAnthropic) -> async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): await async_client.beta.vaults.credentials.with_raw_response.update( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="", ) @@ -671,7 +661,6 @@ async def test_path_params_update(self, async_client: AsyncAnthropic) -> None: vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.list( @@ -679,7 +668,6 @@ async def test_method_list(self, async_client: AsyncAnthropic) -> None: ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsCredential], credential, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.list( @@ -692,7 +680,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - ) assert_matches_type(AsyncPageCursor[BetaManagedAgentsCredential], credential, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.vaults.credentials.with_raw_response.list( @@ -704,7 +691,6 @@ async def test_raw_response_list(self, async_client: AsyncAnthropic) -> None: credential = await response.parse() assert_matches_type(AsyncPageCursor[BetaManagedAgentsCredential], credential, path=["response"]) - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.vaults.credentials.with_streaming_response.list( @@ -718,7 +704,6 @@ async def test_streaming_response_list(self, async_client: AsyncAnthropic) -> No assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="buildURL drops path-level query params") @parametrize async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): @@ -729,7 +714,7 @@ async def test_path_params_list(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_delete(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.delete( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) assert_matches_type(BetaManagedAgentsDeletedCredential, credential, path=["response"]) @@ -737,7 +722,7 @@ async def test_method_delete(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_delete_with_all_params(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.delete( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -747,7 +732,7 @@ async def test_method_delete_with_all_params(self, async_client: AsyncAnthropic) @parametrize async def test_raw_response_delete(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.vaults.credentials.with_raw_response.delete( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) @@ -759,7 +744,7 @@ async def test_raw_response_delete(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_streaming_response_delete(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.vaults.credentials.with_streaming_response.delete( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) as response: assert not response.is_closed @@ -774,7 +759,7 @@ async def test_streaming_response_delete(self, async_client: AsyncAnthropic) -> async def test_path_params_delete(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): await async_client.beta.vaults.credentials.with_raw_response.delete( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="", ) @@ -787,7 +772,7 @@ async def test_path_params_delete(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_archive(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.archive( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) assert_matches_type(BetaManagedAgentsCredential, credential, path=["response"]) @@ -795,7 +780,7 @@ async def test_method_archive(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_method_archive_with_all_params(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.archive( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -805,7 +790,7 @@ async def test_method_archive_with_all_params(self, async_client: AsyncAnthropic @parametrize async def test_raw_response_archive(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.vaults.credentials.with_raw_response.archive( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) @@ -817,7 +802,7 @@ async def test_raw_response_archive(self, async_client: AsyncAnthropic) -> None: @parametrize async def test_streaming_response_archive(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.vaults.credentials.with_streaming_response.archive( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) as response: assert not response.is_closed @@ -832,7 +817,7 @@ async def test_streaming_response_archive(self, async_client: AsyncAnthropic) -> async def test_path_params_archive(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): await async_client.beta.vaults.credentials.with_raw_response.archive( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="", ) @@ -842,31 +827,28 @@ async def test_path_params_archive(self, async_client: AsyncAnthropic) -> None: vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_mcp_oauth_validate(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.mcp_oauth_validate( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) assert_matches_type(BetaManagedAgentsCredentialValidation, credential, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_method_mcp_oauth_validate_with_all_params(self, async_client: AsyncAnthropic) -> None: credential = await async_client.beta.vaults.credentials.mcp_oauth_validate( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", ) assert_matches_type(BetaManagedAgentsCredentialValidation, credential, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_raw_response_mcp_oauth_validate(self, async_client: AsyncAnthropic) -> None: response = await async_client.beta.vaults.credentials.with_raw_response.mcp_oauth_validate( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) @@ -875,11 +857,10 @@ async def test_raw_response_mcp_oauth_validate(self, async_client: AsyncAnthropi credential = await response.parse() assert_matches_type(BetaManagedAgentsCredentialValidation, credential, path=["response"]) - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_streaming_response_mcp_oauth_validate(self, async_client: AsyncAnthropic) -> None: async with async_client.beta.vaults.credentials.with_streaming_response.mcp_oauth_validate( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="vlt_011CZkZDLs7fYzm1hXNPeRjv", ) as response: assert not response.is_closed @@ -890,12 +871,11 @@ async def test_streaming_response_mcp_oauth_validate(self, async_client: AsyncAn assert cast(Any, response.is_closed) is True - @pytest.mark.skip(reason="prism can't find endpoint with beta only tag") @parametrize async def test_path_params_mcp_oauth_validate(self, async_client: AsyncAnthropic) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `vault_id` but received ''"): await async_client.beta.vaults.credentials.with_raw_response.mcp_oauth_validate( - credential_id="vcrd_011CZkZEMt8gZan2iYOQfSkw", + credential_id="vcrd_011CZkZEMt8gZan2iYPQfSkw", vault_id="", ) diff --git a/tests/api_resources/messages/test_batches.py b/tests/api_resources/messages/test_batches.py index b5e9d0e27..6812b41d4 100644 --- a/tests/api_resources/messages/test_batches.py +++ b/tests/api_resources/messages/test_batches.py @@ -20,6 +20,18 @@ base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +def mock_results(respx_mock: MockRouter) -> None: + # the mock server's example results_url points at the production API + respx_mock.get("/v1/messages/batches/message_batch_id").mock( + return_value=httpx2.Response(200, json={"results_url": "/v1/messages/batches/message_batch_id/results"}) + ) + respx_mock.get("/v1/messages/batches/message_batch_id/results").mock( + return_value=httpx2.Response( + 200, content=json.dumps({"custom_id": "my-custom-id-1", "result": {"type": "canceled"}}) + ) + ) + + class TestBatches: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) @@ -401,9 +413,10 @@ def test_method_results(self, client: Anthropic, respx_mock: MockRouter) -> None assert i == 1 assert results.http_response.is_stream_consumed - @parametrize - @pytest.mark.skip(reason="somehow hitting prod endpoint") - def test_raw_response_results(self, client: Anthropic) -> None: + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("client", [False], indirect=True) + def test_raw_response_results(self, client: Anthropic, respx_mock: MockRouter) -> None: + mock_results(respx_mock) response = client.messages.batches.with_raw_response.results( message_batch_id="message_batch_id", ) @@ -413,9 +426,10 @@ def test_raw_response_results(self, client: Anthropic) -> None: for item in stream: assert_matches_type(MessageBatchIndividualResponse, item, path=["line"]) - @parametrize - @pytest.mark.skip(reason="somehow hitting prod endpoint") - def test_streaming_response_results(self, client: Anthropic) -> None: + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("client", [False], indirect=True) + def test_streaming_response_results(self, client: Anthropic, respx_mock: MockRouter) -> None: + mock_results(respx_mock) with client.messages.batches.with_streaming_response.results( message_batch_id="message_batch_id", ) as response: @@ -819,9 +833,10 @@ async def test_method_results(self, async_client: AsyncAnthropic, respx_mock: Mo assert i == 1 assert results.http_response.is_stream_consumed - @parametrize - @pytest.mark.skip(reason="somehow hitting prod endpoint") - async def test_raw_response_results(self, async_client: AsyncAnthropic) -> None: + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("async_client", [False], indirect=True) + async def test_raw_response_results(self, async_client: AsyncAnthropic, respx_mock: MockRouter) -> None: + mock_results(respx_mock) response = await async_client.messages.batches.with_raw_response.results( message_batch_id="message_batch_id", ) @@ -831,9 +846,10 @@ async def test_raw_response_results(self, async_client: AsyncAnthropic) -> None: async for item in stream: assert_matches_type(MessageBatchIndividualResponse, item, path=["line"]) - @parametrize - @pytest.mark.skip(reason="somehow hitting prod endpoint") - async def test_streaming_response_results(self, async_client: AsyncAnthropic) -> None: + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("async_client", [False], indirect=True) + async def test_streaming_response_results(self, async_client: AsyncAnthropic, respx_mock: MockRouter) -> None: + mock_results(respx_mock) async with async_client.messages.batches.with_streaming_response.results( message_batch_id="message_batch_id", ) as response: diff --git a/tests/api_resources/organization/test_workspaces.py b/tests/api_resources/organization/test_workspaces.py index 4b0428f68..49f55336a 100644 --- a/tests/api_resources/organization/test_workspaces.py +++ b/tests/api_resources/organization/test_workspaces.py @@ -172,6 +172,7 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: after_id="after_id", before_id="before_id", include_archived=True, + include_default=True, limit=1, ) assert_matches_type(SyncPage[Workspace], workspace, path=["response"]) @@ -394,6 +395,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - after_id="after_id", before_id="before_id", include_archived=True, + include_default=True, limit=1, ) assert_matches_type(AsyncPage[Workspace], workspace, path=["response"]) diff --git a/tests/api_resources/test_models.py b/tests/api_resources/test_models.py index 5ed837efc..2853ac8be 100644 --- a/tests/api_resources/test_models.py +++ b/tests/api_resources/test_models.py @@ -73,6 +73,7 @@ def test_method_list_with_all_params(self, client: Anthropic) -> None: model = client.models.list( after_id="after_id", before_id="before_id", + lifecycle=["active"], limit=1, betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", @@ -162,6 +163,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncAnthropic) - model = await async_client.models.list( after_id="after_id", before_id="before_id", + lifecycle=["active"], limit=1, betas=["message-batches-2024-09-24"], workspace_id="wrkspc_011CZkZaBF1tNoB5wlCeusgy", diff --git a/tests/lib/test_refusal_fallback.py b/tests/lib/test_refusal_fallback.py index 1fe3b9e7e..12c545ab6 100644 --- a/tests/lib/test_refusal_fallback.py +++ b/tests/lib/test_refusal_fallback.py @@ -121,7 +121,12 @@ def test_retries_a_refusal_with_the_fallback_params_and_credit_token(self, respx # a `fallback` seam block is prepended at the model boundary — the same # block shape the streaming splice emits assert [block.to_dict() for block in result.content] == [ - {"type": "fallback", "from": {"model": "primary-model"}, "to": {"model": "fallback-model"}, "trigger": {"type": "refusal", "category": None}} + { + "type": "fallback", + "from": {"model": "primary-model"}, + "to": {"model": "fallback-model"}, + "trigger": {"type": "refusal", "category": None}, + } ] bodies = request_bodies(respx_mock) assert [body["model"] for body in bodies] == ["primary-model", "fallback-model"] @@ -237,8 +242,18 @@ def test_walks_each_hop_through_the_chain_until_a_model_accepts(self, respx_mock assert state.index == 1 # one seam per model boundary, in hop order, ahead of the served content assert [block.to_dict() for block in result.content] == [ - {"type": "fallback", "from": {"model": "primary-model"}, "to": {"model": "mid-model"}, "trigger": {"type": "refusal", "category": None}}, - {"type": "fallback", "from": {"model": "mid-model"}, "to": {"model": "last-model"}, "trigger": {"type": "refusal", "category": None}}, + { + "type": "fallback", + "from": {"model": "primary-model"}, + "to": {"model": "mid-model"}, + "trigger": {"type": "refusal", "category": None}, + }, + { + "type": "fallback", + "from": {"model": "mid-model"}, + "to": {"model": "last-model"}, + "trigger": {"type": "refusal", "category": None}, + }, {"type": "text", "text": "ok"}, ] bodies = request_bodies(respx_mock) @@ -263,7 +278,12 @@ def test_a_pinned_continuation_seams_from_the_pinned_model(self, respx_mock: Moc assert result.model == "last-model" assert [block.to_dict() for block in result.content] == [ - {"type": "fallback", "from": {"model": "mid-model"}, "to": {"model": "last-model"}, "trigger": {"type": "refusal", "category": None}}, + { + "type": "fallback", + "from": {"model": "mid-model"}, + "to": {"model": "last-model"}, + "trigger": {"type": "refusal", "category": None}, + }, {"type": "text", "text": "ok"}, ] bodies = request_bodies(respx_mock) @@ -675,7 +695,12 @@ async def test_retries_a_refusal_with_the_fallback_params_and_credit_token(self, assert result.model == "fallback-model" assert [block.to_dict() for block in result.content] == [ - {"type": "fallback", "from": {"model": "primary-model"}, "to": {"model": "fallback-model"}, "trigger": {"type": "refusal", "category": None}} + { + "type": "fallback", + "from": {"model": "primary-model"}, + "to": {"model": "fallback-model"}, + "trigger": {"type": "refusal", "category": None}, + } ] bodies = request_bodies(respx_mock) assert [body["model"] for body in bodies] == ["primary-model", "fallback-model"] diff --git a/tests/lib/test_refusal_fallback_streaming.py b/tests/lib/test_refusal_fallback_streaming.py index bc8a4a15e..59ceff92b 100644 --- a/tests/lib/test_refusal_fallback_streaming.py +++ b/tests/lib/test_refusal_fallback_streaming.py @@ -614,7 +614,8 @@ def test_server_side_fallbacks_raise_an_error(self, respx_mock: MockRouter) -> N client = make_lenient_client(middleware=[BetaRefusalFallbackMiddleware(FALLBACKS)]) with pytest.raises( - AnthropicError, match=r"Sending the `fallbacks:` request param is not supported when using the `BetaRefusalFallbackMiddleware`\. You should either remove the middleware and send `fallbacks:` with the `server-side-fallback-2026-07-01` beta header to let the API handle refusal fallbacks, or omit the `fallbacks:` param if you'd like `BetaRefusalFallbackMiddleware` to handle fallbacks on the client side\." + AnthropicError, + match=r"Sending the `fallbacks:` request param is not supported when using the `BetaRefusalFallbackMiddleware`\. You should either remove the middleware and send `fallbacks:` with the `server-side-fallback-2026-07-01` beta header to let the API handle refusal fallbacks, or omit the `fallbacks:` param if you'd like `BetaRefusalFallbackMiddleware` to handle fallbacks on the client side\.", ): client.beta.messages.create( model="claude-fable-5", diff --git a/tests/lib/tools/memory_sync/test_lost_response.py b/tests/lib/tools/memory_sync/test_lost_response.py new file mode 100644 index 000000000..18290c109 --- /dev/null +++ b/tests/lib/tools/memory_sync/test_lost_response.py @@ -0,0 +1,270 @@ +"""An upload whose response never arrives. + +The server can save a create or update and the response still be lost — a +timeout, a dropped connection. The sync then cannot tell whether the bytes +landed. If they did, the server's copy is the sync's own upload, not another +writer's edit, and a newer local edit must go out over it. +""" + +from __future__ import annotations + +from typing import Any +from pathlib import Path +from contextlib import contextmanager +from collections.abc import Iterator + +import httpx2 +import pytest + +from anthropic import APIConnectionError +from anthropic.lib.tools._memories import SessionMemoryStores + +from .conftest import run_sync +from ._fake_anthropic import MemoryServer, created, deleted, updated, fake_anthropic + +LOGGER = "anthropic.lib.tools.agent_toolset" + + +async def _downloaded(tmp_path: Path, initial: dict[str, str]) -> tuple[Path, MemoryServer, SessionMemoryStores, Any]: + """A `SessionMemoryStores` with one store already downloaded, plus the + fake `client.beta.memory_stores.memories` resource it calls.""" + client, server = fake_anthropic(initial) + stores = SessionMemoryStores(client, workdir=tmp_path) + await stores.download(await client.beta.sessions.retrieve("s1")) + return tmp_path / "memory" / "notes", server, stores, client.beta.memory_stores.memories + + +@contextmanager +def uploads_fail(memories: Any, *, after_saving: bool) -> Iterator[None]: + """Every create and update raises a connection error — after the server + has saved the write (the response was lost) or before it arrived at all.""" + real_create, real_update = memories.create, memories.update + + def failing(send: Any) -> Any: + async def call(*args: Any, **kwargs: Any) -> Any: + if after_saving: + await send(*args, **kwargs) + raise APIConnectionError(request=httpx2.Request("POST", "https://api.example/v1/memory")) + + return call + + memories.create, memories.update = failing(real_create), failing(real_update) + try: + yield + finally: + memories.create, memories.update = real_create, real_update + + +@pytest.mark.asyncio() +async def test_an_edit_made_after_a_lost_upload_response_still_reaches_the_server(tmp_path: Path) -> None: + local, server, stores, memories = await _downloaded(tmp_path, {"note.md": "v0"}) + + (local / "note.md").write_text("v1") + (local / "new.md").write_text("v1") + with uploads_fail(memories, after_saving=True): + await run_sync(stores) + # The server holds both writes; the sync never heard back. + assert server.files == {"note.md": "v1", "new.md": "v1"} + + (local / "note.md").write_text("v2") + (local / "new.md").write_text("v2") + await run_sync(stores) + + # The server's v1 was this sync's own upload, so v2 goes out over it. + assert server.files == {"note.md": "v2", "new.md": "v2"} + assert (local / "note.md").read_text() == "v2" + assert (local / "new.md").read_text() == "v2" + assert server.received == [ + created("new.md", "v1"), + updated("note.md", "v1", was="v0"), + updated("new.md", "v2", was="v1"), + updated("note.md", "v2", was="v1"), + ] + + # Settled: nothing more to send. + await run_sync(stores) + assert len(server.received) == 4 + + +@pytest.mark.asyncio() +async def test_a_retry_whose_response_is_lost_is_remembered_too(tmp_path: Path) -> None: + local, server, stores, memories = await _downloaded(tmp_path, {"note.md": "v0"}) + + (local / "note.md").write_text("v1") + with uploads_fail(memories, after_saving=False): + await run_sync(stores) + # The retry sends the same bytes; this time they land and the response is lost. + with uploads_fail(memories, after_saving=True): + await run_sync(stores) + assert server.files["note.md"] == "v1" + + (local / "note.md").write_text("v2") + await run_sync(stores) + + assert server.files["note.md"] == "v2" + assert (local / "note.md").read_text() == "v2" + + +@pytest.mark.asyncio() +async def test_a_client_retry_refused_because_the_first_attempt_landed_is_remembered(tmp_path: Path) -> None: + """The client's own retry re-sends the update with the old precondition; + the first attempt already moved the server, so the retry gets a 409.""" + local, server, stores, memories = await _downloaded(tmp_path, {"note.md": "v0"}) + real_update = memories.update + + async def sent_twice(memory_id: str, **kwargs: Any) -> Any: + await real_update(memory_id, **kwargs) + return await real_update(memory_id, **kwargs) + + (local / "note.md").write_text("v1") + memories.update = sent_twice + await run_sync(stores) + memories.update = real_update + assert server.files["note.md"] == "v1" + assert server.received == [updated("note.md", "v1", was="v0")] * 2 + + (local / "note.md").write_text("v2") + await run_sync(stores) + + assert server.files["note.md"] == "v2" + assert (local / "note.md").read_text() == "v2" + + +@pytest.mark.asyncio() +async def test_a_deletion_made_after_a_lost_upload_response_still_reaches_the_server(tmp_path: Path) -> None: + local, server, stores, memories = await _downloaded(tmp_path, {"note.md": "v0", "keep.md": "v0"}) + + (local / "note.md").write_text("v1") + (local / "new.md").write_text("v1") + with uploads_fail(memories, after_saving=True): + await run_sync(stores) + (local / "note.md").unlink() + (local / "new.md").unlink() + + await stores.finish() + + # The server's copies are this sync's own uploads, so the deletions go + # out, guarded by the content that was sent. + assert server.files == {"keep.md": "v0"} + assert server.received[-2:] == [deleted("new.md", was="v1"), deleted("note.md", was="v1")] + + +@pytest.mark.asyncio() +async def test_a_folder_wiped_after_a_lost_create_response_is_rebuilt_not_read_as_deletions(tmp_path: Path) -> None: + local, server, stores, memories = await _downloaded(tmp_path, {"note.md": "v0"}) + + (local / "new.md").write_text("v1") + with uploads_fail(memories, after_saving=True): + await run_sync(stores) + # Both files vanish at once; the marker stays. + (local / "note.md").unlink() + (local / "new.md").unlink() + + # The final sync waives the delete window, so a misread wipe would delete at once. + await stores.finish() + + # Two known files gone together is a wipe: re-downloaded, nothing deleted. + assert server.files == {"note.md": "v0", "new.md": "v1"} + assert (local / "note.md").read_text() == "v0" + assert (local / "new.md").read_text() == "v1" + assert server.received == [created("new.md", "v1")] + + +@pytest.mark.asyncio() +async def test_the_shutdown_flush_pushes_a_revert_made_after_a_lost_upload_response(tmp_path: Path) -> None: + local, server, stores, memories = await _downloaded(tmp_path, {"note.md": "v0"}) + + (local / "note.md").write_text("v1") + with uploads_fail(memories, after_saving=True): + await run_sync(stores) + # Back to the last content the sync knows it saved — but the server moved on. + (local / "note.md").write_text("v0") + + await stores.flush_writes() + + assert server.files["note.md"] == "v0" + assert server.received[-1] == updated("note.md", "v0", was="v1") + + +@pytest.mark.asyncio() +async def test_the_shutdown_flush_sends_nothing_for_a_revert_whose_upload_never_landed(tmp_path: Path) -> None: + local, server, stores, memories = await _downloaded(tmp_path, {"note.md": "v0", "keep.md": "v0"}) + + (local / "note.md").write_text("v1") + with uploads_fail(memories, after_saving=False): + await run_sync(stores) + (local / "note.md").write_text("v0") + # Another writer deletes the memory; the file holds no edit to save. + server.delete("note.md") + + await stores.flush_writes() + + assert server.files == {"keep.md": "v0"} + assert server.received == [] + + +@pytest.mark.asyncio() +async def test_the_shutdown_flush_pushes_an_edit_made_after_a_lost_upload_response(tmp_path: Path) -> None: + local, server, stores, memories = await _downloaded(tmp_path, {"note.md": "v0"}) + + (local / "note.md").write_text("v1") + with uploads_fail(memories, after_saving=True): + await run_sync(stores) + (local / "note.md").write_text("v2") + + await stores.flush_writes() + + assert server.files["note.md"] == "v2" + assert server.received[-1] == updated("note.md", "v2", was="v1") + + +@pytest.mark.asyncio() +async def test_another_writers_edit_after_a_lost_upload_response_still_wins( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level("WARNING", logger=LOGGER) + local, server, stores, memories = await _downloaded(tmp_path, {"note.md": "v0"}) + + (local / "note.md").write_text("v1") + with uploads_fail(memories, after_saving=True): + await run_sync(stores) + server.write("note.md", "theirs") + (local / "note.md").write_text("v2") + + await run_sync(stores) + + # The server's copy is not what this sync sent, so the usual conflict rule holds. + assert server.files["note.md"] == "theirs" + assert (local / "note.md").read_text() == "theirs" + assert "changed both locally and remotely" in caplog.text + + +@pytest.mark.asyncio() +async def test_a_send_is_forgotten_once_a_later_sync_has_seen_the_server( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A remembered send must not outlive the next completed sync: kept longer, + it would mistake another writer's identical bytes for this sync's upload + and overwrite them.""" + caplog.set_level("WARNING", logger=LOGGER) + local, server, stores, memories = await _downloaded(tmp_path, {"note.md": "v0"}) + + (local / "note.md").write_text("v1") + with uploads_fail(memories, after_saving=False): + await run_sync(stores) + assert server.files["note.md"] == "v0" + + # The agent reverts, so the next sync has nothing to send — its listing + # shows the v1 upload never landed. + (local / "note.md").write_text("v0") + await run_sync(stores) + + # Another writer now saves the very bytes that failed upload carried. + server.write("note.md", "v1") + (local / "note.md").write_text("v2") + await run_sync(stores) + + assert server.files["note.md"] == "v1" + assert (local / "note.md").read_text() == "v1" + assert "changed both locally and remotely" in caplog.text + assert server.received == [] diff --git a/tests/lib/tools/test_runner_eager_tools.py b/tests/lib/tools/test_runner_eager_tools.py index 6513c69a9..2dd2e6144 100644 --- a/tests/lib/tools/test_runner_eager_tools.py +++ b/tests/lib/tools/test_runner_eager_tools.py @@ -27,8 +27,12 @@ from anthropic import Anthropic, AsyncAnthropic, beta_tool, beta_async_tool from anthropic._compat import PYDANTIC_V1 from anthropic.lib.tools import ToolError -from anthropic.types.beta import BetaMessageParam +from anthropic.types.beta import BetaMessageParam, BetaBrowserJavascriptExecInput from anthropic.lib.streaming import BetaMessageStream, BetaAsyncMessageStream +from anthropic.tools.browser import BetaToolsetCallContext + +from .toolsets._fakes import World, FakeBrowser, AsyncFakeBrowser +from .toolsets._computer_fakes import Desktop, FakeDesktop, AsyncFakeDesktop base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -438,6 +442,35 @@ async def test_gives_the_timing_from_before_when_the_caller_defers_every_call(se ] +async def test_lists_only_the_call_that_the_caller_defers_and_not_the_calls_after_it(setup: SetupFactory) -> None: + test = setup([reply(call("a"), call("b"), call("c"))]) + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + if event.type == "content_block_stop" and event.content_block.type == "tool_use": + if event.content_block.id == "toolu_b": + test.runner.defer_tool_call(event.content_block) + + assert ids(test.runner.deferred_tool_calls) == ["toolu_b"] + break + + +async def test_defers_the_calls_of_one_reply_only(setup: SetupFactory) -> None: + test = setup([reply(call("a"), call("b")), reply(call("c"), call("d")), done()]) + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + test.see(event) + if event.type == "content_block_stop" and event.content_block.type == "tool_use": + if event.content_block.id == "toolu_a": + test.runner.defer_tool_call(event.content_block) + test.timeline.append("loop body ends") + + second_reply = test.timeline[test.timeline.index("request 2") : test.timeline.index("request 3")] + starts = [line for line in second_reply if "STARTS" in line or line == "loop body ends"] + assert starts == ["lookup(c) STARTS", "lookup(d) STARTS", "loop body ends"] # the next reply starts early again + + async def test_defer_tool_call_does_nothing_before_the_loop_starts_because_there_is_no_reply_yet( setup: SetupFactory, ) -> None: @@ -771,6 +804,20 @@ def call(key: str, json_input: str | None = None) -> Block: return {"type": "tool_use", "key": key, "json": json.dumps({"key": key}) if json_input is None else json_input} +def toolset_call( + key: str, name: str = "key", input: dict[str, Any] | None = None, toolset_name: str | None = "computer" +) -> Block: + """The model calls the tool `name` of a toolset with the id `toolu_`. By default it is the computer toolset's + `key` tool. Without a `toolset_name` it is a call named like a toolset, which the runner can't route.""" + return { + "type": "tool_use", + "key": key, + "json": json.dumps({"text": key} if input is None else input), + "name": name, + **({"toolset_name": toolset_name} if toolset_name is not None else {}), + } + + def text(value: str) -> Block: return {"type": "text", "text": value} @@ -866,7 +913,14 @@ def to_events(canned: Reply) -> list[dict[str, Any]]: start: dict[str, Any] = block delta: dict[str, Any] | None = None if block["type"] == "tool_use": - start = {"type": "tool_use", "id": f"toolu_{block['key']}", "name": "lookup", "input": {}} + start = { + "type": "tool_use", + "id": f"toolu_{block['key']}", + "name": block.get("name", "lookup"), + "input": {}, + } + if "toolset_name" in block: + start["toolset_name"] = block["toolset_name"] delta = {"type": "input_json_delta", "partial_json": block["json"]} elif block["type"] == "text": start = {"type": "text", "text": ""} @@ -889,7 +943,13 @@ def to_events(canned: Reply) -> list[dict[str, Any]]: def to_message(canned: Reply) -> dict[str, Any]: """The reply as the message the API sends for it when not streaming.""" content = [ - {"type": "tool_use", "id": f"toolu_{block['key']}", "name": "lookup", "input": json.loads(block["json"])} + { + "type": "tool_use", + "id": f"toolu_{block['key']}", + "name": block.get("name", "lookup"), + "input": json.loads(block["json"]), + **({"toolset_name": block["toolset_name"]} if "toolset_name" in block else {}), + } if block["type"] == "tool_use" else block for block in canned.blocks @@ -966,12 +1026,55 @@ async def async_lookup(key: str) -> str: return async_lookup +class TimelineDesktop(Desktop): + """A desktop that writes to `timeline` when a tool of the computer toolset starts.""" + + def __init__(self, timeline: list[str]) -> None: + super().__init__() + self._timeline = timeline + + @override + def do(self, name: str, context: BetaToolsetCallContext, input: Any) -> Any: + self._timeline.append(f"computer {name} STARTS") + return super().do(name, context, input) + + +class TimelineWorld(World): + """A browser that writes to `timeline` when a tool of the browser toolset starts.""" + + def __init__(self, timeline: list[str]) -> None: + super().__init__() + self._timeline = timeline + + @override + def do(self, name: str, context: BetaToolsetCallContext, input: Any) -> None: + self._timeline.append(f"browser {name} STARTS") + super().do(name, context, input) + + +class JsBrowser(FakeBrowser): + """A browser toolset whose driver implements `javascript_exec`.""" + + @override + def javascript_exec(self, context: BetaToolsetCallContext, input: BetaBrowserJavascriptExecInput) -> str: + self.world.do("javascript_exec", context, input) + return "ran" + + +class AsyncJsBrowser(AsyncFakeBrowser): + @override + async def javascript_exec(self, context: BetaToolsetCallContext, input: BetaBrowserJavascriptExecInput) -> str: + self.world.do("javascript_exec", context, input) + return "ran" + + class Setup: - def __init__(self, *, sync: bool, runner: Any, timeline: list[str], requests: list[Any]) -> None: + def __init__(self, *, sync: bool, runner: Any, timeline: list[str], requests: list[Any], desktop: Desktop) -> None: self.sync = sync self.runner = runner self.timeline = timeline self.requests = requests + self.desktop = desktop self._blocks: dict[int, str] = {} def label(self, event: Any) -> str: @@ -1001,6 +1104,9 @@ def __call__( lookup: Callable[[str], str] | None = None, log_returns: bool = False, messages: list[Any] | None = None, + computer: dict[str, Any] | None = None, + browser: dict[str, Any] | None = None, + extra_tools: list[Any] | None = None, ) -> Setup: timeline: list[str] = [] requests: list[Any] = [] @@ -1025,14 +1131,23 @@ def respond(request: httpx2.Request) -> httpx2.Response: self._respx_mock.post("/v1/messages").mock(side_effect=respond) + tools: list[Any] = [lookup_tool(self._sync, timeline, lookup=lookup, log_returns=log_returns)] + desktop = TimelineDesktop(timeline) + if computer is not None: + # a computer toolset over a desktop that writes its calls to the timeline, with these options + tools.append((FakeDesktop if self._sync else AsyncFakeDesktop)(desktop=desktop, **computer)) + if browser is not None: + tools.append((JsBrowser if self._sync else AsyncJsBrowser)(world=TimelineWorld(timeline), **browser)) + tools.extend(extra_tools or []) + tool_runner: Any = self._client.beta.messages.tool_runner runner = tool_runner( **{**PARAMS, "messages": messages or PARAMS["messages"]}, - tools=[lookup_tool(self._sync, timeline, lookup=lookup, log_returns=log_returns)], + tools=tools, stream=stream, **({"run_tools_eagerly": True} if run_tools_eagerly and stream else {}), ) - return Setup(sync=self._sync, runner=runner, timeline=timeline, requests=requests) + return Setup(sync=self._sync, runner=runner, timeline=timeline, requests=requests, desktop=desktop) @pytest.fixture(params=[True, False], ids=["sync", "async"]) diff --git a/tests/lib/tools/test_runner_eager_toolsets.py b/tests/lib/tools/test_runner_eager_toolsets.py new file mode 100644 index 000000000..2b32aaf88 --- /dev/null +++ b/tests/lib/tools/test_runner_eager_toolsets.py @@ -0,0 +1,454 @@ +"""A tool runner with `run_tools_eagerly` and a toolset. The runner runs a toolset call while the reply streams, once +the model has moved on from it, and the toolset's `confirm` is asked before the call runs. Once one of a toolset's calls +fails or is held, that toolset's later calls wait for the reply to end. Function tools and other toolsets don't wait. + +The replies and the timeline are the ones of `test_runner_eager_tools.py`. `computer={}` and `browser={}` add a +toolset whose calls are written to the timeline, with these options.""" + +from __future__ import annotations + +import os +import json +from typing import Any, Callable + +import pytest + +from anthropic import beta_tool, beta_async_tool +from anthropic._compat import PYDANTIC_V1 +from anthropic.lib.tools import ToolError +from anthropic.tools.browser import BetaConfirmContext +from anthropic.tools.computer import BetaComputerConfirmContext + +from .toolsets._stubs import EchoBrowserToolset, AsyncEchoBrowserToolset +from .test_runner_eager_tools import ( # the fixtures + Block, + Setup, + SetupFactory, + call, + done, + sync as sync, + reply, + setup as setup, + events_of, + replies_of, + first_reply, + results_sent, + toolset_call, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + +pytestmark = [ + pytest.mark.skipif(PYDANTIC_V1, reason="tool runner not supported with pydantic v1"), + pytest.mark.respx(base_url=base_url), +] + + +async def test_a_toolset_call_does_not_run_a_function_tool_of_the_same_name(setup: SetupFactory, sync: bool) -> None: + ran: list[str] = [] + key_tool: Any + if sync: + + @beta_tool(name="key") + def sync_key(text: str) -> str: + """A function tool named like the computer toolset's `key` tool.""" + ran.append(text) + return "the function tool ran" + + key_tool = sync_key + else: + + @beta_async_tool(name="key") + async def async_key(text: str) -> str: + """A function tool named like the computer toolset's `key` tool.""" + ran.append(text) + return "the function tool ran" + + key_tool = async_key + test = setup([reply(toolset_call("k")), done()], computer={}, extra_tools=[key_tool]) + + async for stream in replies_of(test.runner): + async for _event in events_of(stream): + pass + + results = results_sent(test) + assert ran == [] + assert test.desktop.calls == ["key"] + assert [result["tool_use_id"] for result in results] == ["toolu_k"] + assert "the function tool ran" not in json.dumps(results) + + +@pytest.mark.parametrize("family", ["computer", "browser"]) +async def test_starts_a_toolset_call_once_the_model_has_moved_on_from_it(setup: SetupFactory, family: str) -> None: + tool_call, started = family_call(family) + test = setup( + [reply(call("a"), tool_call, call("b")), done()], + computer={} if family == "computer" else None, + browser={} if family == "browser" else None, + ) + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + test.see(event) + test.timeline.append("loop body ends") + + assert first_reply(test.timeline) == [ + "message_start", + "a: content_block_start", + "a: content_block_delta", + "input_json", + "a: content_block_stop", + "k: content_block_start", # the model has moved on from a + "lookup(a) STARTS", + "k: content_block_delta", + "input_json", + "k: content_block_stop", + "b: content_block_start", # the model has moved on from the toolset call + started, + "b: content_block_delta", + "input_json", + "b: content_block_stop", + "message_delta (tool_use)", # the model has moved on from b + "lookup(b) STARTS", + "message_stop", + "loop body ends", + ] + # The results go back in the model's order, and the toolset call ran once. + results = results_sent(test) + assert [result["tool_use_id"] for result in results] == ["toolu_a", "toolu_k", "toolu_b"] + assert not results[1].get("is_error") + + +@pytest.mark.parametrize("family", ["computer", "browser"]) +async def test_asks_confirm_before_a_call_runs_while_the_reply_streams(setup: SetupFactory, family: str) -> None: + tool_call, started = family_call(family) + asked: list[str] = [] + + def confirm(context: BetaConfirmContext | BetaComputerConfirmContext) -> bool: + asked.append(context.member) + test.timeline.append(f"confirm {context.member}") + return True + + test = setup( + [reply(tool_call, call("b")), done()], + computer={"confirm": confirm} if family == "computer" else None, + browser={"confirm": confirm} if family == "browser" else None, + ) + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + test.see(event) + test.timeline.append("loop body ends") + + # The toolset call runs while the reply streams, after `confirm` has approved it. + member = asked[0] + assert starts(first_reply(test.timeline), also="confirm") == [ + f"confirm {member}", + started, + "lookup(b) STARTS", + "loop body ends", + ] + + +@pytest.mark.parametrize("family", ["computer", "browser"]) +async def test_does_not_run_a_call_that_confirm_declines_or_the_toolsets_calls_after_it( + setup: SetupFactory, family: str +) -> None: + first, _started = family_call(family, "k1") + second, _started = family_call(family, "k2") + + def confirm(context: BetaConfirmContext | BetaComputerConfirmContext) -> bool: + test.timeline.append(f"confirm {context.member}") + return False + + test = setup( + [reply(first, second), done()], + computer={"confirm": confirm} if family == "computer" else None, + browser={"confirm": confirm} if family == "browser" else None, + ) + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + test.see(event) + test.timeline.append("loop body ends") + + # `confirm` is asked once, no tool starts, and the second call is not run. + lines = starts(first_reply(test.timeline), also="confirm") + assert len(lines) == 2 and lines[0].startswith("confirm ") and lines[1] == "loop body ends" + results = results_sent(test) + assert [result["tool_use_id"] for result in results] == ["toolu_k1", "toolu_k2"] + assert results[0]["is_error"] and results[1]["is_error"] + assert "Not executed" in json.dumps(results[1]) + + +async def test_starts_javascript_exec_like_any_other_call(setup: SetupFactory) -> None: + test = setup([reply(call("a"), js_call("j"), call("b")), done()], browser=JS_BROWSER) + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + test.see(event) + test.timeline.append("loop body ends") + + assert starts(first_reply(test.timeline)) == [ + "lookup(a) STARTS", + "browser javascript_exec STARTS", + "lookup(b) STARTS", + "loop body ends", + ] + + +async def test_a_toolset_call_that_the_caller_defers_holds_its_toolsets_later_calls_and_no_other_call( + setup: SetupFactory, +) -> None: + wait = toolset_call("w", "wait", {"duration": 1}, "browser") + test = setup( + [reply(call("a"), toolset_call("k1"), call("f"), wait, toolset_call("k2")), done()], + computer={}, + browser={}, + ) + see = defer_calls(test, "toolu_k1") + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + see(event) + test.timeline.append("loop body ends") + + assert starts(first_reply(test.timeline)) == [ + "lookup(a) STARTS", + "lookup(f) STARTS", # a function tool doesn't wait for the held call + "browser wait STARTS", # nor does another toolset + "loop body ends", + "computer key STARTS", # k1 + "computer key STARTS", # k2 waited behind it + ] + assert [result["tool_use_id"] for result in results_sent(test)] == [ + "toolu_a", + "toolu_k1", + "toolu_f", + "toolu_w", + "toolu_k2", + ] + + +async def test_a_function_call_that_the_caller_defers_holds_no_other_call(setup: SetupFactory) -> None: + test = setup([reply(call("a"), toolset_call("k"), call("b")), done()], computer={}) + see = defer_calls(test, "toolu_a") + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + see(event) + test.timeline.append("loop body ends") + + assert starts(first_reply(test.timeline)) == [ + "computer key STARTS", + "lookup(b) STARTS", + "loop body ends", + "lookup(a) STARTS", + ] + + +async def test_lists_only_the_call_that_the_caller_defers_and_not_its_toolsets_later_calls( + setup: SetupFactory, +) -> None: + test = setup([reply(toolset_call("k1"), toolset_call("k2")), done()], computer={}) + see = defer_calls(test, "toolu_k1") + listed: list[list[str]] = [] + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + see(event) + listed.append([tool_use.id for tool_use in test.runner.deferred_tool_calls]) + break + + assert listed == [["toolu_k1"]] + + +async def test_the_hold_ends_with_the_reply(setup: SetupFactory) -> None: + test = setup([reply(toolset_call("k1"), toolset_call("k2")), reply(toolset_call("k3")), done()], computer={}) + see = defer_calls(test, "toolu_k1") + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + see(event) + test.timeline.append("loop body ends") + + second_reply = test.timeline[test.timeline.index("request 2") : test.timeline.index("request 3")] + assert starts(second_reply) == [ + "computer key STARTS", # the next reply's calls run early again + "loop body ends", + ] + + +async def test_does_not_run_a_call_that_has_not_started_when_the_caller_leaves_the_loop( + setup: SetupFactory, +) -> None: + test = setup([reply(toolset_call("k1"), toolset_call("k2")), done()], computer={}) + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + test.see(event) + # The model has moved on from k1, so k1 has run. k2 has not. + if event.type == "content_block_stop" and event.content_block.type == "tool_use": + if event.content_block.id == "toolu_k2": + break + break + + assert starts(test.timeline) == ["computer key STARTS"] + + +async def test_does_not_run_a_held_call_once_one_of_its_toolsets_calls_has_failed(setup: SetupFactory) -> None: + test = setup([reply(toolset_call("k1"), toolset_call("k2")), done()], computer={}) + test.desktop.fail["key"] = ToolError("no such key") + see = defer_calls(test, "toolu_k2") + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + see(event) + test.timeline.append("loop body ends") + + assert starts(first_reply(test.timeline)) == ["computer key STARTS", "loop body ends"] + results = results_sent(test) + assert [result["tool_use_id"] for result in results] == ["toolu_k1", "toolu_k2"] + assert "Not executed" in json.dumps(results[1]) + + +async def test_a_call_named_like_a_toolset_with_no_toolset_name_holds_that_toolsets_later_calls( + setup: SetupFactory, +) -> None: + # The runner answers such a call as a malformed toolset call when it builds the tool response. That answers the + # toolset's later calls with `Not executed`, so none of them may have run. + malformed = toolset_call("m", "computer", {"action": "key"}, None) + test = setup([reply(call("a"), malformed, toolset_call("k"), call("b")), done()], computer={}) + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + test.see(event) + test.timeline.append("loop body ends") + + assert starts(first_reply(test.timeline)) == ["lookup(a) STARTS", "lookup(b) STARTS", "loop body ends"] + results = results_sent(test) + assert [result["tool_use_id"] for result in results] == ["toolu_a", "toolu_m", "toolu_k", "toolu_b"] + assert results[1]["is_error"] and results[2]["is_error"] and not results[3].get("is_error") + assert "Not executed" in json.dumps(results[2]) + + +async def test_a_held_call_named_like_a_toolset_with_no_toolset_name_holds_that_toolsets_later_calls( + setup: SetupFactory, +) -> None: + # The caller holds the malformed call. It is still answered as malformed when the tool response is built, and that + # answers the toolset's later calls with `Not executed`, so none of them may have run. + malformed = toolset_call("m", "computer", {"action": "key"}, None) + test = setup([reply(malformed, toolset_call("k"), call("b")), done()], computer={}) + see = defer_calls(test, "toolu_m") + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + see(event) + test.timeline.append("loop body ends") + + assert starts(first_reply(test.timeline)) == ["lookup(b) STARTS", "loop body ends"] + results = results_sent(test) + assert [result["tool_use_id"] for result in results] == ["toolu_m", "toolu_k", "toolu_b"] + assert "Not executed" in json.dumps(results[1]) + + +async def test_a_call_to_a_toolset_the_runner_does_not_have_is_answered_as_not_found( + setup: SetupFactory, +) -> None: + test = setup([reply(call("a"), toolset_call("k"), call("b")), done()]) + + with pytest.warns(UserWarning, match="not found in tool runner"): + async for stream in replies_of(test.runner): + async for event in events_of(stream): + test.see(event) + test.timeline.append("loop body ends") + + assert starts(first_reply(test.timeline)) == ["lookup(a) STARTS", "lookup(b) STARTS", "loop body ends"] + results = results_sent(test) + assert [result["tool_use_id"] for result in results] == ["toolu_a", "toolu_k", "toolu_b"] + assert results[1]["is_error"] and "not found" in json.dumps(results[1]) + + +async def test_runs_no_later_call_of_a_toolset_once_one_of_its_calls_has_failed(setup: SetupFactory) -> None: + test = setup([reply(toolset_call("k1"), toolset_call("k2"), call("c")), done()], computer={}) + + test.desktop.fail["key"] = ToolError("no such key") + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + test.see(event) + test.timeline.append("loop body ends") + + # k2 doesn't start after k1 failed. It is answered with `Not executed`, and c, which is not a toolset call, runs. + assert starts(first_reply(test.timeline)) == ["computer key STARTS", "lookup(c) STARTS", "loop body ends"] + results = results_sent(test) + assert [result["tool_use_id"] for result in results] == ["toolu_k1", "toolu_k2", "toolu_c"] + assert results[0]["is_error"] and results[1]["is_error"] and not results[2].get("is_error") + assert "Not executed" in json.dumps(results[1]) + + +async def test_a_toolset_that_does_not_extend_the_sdks_classes_starts_its_calls_early_too( + setup: SetupFactory, sync: bool +) -> None: + echo = EchoBrowserToolset() if sync else AsyncEchoBrowserToolset() + test = setup( + [reply(call("a"), toolset_call("w", "wait", {"duration": 1}, "browser"), call("b")), done()], + extra_tools=[echo], + ) + ran_while_streaming: list[str] = [] + + async for stream in replies_of(test.runner): + async for event in events_of(stream): + test.see(event) + ran_while_streaming += [name for name, _input in echo.calls] + test.timeline.append("loop body ends") + + assert ran_while_streaming[:1] == ["wait"] + assert [name for name, _input in echo.calls] == ["wait"] # it ran once + + +# --------------------------------------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------------------------------------- + + +def allow(_context: BetaConfirmContext | BetaComputerConfirmContext) -> bool: + """A `confirm` that approves every call.""" + return True + + +JS_BROWSER: dict[str, Any] = {"confirm": allow, "configs": {"javascript_exec": {"enabled": True}}} +"""Options for a browser toolset that serves `javascript_exec`.""" + + +def js_call(key: str) -> Block: + """A call to the browser's `javascript_exec`.""" + return toolset_call(key, "javascript_exec", {"text": "1"}, "browser") + + +def starts(timeline: list[str], *, also: str | None = None) -> list[str]: + """When each tool started, and when the caller's loop body ended. `also` keeps the lines that start with it too.""" + return [ + line + for line in timeline + if "STARTS" in line or line == "loop body ends" or (also is not None and line.startswith(also)) + ] + + +def family_call(family: str, key: str = "k") -> tuple[Block, str]: + """A call to a tool of the `family` toolset, and the timeline line for when that tool starts.""" + if family == "computer": + return toolset_call(key), "computer key STARTS" + return toolset_call(key, "wait", {"duration": 1}, "browser"), "browser wait STARTS" + + +def defer_calls(test: Setup, *ids: str) -> Callable[[Any], None]: + """Defers the calls with these ids, when the caller sees their blocks finish. Pass each event to what it returns.""" + + def see(event: Any) -> None: + test.see(event) + if event.type == "content_block_stop" and event.content_block.type == "tool_use": + if event.content_block.id in ids: + test.runner.defer_tool_call(event.content_block) + + return see diff --git a/tests/lib/tools/test_runners.py b/tests/lib/tools/test_runners.py index fb43e365b..0ffad3752 100644 --- a/tests/lib/tools/test_runners.py +++ b/tests/lib/tools/test_runners.py @@ -16,7 +16,7 @@ from anthropic._types import Omit, omit from anthropic._utils import assert_signatures_in_sync from anthropic._compat import PYDANTIC_V1 -from anthropic.lib.tools import BetaFunctionTool, BetaAsyncFunctionTool, BetaFunctionToolResultType +from anthropic.lib.tools import ToolError, BetaFunctionTool, BetaAsyncFunctionTool, BetaFunctionToolResultType from anthropic.lib.tools._beta_runner import ( _STOP_REASON_STEPS, BetaToolRunner, @@ -2224,6 +2224,200 @@ async def test_tool_changes_when_streaming( ] +def _weather_tool(sync: bool, run: Callable[[str, Literal["c", "f"]], BetaFunctionToolResultType]) -> Any: + """A `get_weather(location, units)` tool over `run`, synchronous or asynchronous.""" + + async def run_async(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResultType: + return run(location, units) + + if sync: + return beta_tool(run, name="get_weather", description="Lookup the weather for a given city.") + return beta_async_tool(run_async, name="get_weather", description="Lookup the weather for a given city.") + + +async def _run_tool_use( + client: Anthropic | AsyncAnthropic, *, tools: List[Any], messages: List[BetaMessageParam] +) -> List[BetaMessageParam]: + """`_run_sync_tool_use` for either client.""" + runner: Any = client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=tools, messages=messages + ) + responses: List[BetaMessageParam] = [] + if isinstance(client, Anthropic): + for _ in runner: + response = runner.generate_tool_call_response() + if response is not None: + responses.append(response) + else: + async for _ in runner: + response = await runner.generate_tool_call_response() + if response is not None: + responses.append(response) + return responses + + +@pytest.mark.skipif(PYDANTIC_V1, reason="tool runner not supported with pydantic v1") +@_sync_and_async +@pytest.mark.respx(base_url=base_url) +async def test_tool_error_text_with_a_lone_surrogate_does_not_break_the_next_request( + sync: bool, + client: Anthropic, + async_client: AsyncAnthropic, + respx_mock: MockRouter, + caplog: pytest.LogCaptureFixture, +) -> None: + # A tool's exception text lands in the tool_result the next request carries. An unpaired + # surrogate in it cannot be encoded as UTF-8, so it must be repaired before it is sent, or + # that request (and every retry over the same history) fails in the client's JSON encoder. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _tool_use_response("get_weather", "toolu_surrogate"), + _end_turn_response(), + ] + ) + + def run(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResultType: + raise ValueError(f"bad \ud800 value for {location} in {units}") + + # the runner logs the raw exception text; kept out of the captured log so the test report stays encodable + with caplog.at_level(logging.CRITICAL, logger="anthropic.lib.tools._beta_runner"): + results = await _run_tool_use( + (client if sync else async_client).with_options(max_retries=0), + tools=[_weather_tool(sync, run)], + messages=[{"role": "user", "content": "What is the weather in SF?"}], + ) + + assert len(respx_mock.calls) == 2, "the follow-up request carrying the tool_result was sent" + [result_message] = results + [tool_result] = cast(List[BetaToolResultBlockParam], result_message["content"]) + assert tool_result.get("is_error") is True + content = tool_result.get("content") + assert isinstance(content, str) + content.encode("utf-8") # well formed: raises on a lone surrogate + assert content == "ValueError('bad \\ud800 value for San Francisco, CA in f')" # `repr` escapes the surrogate + sent = json.loads(respx_mock.calls.last.request.content) + assert sent["messages"][-1]["content"][0]["content"] == content + + +@pytest.mark.skipif(PYDANTIC_V1, reason="tool runner not supported with pydantic v1") +@_sync_and_async +@pytest.mark.respx(base_url=base_url) +async def test_tool_error_content_with_a_lone_surrogate_does_not_break_the_next_request( + sync: bool, + client: Anthropic, + async_client: AsyncAnthropic, + respx_mock: MockRouter, + caplog: pytest.LogCaptureFixture, +) -> None: + # A ToolError's content is the tool_result as the tool wrote it; an unpaired surrogate in it is + # repaired the same way, and nothing else about it changes. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _tool_use_response("get_weather", "toolu_surrogate"), + _end_turn_response(), + ] + ) + + def run(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResultType: # noqa: ARG001 + raise ToolError("rate limited \ud800 proxy") + + with caplog.at_level(logging.CRITICAL, logger="anthropic.lib.tools._beta_runner"): + results = await _run_tool_use( + (client if sync else async_client).with_options(max_retries=0), + tools=[_weather_tool(sync, run)], + messages=[{"role": "user", "content": "What is the weather in SF?"}], + ) + + assert len(respx_mock.calls) == 2, "the follow-up request carrying the tool_result was sent" + [result_message] = results + [tool_result] = cast(List[BetaToolResultBlockParam], result_message["content"]) + assert tool_result.get("is_error") is True + content = tool_result.get("content") + assert isinstance(content, str) + content.encode("utf-8") # well formed: raises on a lone surrogate + assert content == "rate limited \ufffd proxy" + sent = json.loads(respx_mock.calls.last.request.content) + assert sent["messages"][-1]["content"][0]["content"] == content + + +@pytest.mark.skipif(PYDANTIC_V1, reason="tool runner not supported with pydantic v1") +@_sync_and_async +@pytest.mark.respx(base_url=base_url) +async def test_tool_result_text_with_a_lone_surrogate_does_not_break_the_next_request( + sync: bool, + client: Anthropic, + async_client: AsyncAnthropic, + respx_mock: MockRouter, + caplog: pytest.LogCaptureFixture, +) -> None: + # A tool's plain string result is sent as it was returned, except that an unpaired surrogate + # in it is repaired so the request can be encoded at all. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _tool_use_response("get_weather", "toolu_surrogate"), + _end_turn_response(), + ] + ) + + def run(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResultType: # noqa: ARG001 + return "ok \ud800 done" + + with caplog.at_level(logging.CRITICAL, logger="anthropic.lib.tools._beta_runner"): + results = await _run_tool_use( + (client if sync else async_client).with_options(max_retries=0), + tools=[_weather_tool(sync, run)], + messages=[{"role": "user", "content": "What is the weather in SF?"}], + ) + + assert len(respx_mock.calls) == 2, "the follow-up request carrying the tool_result was sent" + [result_message] = results + [tool_result] = cast(List[BetaToolResultBlockParam], result_message["content"]) + assert tool_result.get("is_error") is None + content = tool_result.get("content") + assert isinstance(content, str) + content.encode("utf-8") # well formed: raises on a lone surrogate + assert content == "ok \ufffd done" + sent = json.loads(respx_mock.calls.last.request.content) + assert sent["messages"][-1]["content"][0]["content"] == content + + +@pytest.mark.skipif(PYDANTIC_V1, reason="tool runner not supported with pydantic v1") +@_sync_and_async +@pytest.mark.respx(base_url=base_url) +async def test_a_tool_that_returns_none_sends_a_null_tool_result( + sync: bool, + client: Anthropic, + async_client: AsyncAnthropic, + respx_mock: MockRouter, + caplog: pytest.LogCaptureFixture, +) -> None: + # A tool with nothing to say returns None; the tool_result carries content null, not an error. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _tool_use_response("get_weather", "toolu_none"), + _end_turn_response(), + ] + ) + + def run(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResultType: # noqa: ARG001 + return cast(BetaFunctionToolResultType, None) # what a tool with no return statement hands the runner + + with caplog.at_level(logging.CRITICAL, logger="anthropic.lib.tools._beta_runner"): + results = await _run_tool_use( + (client if sync else async_client).with_options(max_retries=0), + tools=[_weather_tool(sync, run)], + messages=[{"role": "user", "content": "What is the weather in SF?"}], + ) + + assert len(respx_mock.calls) == 2, "the follow-up request carrying the tool_result was sent" + [result_message] = results + [tool_result] = cast(List[BetaToolResultBlockParam], result_message["content"]) + assert tool_result.get("is_error") is None + assert tool_result.get("content") is None + sent = json.loads(respx_mock.calls.last.request.content) + assert sent["messages"][-1]["content"][0]["content"] is None + + def _get_weather(location: str, units: Literal["c", "f"]) -> Dict[str, Any]: # Simulate a weather API call print(f"Fetching weather for {location} in {units}") diff --git a/tests/lib/tools/test_session_runner.py b/tests/lib/tools/test_session_runner.py index e80fdd7bf..06d5d787d 100644 --- a/tests/lib/tools/test_session_runner.py +++ b/tests/lib/tools/test_session_runner.py @@ -339,6 +339,32 @@ async def echo(input: dict[str, Any]) -> str: assert sent["is_error"] is False +@pytest.mark.asyncio() +async def test_a_tool_that_returns_none_posts_no_output_and_the_loop_goes_on() -> None: + # A tool with nothing to say returns None: the result reads "(no output)", it is not an error, and the next + # call in the stream is still dispatched. + async def silent(_input: dict[str, Any]) -> Any: + return None + + async def echo(input: dict[str, Any]) -> str: + return f"got {input.get('x')}" + + events = FakeAsyncEvents( + stream_events=[_tool_use("tu_1", "silent", {}), _tool_use("tu_2", "echo", {"x": 1}), _terminated()] + ) + + items = [ + item + async for item in _run_with_fakes(events=events, tools=[_FakeTool("silent", silent), _FakeTool("echo", echo)]) + ] + + assert [item.tool_use_id for item in items] == ["tu_1", "tu_2"] + assert items[0].is_error is False and items[0].posted is True + assert _result_text(items[0]) == "(no output)" + assert _result_text(items[1]) == "got 1" + assert len(events.send_calls) == 2 + + @pytest.mark.asyncio() async def test_yields_error_for_failing_tool() -> None: async def boom(_input: dict[str, Any]) -> str: diff --git a/tests/lib/tools/toolsets/__init__.py b/tests/lib/tools/toolsets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/tools/toolsets/_computer_fakes.py b/tests/lib/tools/toolsets/_computer_fakes.py new file mode 100644 index 000000000..9242456d1 --- /dev/null +++ b/tests/lib/tools/toolsets/_computer_fakes.py @@ -0,0 +1,185 @@ +"""In-memory computer toolsets for the computer toolset tests: a handful of members over a scripted desktop that +tracks the cursor and logs every call. The sync and async fakes drive the same `Desktop`.""" + +from __future__ import annotations + +from typing import Any +from typing_extensions import override + +from anthropic.types.beta import ( + BetaComputerKeyInput, + BetaComputerTypeInput, + BetaComputerWaitInput, + BetaComputerZoomInput, + BetaComputerScrollInput, + BetaComputerHoldKeyInput, + BetaComputerLeftClickInput, + BetaComputerMouseMoveInput, + BetaComputerScreenshotInput, + BetaComputerLeftClickDragInput, + BetaComputerCursorPositionInput, +) +from anthropic.tools.computer import ( + BetaScreenshotResult, + BetaToolsetCallContext, + BetaComputerCursorPositionResult, + BetaAbstractComputerToolset20260801, + BetaAsyncAbstractComputerToolset20260801, +) + +PNG = "iVBORw0KGgo=" + + +class Desktop: + """The desktop both fakes drive: the cursor, a call log, and what each member hands back.""" + + def __init__(self) -> None: + self.cursor = (0, 0) + self.calls: list[str] = [] + self.inputs: list[Any] = [] + self.contexts: list[BetaToolsetCallContext] = [] + self.fail: dict[str, BaseException] = {} + self.results: dict[str, Any] = {} # a member name -> the result to hand back instead of the usual one + + def do(self, name: str, context: BetaToolsetCallContext, input: Any) -> Any: + self.calls.append(name) + self.inputs.append(input) + self.contexts.append(context) + if name in self.fail: + raise self.fail[name] + return self.results.get(name) + + # member bodies ------------------------------------------------------------------------- + + def screenshot(self, context: BetaToolsetCallContext, input: BetaComputerScreenshotInput) -> BetaScreenshotResult: + return self.do("screenshot", context, input) or BetaScreenshotResult(data=PNG) + + def zoom(self, context: BetaToolsetCallContext, input: BetaComputerZoomInput) -> BetaScreenshotResult: + return self.do("zoom", context, input) or BetaScreenshotResult(data=PNG, media_type="image/jpeg") + + def cursor_position( + self, context: BetaToolsetCallContext, input: BetaComputerCursorPositionInput + ) -> BetaComputerCursorPositionResult: + x, y = self.cursor + return self.do("cursor_position", context, input) or BetaComputerCursorPositionResult(x=x, y=y) + + def mouse_move(self, context: BetaToolsetCallContext, input: BetaComputerMouseMoveInput) -> str | None: + self.cursor = (input.coordinate[0], input.coordinate[1]) + return self.do("mouse_move", context, input) + + def action(self, name: str, context: BetaToolsetCallContext, input: Any) -> Any: + return self.do(name, context, input) # a pure action may hand back one line of text + + +def approve(context: object) -> bool: # noqa: ARG001 + return True + + +class FakeDesktop(BetaAbstractComputerToolset20260801): + def __init__(self, desktop: Desktop | None = None, **options: Any) -> None: + self.desktop = desktop or Desktop() + # the toolset serves `type`, `key` and `hold_key`, so it needs a `confirm`; a test that wants none passes `None` + # and turns `type`, `key` and `hold_key` off + options.setdefault("confirm", approve) + super().__init__(**options) + + @override + def screenshot(self, context: BetaToolsetCallContext, input: BetaComputerScreenshotInput) -> BetaScreenshotResult: + return self.desktop.screenshot(context, input) + + @override + def zoom(self, context: BetaToolsetCallContext, input: BetaComputerZoomInput) -> BetaScreenshotResult: + return self.desktop.zoom(context, input) + + @override + def cursor_position( + self, context: BetaToolsetCallContext, input: BetaComputerCursorPositionInput + ) -> BetaComputerCursorPositionResult: + return self.desktop.cursor_position(context, input) + + @override + def mouse_move(self, context: BetaToolsetCallContext, input: BetaComputerMouseMoveInput) -> str | None: + return self.desktop.mouse_move(context, input) + + @override + def left_click(self, context: BetaToolsetCallContext, input: BetaComputerLeftClickInput) -> str | None: + return self.desktop.action("left_click", context, input) + + @override + def left_click_drag(self, context: BetaToolsetCallContext, input: BetaComputerLeftClickDragInput) -> None: + self.desktop.action("left_click_drag", context, input) + + @override + def type(self, context: BetaToolsetCallContext, input: BetaComputerTypeInput) -> None: + self.desktop.action("type", context, input) + + @override + def key(self, context: BetaToolsetCallContext, input: BetaComputerKeyInput) -> None: + self.desktop.action("key", context, input) + + @override + def hold_key(self, context: BetaToolsetCallContext, input: BetaComputerHoldKeyInput) -> None: + self.desktop.action("hold_key", context, input) + + @override + def scroll(self, context: BetaToolsetCallContext, input: BetaComputerScrollInput) -> None: + self.desktop.action("scroll", context, input) + + @override + def wait(self, context: BetaToolsetCallContext, input: BetaComputerWaitInput) -> None: + self.desktop.action("wait", context, input) + + +class AsyncFakeDesktop(BetaAsyncAbstractComputerToolset20260801): + def __init__(self, desktop: Desktop | None = None, **options: Any) -> None: + self.desktop = desktop or Desktop() + options.setdefault("confirm", approve) + super().__init__(**options) + + @override + async def screenshot( + self, context: BetaToolsetCallContext, input: BetaComputerScreenshotInput + ) -> BetaScreenshotResult: + return self.desktop.screenshot(context, input) + + @override + async def zoom(self, context: BetaToolsetCallContext, input: BetaComputerZoomInput) -> BetaScreenshotResult: + return self.desktop.zoom(context, input) + + @override + async def cursor_position( + self, context: BetaToolsetCallContext, input: BetaComputerCursorPositionInput + ) -> BetaComputerCursorPositionResult: + return self.desktop.cursor_position(context, input) + + @override + async def mouse_move(self, context: BetaToolsetCallContext, input: BetaComputerMouseMoveInput) -> str | None: + return self.desktop.mouse_move(context, input) + + @override + async def left_click(self, context: BetaToolsetCallContext, input: BetaComputerLeftClickInput) -> str | None: + return self.desktop.action("left_click", context, input) + + @override + async def left_click_drag(self, context: BetaToolsetCallContext, input: BetaComputerLeftClickDragInput) -> None: + self.desktop.action("left_click_drag", context, input) + + @override + async def type(self, context: BetaToolsetCallContext, input: BetaComputerTypeInput) -> None: + self.desktop.action("type", context, input) + + @override + async def key(self, context: BetaToolsetCallContext, input: BetaComputerKeyInput) -> None: + self.desktop.action("key", context, input) + + @override + async def hold_key(self, context: BetaToolsetCallContext, input: BetaComputerHoldKeyInput) -> None: + self.desktop.action("hold_key", context, input) + + @override + async def scroll(self, context: BetaToolsetCallContext, input: BetaComputerScrollInput) -> None: + self.desktop.action("scroll", context, input) + + @override + async def wait(self, context: BetaToolsetCallContext, input: BetaComputerWaitInput) -> None: + self.desktop.action("wait", context, input) diff --git a/tests/lib/tools/toolsets/_fakes.py b/tests/lib/tools/toolsets/_fakes.py new file mode 100644 index 000000000..508d3adbb --- /dev/null +++ b/tests/lib/tools/toolsets/_fakes.py @@ -0,0 +1,328 @@ +"""In-memory browser toolsets for the pipeline tests: a handful of members over a scripted tab list, +with a queue of state changes the test can load before a call. The sync and async fakes drive the +same `World`. The inputs and helpers the tests share when reading a call's result sit at the bottom.""" + +from __future__ import annotations + +from typing import Any, cast +from typing_extensions import override + +import pytest + +from anthropic.tools import ( + ToolError, +) +from anthropic.types.beta import ( + BetaBrowserKeyInput, + BetaBrowserWaitInput, + BetaBrowserNewTabInput, + BetaBrowserScrollInput, + BetaBrowserHoldKeyInput, + BetaBrowserCloseTabInput, + BetaBrowserListTabsInput, + BetaBrowserNavigateInput, + BetaBrowserScrollToInput, + BetaBrowserLeftClickInput, + BetaBrowserSwitchTabInput, + BetaBrowserFileUploadInput, + BetaBrowserScreenshotInput, + BetaBrowserGetPageTextInput, + BetaBrowserStateTabEntryParam, +) +from anthropic.tools.browser import ( + BetaBrowserState, + BetaConfirmContext, + BetaScreenshotResult, + BetaToolsetCallContext, + BetaBrowserNavigateResult, + BetaAbstractBrowserToolset20260801, + BetaAsyncAbstractBrowserToolset20260801, +) +from anthropic.lib.tools._toolsets._results import StateChange + + +class World: + """The browser both fakes drive: tabs, the active tab, queued state changes and a call log.""" + + def __init__(self) -> None: + self.tabs: dict[str, dict[str, str]] = {"tab_1": {"title": "Blank", "url": "about:blank"}} + self.active: str | None = "tab_1" + self.changes: list[StateChange] = [] + self.calls: list[str] = [] + self.inputs: list[Any] = [] + self.contexts: list[BetaToolsetCallContext] = [] + self.state_contexts: list[BetaToolsetCallContext] = [] + self.page_text = "Hello" + self.fail: dict[str, BaseException] = {} + self.state_error: BaseException | None = None + self.state_override: BetaBrowserState | None = None + + # a member name -> the result to hand back instead of the usual one; tests stash off-type values on purpose + self.results: dict[str, Any] = {} + self.next_tab = 2 + + def state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + self.state_contexts.append(context) + + if self.state_error is not None: + raise self.state_error + if self.state_override is not None: + return self.state_override + + changes, self.changes = self.changes, [] + tabs: list[BetaBrowserStateTabEntryParam] = [ + {"tab_id": tab_id, "title": tab["title"], "url": tab["url"], "active": tab_id == self.active} + for tab_id, tab in self.tabs.items() + ] + return BetaBrowserState(tabs=tabs, state_changes=changes) + + def do(self, name: str, context: BetaToolsetCallContext, input: Any) -> None: + self.calls.append(name) + self.inputs.append(input) + self.contexts.append(context) + if name in self.fail: + raise self.fail[name] + + def tab(self, tab_id: str | None) -> str: + key = tab_id or self.active + if key is None or key not in self.tabs: + raise ToolError(f"No open tab with tab_id {tab_id!r}.") + return key + + # member bodies ------------------------------------------------------------------------- + + def navigate(self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput) -> BetaBrowserNavigateResult: + self.do("navigate", context, input) + key = self.tab(input.tab_id) + + # a history word moves within the tab's history; like a real browser the fake then reports the page it is on + url = self.tabs.get(key, {}).get("url", "about:blank") if input.url in ("back", "forward") else input.url + self.tabs[key] = {"title": f"Title of {url}", "url": url} + + if "navigate" in self.results: + return self.results["navigate"] + return BetaBrowserNavigateResult(url=url, status=200, title=self.tabs[key]["title"]) + + def screenshot(self, context: BetaToolsetCallContext, input: BetaBrowserScreenshotInput) -> BetaScreenshotResult: + self.do("screenshot", context, input) + if "screenshot" in self.results: + return self.results["screenshot"] + return BetaScreenshotResult(data="iVBORw0KGgo=") + + def action(self, name: str, context: BetaToolsetCallContext, input: Any) -> Any: + self.do(name, context, input) + return self.results.get(name) # a pure action may hand back one line of text + + def get_page_text(self, context: BetaToolsetCallContext, input: BetaBrowserGetPageTextInput) -> str: + self.do("get_page_text", context, input) + return self.page_text + + def new_tab(self, context: BetaToolsetCallContext, input: BetaBrowserNewTabInput) -> BetaBrowserStateTabEntryParam: + self.do("new_tab", context, input) + + if "new_tab" in self.results: + return self.results["new_tab"] + + tab_id = f"tab_{self.next_tab}" + self.next_tab += 1 + self.tabs[tab_id] = {"title": "", "url": "about:blank"} + self.active = tab_id + self.changes.append({"type": "tab_opened", "tab_id": tab_id}) + return {"tab_id": tab_id, "title": "", "url": "about:blank", "active": True} + + def list_tabs( + self, context: BetaToolsetCallContext, input: BetaBrowserListTabsInput + ) -> list[BetaBrowserStateTabEntryParam]: + self.do("list_tabs", context, input) + return [ + {"tab_id": tab_id, "title": tab["title"], "url": tab["url"], "active": tab_id == self.active} + for tab_id, tab in self.tabs.items() + ] + + def switch_tab( + self, context: BetaToolsetCallContext, input: BetaBrowserSwitchTabInput + ) -> BetaBrowserStateTabEntryParam: + self.do("switch_tab", context, input) + key = self.tab(input.tab_id) + self.active = key + return {"tab_id": key, "title": self.tabs[key]["title"], "url": self.tabs[key]["url"], "active": True} + + def close_tab(self, context: BetaToolsetCallContext, input: BetaBrowserCloseTabInput) -> None: + self.do("close_tab", context, input) + key = self.tab(input.tab_id) + del self.tabs[key] + if self.active == key: + self.active = next(iter(self.tabs), None) + + +class FakeBrowser(BetaAbstractBrowserToolset20260801): + def __init__(self, world: World | None = None, **options: Any) -> None: + self.world = world or World() + super().__init__(**options) + + @override + def _browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + return self.world.state(context) + + @override + def navigate(self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput) -> BetaBrowserNavigateResult: + return self.world.navigate(context, input) + + @override + def screenshot(self, context: BetaToolsetCallContext, input: BetaBrowserScreenshotInput) -> BetaScreenshotResult: + return self.world.screenshot(context, input) + + @override + def left_click(self, context: BetaToolsetCallContext, input: BetaBrowserLeftClickInput) -> str | None: + return self.world.action("left_click", context, input) + + @override + def scroll(self, context: BetaToolsetCallContext, input: BetaBrowserScrollInput) -> None: + self.world.action("scroll", context, input) + + @override + def scroll_to(self, context: BetaToolsetCallContext, input: BetaBrowserScrollToInput) -> None: + self.world.action("scroll_to", context, input) + + @override + def key(self, context: BetaToolsetCallContext, input: BetaBrowserKeyInput) -> None: + self.world.action("key", context, input) + + @override + def hold_key(self, context: BetaToolsetCallContext, input: BetaBrowserHoldKeyInput) -> None: + self.world.action("hold_key", context, input) + + @override + def wait(self, context: BetaToolsetCallContext, input: BetaBrowserWaitInput) -> None: + self.world.action("wait", context, input) + + @override + def file_upload(self, context: BetaToolsetCallContext, input: BetaBrowserFileUploadInput) -> None: + self.world.action("file_upload", context, input) + + @override + def get_page_text(self, context: BetaToolsetCallContext, input: BetaBrowserGetPageTextInput) -> str: + return self.world.get_page_text(context, input) + + @override + def new_tab(self, context: BetaToolsetCallContext, input: BetaBrowserNewTabInput) -> BetaBrowserStateTabEntryParam: + return self.world.new_tab(context, input) + + @override + def list_tabs( + self, context: BetaToolsetCallContext, input: BetaBrowserListTabsInput + ) -> list[BetaBrowserStateTabEntryParam]: + return self.world.list_tabs(context, input) + + @override + def switch_tab( + self, context: BetaToolsetCallContext, input: BetaBrowserSwitchTabInput + ) -> BetaBrowserStateTabEntryParam: + return self.world.switch_tab(context, input) + + @override + def close_tab(self, context: BetaToolsetCallContext, input: BetaBrowserCloseTabInput) -> None: + self.world.close_tab(context, input) + + +class AsyncFakeBrowser(BetaAsyncAbstractBrowserToolset20260801): + def __init__(self, world: World | None = None, **options: Any) -> None: + self.world = world or World() + super().__init__(**options) + + @override + async def _browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + return self.world.state(context) + + @override + async def navigate( + self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput + ) -> BetaBrowserNavigateResult: + return self.world.navigate(context, input) + + @override + async def screenshot( + self, context: BetaToolsetCallContext, input: BetaBrowserScreenshotInput + ) -> BetaScreenshotResult: + return self.world.screenshot(context, input) + + @override + async def left_click(self, context: BetaToolsetCallContext, input: BetaBrowserLeftClickInput) -> str | None: + return self.world.action("left_click", context, input) + + @override + async def scroll(self, context: BetaToolsetCallContext, input: BetaBrowserScrollInput) -> None: + self.world.action("scroll", context, input) + + @override + async def scroll_to(self, context: BetaToolsetCallContext, input: BetaBrowserScrollToInput) -> None: + self.world.action("scroll_to", context, input) + + @override + async def key(self, context: BetaToolsetCallContext, input: BetaBrowserKeyInput) -> None: + self.world.action("key", context, input) + + @override + async def hold_key(self, context: BetaToolsetCallContext, input: BetaBrowserHoldKeyInput) -> None: + self.world.action("hold_key", context, input) + + @override + async def wait(self, context: BetaToolsetCallContext, input: BetaBrowserWaitInput) -> None: + self.world.action("wait", context, input) + + @override + async def file_upload(self, context: BetaToolsetCallContext, input: BetaBrowserFileUploadInput) -> None: + self.world.action("file_upload", context, input) + + @override + async def get_page_text(self, context: BetaToolsetCallContext, input: BetaBrowserGetPageTextInput) -> str: + return self.world.get_page_text(context, input) + + @override + async def new_tab( + self, context: BetaToolsetCallContext, input: BetaBrowserNewTabInput + ) -> BetaBrowserStateTabEntryParam: + return self.world.new_tab(context, input) + + @override + async def list_tabs( + self, context: BetaToolsetCallContext, input: BetaBrowserListTabsInput + ) -> list[BetaBrowserStateTabEntryParam]: + return self.world.list_tabs(context, input) + + @override + async def switch_tab( + self, context: BetaToolsetCallContext, input: BetaBrowserSwitchTabInput + ) -> BetaBrowserStateTabEntryParam: + return self.world.switch_tab(context, input) + + @override + async def close_tab(self, context: BetaToolsetCallContext, input: BetaBrowserCloseTabInput) -> None: + self.world.close_tab(context, input) + + +# shared inputs and result readers ---------------------------------------------------------------- + +CLICK: dict[str, Any] = {"target": {"type": "coordinate", "x": 1, "y": 1}} +UPLOAD: dict[str, Any] = {"target": {"type": "ref", "ref": "e1"}} +TAB_GONE = "The tab this call was addressed to is not open; nothing was returned. Use list_tabs to see what is open." + + +def approve(_context: BetaConfirmContext) -> bool: + return True + + +def texts(content: Any) -> list[str]: + return [block["text"] for block in content if block.get("type") == "text"] + + +def state_block(content: Any) -> dict[str, Any]: + (block,) = [b for b in content if b.get("type") == "browser_state"] + return cast(dict[str, Any], block) + + +def refusal(browser: FakeBrowser, context: BetaToolsetCallContext, name: str, input: Any) -> str: + with pytest.raises(ToolError) as caught: + browser.call(context, name, input) + (text,) = texts(caught.value.content) + return text diff --git a/tests/lib/tools/toolsets/_stubs.py b/tests/lib/tools/toolsets/_stubs.py new file mode 100644 index 000000000..8ad2a17c7 --- /dev/null +++ b/tests/lib/tools/toolsets/_stubs.py @@ -0,0 +1,88 @@ +"""Stub toolsets shared by the toolset tests: they record calls and echo the member name plus input, +and raise on the `boom` / `crash` / `misuse` members.""" + +from __future__ import annotations + +import json +from typing import Any +from typing_extensions import override + +from anthropic.lib.tools import ToolError +from anthropic.lib.tools._toolsets import ( + BetaToolsetParam, + BetaToolsetContent, + BetaRunnableToolset, + ToolsetContractError, + BetaToolsetCallContext, + BetaAsyncRunnableToolset, +) + +BROWSER_ENTRY: BetaToolsetParam = { + "type": "browser_toolset_20260801", + "configs": {"javascript_exec": {"enabled": True}}, +} + + +def _echo(name: str, input: object) -> str | BetaToolsetContent: + if name == "boom": + raise ToolError( + [ + {"type": "text", "text": "member refused"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AAAA"}}, + ] + ) + if name == "blank": + raise ToolError([{"type": "text", "text": ""}]) # an empty text block only + if name == "crash": + raise RuntimeError("backend exploded") + if name == "misuse": + raise ToolsetContractError("the driver misused the SDK surface") + return f"{name}:{json.dumps(input, sort_keys=True)}" + + +class EchoBrowserToolset(BetaRunnableToolset): + toolset_name = "browser" + + def __init__(self) -> None: + self.calls: list[tuple[str, Any]] = [] + self.contexts: list[BetaToolsetCallContext] = [] + self.closed = 0 + + @override + def to_dict(self) -> BetaToolsetParam: + return BROWSER_ENTRY + + @override + def call(self, context: BetaToolsetCallContext, name: str, input: object) -> str | BetaToolsetContent: + self.calls.append((name, input)) + self.contexts.append(context) + return _echo(name, input) + + @override + def close(self) -> None: + self.closed += 1 + super().close() + + +class AsyncEchoBrowserToolset(BetaAsyncRunnableToolset): + toolset_name = "browser" + + def __init__(self) -> None: + self.calls: list[tuple[str, Any]] = [] + self.contexts: list[BetaToolsetCallContext] = [] + self.closed = 0 + + @override + def to_dict(self) -> BetaToolsetParam: + return BROWSER_ENTRY + + @override + async def call(self, context: BetaToolsetCallContext, name: str, input: object) -> str | BetaToolsetContent: + self.calls.append((name, input)) + self.contexts.append(context) + return _echo(name, input) + + @override + async def close(self) -> None: + self.closed += 1 + await super().close() diff --git a/tests/lib/tools/toolsets/computer_toolset_20260801.json b/tests/lib/tools/toolsets/computer_toolset_20260801.json new file mode 100644 index 000000000..5c5dce3ec --- /dev/null +++ b/tests/lib/tools/toolsets/computer_toolset_20260801.json @@ -0,0 +1,24 @@ +{ + "type": "computer_toolset_20260801", + "toolset_name": "computer", + "members": [ + "key", + "hold_key", + "type", + "cursor_position", + "mouse_move", + "left_mouse_down", + "left_mouse_up", + "left_click", + "left_click_drag", + "right_click", + "middle_click", + "double_click", + "triple_click", + "scroll", + "wait", + "screenshot", + "zoom" + ], + "default_disabled": [] +} diff --git a/tests/lib/tools/toolsets/test_browser_runner.py b/tests/lib/tools/toolsets/test_browser_runner.py new file mode 100644 index 000000000..86180a543 --- /dev/null +++ b/tests/lib/tools/toolsets/test_browser_runner.py @@ -0,0 +1,539 @@ +"""A browser toolset through `client.beta.messages.tool_runner` with the Messages responses mocked.""" + +from __future__ import annotations + +import os +import json +from typing import Any, cast + +import httpx2 +import pytest +from respx import MockRouter + +from anthropic import Anthropic, AsyncAnthropic, beta_tool, beta_async_tool +from anthropic.tools import ToolsetContractError +from anthropic._compat import PYDANTIC_V1 + +from ._fakes import FakeBrowser, AsyncFakeBrowser + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + +pytestmark = [pytest.mark.respx(base_url=base_url)] +needs_runner = pytest.mark.skipif(PYDANTIC_V1, reason="tool runner not supported with pydantic v1") + + +@pytest.fixture +def client(client: Anthropic) -> Anthropic: # the shared client (tests/conftest.py), without retries + return client.with_options(max_retries=0) + + +@pytest.fixture +def async_client(async_client: AsyncAnthropic) -> AsyncAnthropic: + return async_client.with_options(max_retries=0) + + +def _message(*blocks: dict[str, Any], stop: str = "tool_use") -> httpx2.Response: + return httpx2.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": list(blocks), + "stop_reason": stop, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + +def _member_use(name: str, id: str, input: dict[str, Any]) -> dict[str, Any]: + return {"type": "tool_use", "id": id, "name": name, "input": input, "toolset_name": "browser"} + + +DONE = _message({"type": "text", "text": "Done."}, stop="end_turn") + + +def _requests(respx_mock: MockRouter) -> list[dict[str, Any]]: + calls = cast(list[Any], list(respx_mock.calls)) + return [cast(dict[str, Any], json.loads(call.request.content)) for call in calls] + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_member_calls_round_trip_through_the_runner( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + respx_mock.post("/v1/messages").mock( + side_effect=[ + _message( + _member_use("navigate", "toolu_nav", {"url": "https://example.com/"}), + _member_use("teleport", "toolu_bad", {}), + ), + DONE, + ] + ) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + browser = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + runner = make( + max_tokens=1024, model="claude-haiku-4-5", tools=[browser], messages=[{"role": "user", "content": "go"}] + ) + + done = runner.until_done() + if flavour == "async": + await done + + first, second = _requests(respx_mock) + # The entry on the wire is exactly to_dict(): the type plus enabled:false for unimplemented members. + assert first["tools"] == [browser.to_dict()] + assert "browser-toolset" in cast(Any, respx_mock.calls[0]).request.headers["x-stainless-helper"].split(", ") + results = second["messages"][-1]["content"] + assert results[0]["tool_use_id"] == "toolu_nav" and results[0]["toolset_name"] == "browser" + assert [b["type"] for b in results[0]["content"]] == ["text", "browser_state"] + assert "is_error" not in results[0] + assert results[1] == { + "type": "tool_result", + "tool_use_id": "toolu_bad", + "toolset_name": "browser", + "content": [{"type": "text", "text": "Error: unknown browser toolset member 'teleport'"}], + "is_error": True, + } + # The member and the state hook both saw the tool_use they were answering. + assert browser.world.contexts[0].tool_use is not None and browser.world.contexts[0].tool_use.id == "toolu_nav" + + +@needs_runner +async def test_member_calls_round_trip_through_the_async_runner( + respx_mock: MockRouter, async_client: AsyncAnthropic +) -> None: + respx_mock.post("/v1/messages").mock(side_effect=[_message(_member_use("get_page_text", "toolu_txt", {})), DONE]) + browser = AsyncFakeBrowser() + runner = async_client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=[browser], messages=[{"role": "user", "content": "go"}] + ) + await runner.until_done() + + results = _requests(respx_mock)[1]["messages"][-1]["content"] + assert results[0]["content"][0] == {"type": "text", "text": "Hello"} + assert results[0]["content"][1]["type"] == "browser_state" + + +@needs_runner +def test_a_toolset_of_the_other_flavour_is_refused_when_the_runner_is_built( + client: Anthropic, async_client: AsyncAnthropic +) -> None: + messages: Any = [{"role": "user", "content": "go"}] + with pytest.raises( + ToolsetContractError, match=r"Received an async tool or toolset \(AsyncFakeBrowser\) in the synchronous" + ): + client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=cast(Any, [AsyncFakeBrowser()]), messages=messages + ) + + with pytest.raises( + ToolsetContractError, match=r"Received a synchronous tool or toolset \(FakeBrowser\) in the asynchronous" + ): + async_client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=cast(Any, [FakeBrowser()]), messages=messages + ) + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_a_toolset_in_set_messages_params_is_sent_as_its_wire_entry( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + respx_mock.post("/v1/messages").mock(return_value=DONE) + + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + mine = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + + runner = make(model="claude-test", max_tokens=64, messages=[{"role": "user", "content": "hi"}], tools=[mine]) + raw: dict[str, Any] = {"type": "custom", "name": "raw", "input_schema": {"type": "object"}} + runner.set_messages_params(_with_tools(mine, raw)) + + done = runner.until_done() + if flavour == "async": + await done + assert _requests(respx_mock)[0]["tools"] == [mine.to_dict(), raw] + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_add_tools_refuses_a_toolset_and_queues_nothing( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + respx_mock.post("/v1/messages").mock(return_value=DONE) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + runner = make(model="claude-test", max_tokens=64, messages=[{"role": "user", "content": "hi"}], tools=[]) + toolset = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + + with pytest.raises( + ToolsetContractError, + match=r"^add_tools\(\) can't add the 'browser' toolset: a tool runner's toolsets are fixed", + ): + runner.add_tools(_noop_tool(flavour, "get_time"), toolset) + done = runner.until_done() + if flavour == "async": + await done + + assert _requests(respx_mock)[0]["messages"] == [{"role": "user", "content": "hi"}] + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +@pytest.mark.parametrize("removed", ["toolset", "its_name", "a_function_tool_named_like_it"]) +async def test_remove_tools_refuses_a_toolset_and_queues_nothing( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str, removed: str +) -> None: + respx_mock.post("/v1/messages").mock(return_value=DONE) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + browser = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + get_time = _noop_tool(flavour, "get_time") + runner = make( + model="claude-test", max_tokens=64, messages=[{"role": "user", "content": "hi"}], tools=[browser, get_time] + ) + + refused = { + "toolset": browser, + "its_name": "browser", + "a_function_tool_named_like_it": _noop_tool(flavour, "browser"), + } + with pytest.raises( + ToolsetContractError, + match=r"^remove_tools\(\) can't remove the 'browser' toolset: a tool runner's toolsets are fixed when it is created; build a new runner without it$", + ): + runner.remove_tools(get_time, refused[removed]) + done = runner.until_done() + if flavour == "async": + await done + + assert _requests(respx_mock)[0]["messages"] == [{"role": "user", "content": "hi"}] + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +@pytest.mark.parametrize("definition_type", ["browser_toolset_20260801", "browser_toolset_20270101"]) +async def test_add_tools_refuses_a_raw_definition_of_the_runners_toolset_and_queues_nothing( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str, definition_type: str +) -> None: + respx_mock.post("/v1/messages").mock(return_value=DONE) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + browser = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + runner = make(model="claude-test", max_tokens=64, messages=[{"role": "user", "content": "hi"}], tools=[browser]) + + with pytest.raises(ToolsetContractError, match=r"^add_tools\(\) can't replace the 'browser' toolset: "): + runner.add_tools(_noop_tool(flavour, "get_time"), {"type": definition_type}) + done = runner.until_done() + if flavour == "async": + await done + + assert _requests(respx_mock)[0]["messages"] == [{"role": "user", "content": "hi"}] + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +@pytest.mark.parametrize("added", ["function_tool", "raw_definition"]) +async def test_add_tools_refuses_a_tool_named_like_the_runners_toolset_and_queues_nothing( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str, added: str +) -> None: + respx_mock.post("/v1/messages").mock(return_value=DONE) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + browser = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + runner = make(model="claude-test", max_tokens=64, messages=[{"role": "user", "content": "hi"}], tools=[browser]) + named: Any = ( + _noop_tool(flavour, "browser") + if added == "function_tool" + else {"name": "browser", "description": "A tool.", "input_schema": {"type": "object"}} + ) + + with pytest.raises( + ToolsetContractError, + match=r"^add_tools\(\) can't add a tool named 'browser': the runner's browser toolset has that name; give the tool another name$", + ): + runner.add_tools(_noop_tool(flavour, "get_time"), named) + done = runner.until_done() + if flavour == "async": + await done + + assert _requests(respx_mock)[0]["messages"] == [{"role": "user", "content": "hi"}] + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_add_tools_sends_a_raw_toolset_definition_of_another_family( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + respx_mock.post("/v1/messages").mock(return_value=DONE) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + browser = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + runner = make(model="claude-test", max_tokens=64, messages=[{"role": "user", "content": "hi"}], tools=[browser]) + computer: Any = {"type": "computer_toolset_20260801"} + + runner.add_tools(computer) + done = runner.until_done() + if flavour == "async": + await done + + assert _requests(respx_mock)[0]["messages"][-1] == { + "role": "system", + "content": [{"type": "tool_addition", "tool": {"type": "tool_definition", "definition": computer}}], + } + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_remove_tools_removes_a_function_tool_named_like_a_member( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + respx_mock.post("/v1/messages").mock(return_value=DONE) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + browser = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + runner = make( + model="claude-test", + max_tokens=64, + messages=[{"role": "user", "content": "hi"}], + tools=[browser, _noop_tool(flavour, "navigate")], + ) + + runner.remove_tools("navigate") + done = runner.until_done() + if flavour == "async": + await done + + assert _requests(respx_mock)[0]["messages"][-1] == { + "role": "system", + "content": [{"type": "tool_removal", "tool": {"type": "tool_reference", "name": "navigate"}}], + } + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_remove_tools_with_a_member_name_removes_only_the_function_tool_of_that_name( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + respx_mock.post("/v1/messages").mock( + side_effect=[ + _message( + _member_use("navigate", "toolu_1", {"url": "https://a.test/"}), + {"type": "tool_use", "id": "toolu_fn", "name": "navigate", "input": {}}, + ), + DONE, + ] + ) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + browser = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + + def navigate() -> str: + return "custom" + + async def navigate_async() -> str: + return navigate() + + named_like_a_member: Any = ( + beta_tool(navigate, description="A tool named like a browser member.") + if flavour == "sync" + else beta_async_tool(navigate_async, name="navigate", description="A tool named like a browser member.") + ) + runner = make( + model="claude-test", + max_tokens=64, + messages=[{"role": "user", "content": "hi"}], + tools=[browser, named_like_a_member], + ) + runner.remove_tools("navigate") + + with pytest.warns(UserWarning, match="Tool 'navigate' not found in tool runner"): + done = runner.until_done() + if flavour == "async": + await done + + assert browser.world.calls == ["navigate"] + first, second = _requests(respx_mock) + assert first["messages"][-1] == { + "role": "system", + "content": [{"type": "tool_removal", "tool": {"type": "tool_reference", "name": "navigate"}}], + } + member_result, function_result = second["messages"][-1]["content"] + assert member_result["tool_use_id"] == "toolu_1" and "is_error" not in member_result + assert function_result == { + "type": "tool_result", + "tool_use_id": "toolu_fn", + "content": "Error: Tool 'navigate' not found", + "is_error": True, + } + + +def _noop_tool(flavour: str, name: str) -> Any: + def run() -> str: + return f"{name} ran" + + async def run_async() -> str: + return run() + + if flavour == "sync": + return beta_tool(run, name=name, description=f"Run {name}.") + return beta_async_tool(run_async, name=name, description=f"Run {name}.") + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +@pytest.mark.parametrize("with_toolset", [False, True]) +async def test_a_tools_list_is_kept_so_editing_it_in_place_reaches_the_next_request( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str, with_toolset: bool +) -> None: + respx_mock.post("/v1/messages").mock(return_value=DONE) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + mine = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + toolsets: list[Any] = [mine] if with_toolset else [] + runner = make(model="claude-test", max_tokens=64, messages=[{"role": "user", "content": "hi"}], tools=toolsets) + raw: dict[str, Any] = {"type": "custom", "name": "raw", "input_schema": {"type": "object"}} + added: dict[str, Any] = {"type": "custom", "name": "added", "input_schema": {"type": "object"}} + tools: list[Any] = [*toolsets, raw] + + def update(params: Any) -> Any: + return {**params, "tools": tools} + + runner.set_messages_params(update) + tools.append(added) + done = runner.until_done() + if flavour == "async": + await done + assert _requests(respx_mock)[0]["tools"] == [*(toolset.to_dict() for toolset in toolsets), raw, added] + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_a_function_tool_appended_to_the_list_in_place_is_sent_with_the_next_request( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + respx_mock.post("/v1/messages").mock(return_value=DONE) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + runner = make(model="claude-test", max_tokens=64, messages=[{"role": "user", "content": "hi"}], tools=[]) + + def search(query: str) -> str: + """Search for something.""" + return query + + async def async_search(query: str) -> str: + """Search for something.""" + return query + + tool = beta_tool(search) if flavour == "sync" else beta_async_tool(async_search) + raw: dict[str, Any] = {"type": "custom", "name": "raw", "input_schema": {"type": "object"}} + tools: list[Any] = [raw] + + def update(params: Any) -> Any: + return {**params, "tools": tools} + + runner.set_messages_params(update) + tools.append(tool) + done = runner.until_done() + if flavour == "async": + await done + assert _requests(respx_mock)[0]["tools"] == [raw, tool.to_dict()] + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_a_toolset_taken_out_of_the_list_in_place_is_not_sent_with_the_next_request( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + paused = _message({"type": "text", "text": "Working."}, stop="pause_turn") + respx_mock.post("/v1/messages").mock(side_effect=[paused, DONE]) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + mine = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + runner = make(model="claude-test", max_tokens=64, messages=[{"role": "user", "content": "hi"}], tools=[mine]) + raw: dict[str, Any] = {"type": "custom", "name": "raw", "input_schema": {"type": "object"}} + tools: list[Any] = [mine, raw] + + def update(params: Any) -> Any: + return {**params, "tools": tools} + + runner.set_messages_params(update) + if flavour == "sync": + for _ in runner: + if mine in tools: + tools.remove(mine) + else: + async for _ in runner: + if mine in tools: + tools.remove(mine) + assert [request["tools"] for request in _requests(respx_mock)] == [[mine.to_dict(), raw], [raw]] + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_tools_passed_as_a_one_shot_iterable_reach_every_request( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + paused = _message({"type": "text", "text": "Working."}, stop="pause_turn") + respx_mock.post("/v1/messages").mock(side_effect=[paused, DONE]) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + mine = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + runner = make(model="claude-test", max_tokens=64, messages=[{"role": "user", "content": "hi"}], tools=[mine]) + raw: dict[str, Any] = {"type": "custom", "name": "raw", "input_schema": {"type": "object"}} + + def update(params: Any) -> Any: + return {**params, "tools": (tool for tool in [mine, raw])} + + runner.set_messages_params(update) + done = runner.until_done() + if flavour == "async": + await done + assert [request["tools"] for request in _requests(respx_mock)] == [[mine.to_dict(), raw]] * 2 + + +@needs_runner +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_messages_passed_as_a_one_shot_iterable_reach_every_request( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + paused = _message({"type": "text", "text": "Working."}, stop="pause_turn") + respx_mock.post("/v1/messages").mock(side_effect=[paused, DONE]) + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + mine = FakeBrowser() if flavour == "sync" else AsyncFakeBrowser() + first = {"role": "user", "content": "hi"} + runner = make(model="claude-test", max_tokens=64, messages=[first], tools=[mine]) + + def update(params: Any) -> Any: + return {**params, "messages": (message for message in params["messages"])} + + runner.set_messages_params(update) + done = runner.until_done() + if flavour == "async": + await done + assert [request["messages"][:1] for request in _requests(respx_mock)] == [[first]] * 2 + + +def _with_tools(*tools: Any) -> Any: + def update(params: Any) -> Any: + return {**params, "tools": list(tools)} + + return update + + +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_a_toolset_instance_stands_in_tools_for_create_and_count_tokens( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + # BetaToolLike: messages.create and its siblings take the toolset instance itself and send its wire entry; the + # sync and async resources are separate methods, so both are exercised. + respx_mock.post("/v1/messages").mock(return_value=_message({"type": "text", "text": "Done."}, stop="end_turn")) + respx_mock.post("/v1/messages/count_tokens").mock(return_value=httpx2.Response(200, json={"input_tokens": 3})) + + raw: Any = {"type": "custom", "name": "raw", "input_schema": {"type": "object"}} + messages: Any = [{"role": "user", "content": "hi"}] + + if flavour == "sync": + browser: Any = FakeBrowser() + client.beta.messages.create(model="m", max_tokens=8, messages=messages, tools=[browser, raw]) + client.beta.messages.count_tokens(model="m", messages=messages, tools=[browser]) + else: + browser = AsyncFakeBrowser() + await async_client.beta.messages.create(model="m", max_tokens=8, messages=messages, tools=[browser, raw]) + await async_client.beta.messages.count_tokens(model="m", messages=messages, tools=[browser]) + + sent = [body["tools"] for body in _requests(respx_mock)] + assert sent == [[browser.to_dict(), raw], [browser.to_dict()]] diff --git a/tests/lib/tools/toolsets/test_computer_interface.py b/tests/lib/tools/toolsets/test_computer_interface.py new file mode 100644 index 000000000..4e18b6997 --- /dev/null +++ b/tests/lib/tools/toolsets/test_computer_interface.py @@ -0,0 +1,296 @@ +# ruff: noqa: ARG001 -- the confirm hook and a patched-in member ignore their arguments +"""Construction rules and the dispatch surface of the abstract computer toolset classes.""" + +from __future__ import annotations + +import re +from typing import Any, cast +from typing_extensions import override + +import pytest + +from anthropic.tools import ( + ToolError, + ToolsetConfigError, + ToolsetContractError, +) +from anthropic.types.beta import ( + BetaComputerKeyInput, + BetaComputerZoomInput, + BetaComputerScreenshotInput, + BetaComputerCursorPositionInput, +) +from anthropic.tools.computer import ( + BetaScreenshotResult, + BetaToolsetCallContext, + BetaComputerCursorPositionResult, + BetaAbstractComputerToolset20260801, + BetaAsyncAbstractComputerToolset20260801, +) +from anthropic.lib._stainless_helpers import get_helper_tag + +from ._computer_fakes import FakeDesktop, AsyncFakeDesktop + +CTX = BetaToolsetCallContext() + + +class TwoMembers(BetaAbstractComputerToolset20260801): + @override + def screenshot(self, context: BetaToolsetCallContext, input: BetaComputerScreenshotInput) -> BetaScreenshotResult: + return BetaScreenshotResult(data="AAAA") + + @override + def cursor_position( + self, context: BetaToolsetCallContext, input: BetaComputerCursorPositionInput + ) -> BetaComputerCursorPositionResult: + return BetaComputerCursorPositionResult(x=3, y=4) + + +def _allow(context: object) -> bool: + return True + + +def _disabled(entry: Any) -> list[str]: + configs = cast("dict[str, dict[str, object]]", entry.get("configs") or {}) + return sorted(name for name, config in configs.items() if (config or {}).get("enabled") is False) + + +def test_a_toolset_with_no_member_enabled_sends_every_member_disabled() -> None: + class Nothing(BetaAbstractComputerToolset20260801): + pass + + assert len(_disabled(Nothing().to_dict())) == 17 + switched_off = TwoMembers(configs={"screenshot": {"enabled": False}, "cursor_position": {"enabled": False}}) + assert len(_disabled(switched_off.to_dict())) == 17 + + +def test_unimplemented_members_are_sent_as_disabled_and_implemented_ones_left_to_the_api() -> None: + entry = TwoMembers().to_dict() + assert entry["type"] == "computer_toolset_20260801" + disabled = _disabled(entry) + assert "screenshot" not in disabled and "cursor_position" not in disabled + assert len(disabled) == 15 and "left_click" in disabled and "zoom" in disabled + # Nothing SDK-side leaks into the entry. + assert set(entry) == {"type", "configs"} + assert all(set(config) == {"enabled"} for config in cast(Any, entry)["configs"].values()) + + +def test_the_callers_configs_pass_through_untouched_and_are_copied() -> None: + configs: Any = {"screenshot": {"defer_loading": True}, "zoom": {"enabled": False, "defer_loading": True}} + desktop = TwoMembers(configs=configs) + entry = desktop.to_dict() + assert entry["configs"]["screenshot"] == {"defer_loading": True} # type: ignore[index] + assert entry["configs"]["zoom"] == {"enabled": False, "defer_loading": True} # type: ignore[index] + # Enabling a member the subclass does not implement would offer the model something that can only + # answer "not available": refused at construction. + with pytest.raises(ToolsetConfigError, match="does not implement"): + TwoMembers(configs={"zoom": {"enabled": True}}) + configs["screenshot"]["enabled"] = False + assert desktop.to_dict()["configs"]["screenshot"] == {"defer_loading": True} # type: ignore[index] + assert desktop.to_dict() is not desktop.to_dict() + + +def test_an_execute_only_subclass_serves_every_member_and_turns_members_off_with_configs() -> None: + class Forwarder(BetaAbstractComputerToolset20260801): + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return f"forwarded {name}" + + # A class that overrides execute serves every member: the wire entry disables nothing, and the driver turns off + # what it does not serve through configs. + assert Forwarder(confirm=_allow).to_dict() == {"type": "computer_toolset_20260801"} + trimmed = Forwarder( + configs={"screenshot": {"enabled": False}, "zoom": {"enabled": False}}, confirm=_allow + ).to_dict() + assert _disabled(trimmed) == ["screenshot", "zoom"] + + class Hooked(TwoMembers): + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return super().execute(context, name, input) + + assert _disabled(Hooked(confirm=_allow).to_dict()) == [] + assert Hooked(confirm=_allow).execute( + CTX, "cursor_position", BetaComputerCursorPositionInput() + ) == BetaComputerCursorPositionResult(x=3, y=4) + with pytest.raises(ToolError, match="not available"): + Hooked(confirm=_allow).execute(CTX, "zoom", BetaComputerZoomInput(region=[0, 0, 1, 1])) + assert _disabled(Hooked(configs={"zoom": {"enabled": False}}, confirm=_allow).to_dict()) == ["zoom"] + + +def test_a_null_configs_entry_leaves_the_member_at_its_defaults_and_goes_to_the_api_as_given() -> None: + entry = cast(Any, TwoMembers(configs={"screenshot": None}).to_dict()) + assert entry["configs"]["screenshot"] is None and "screenshot" not in _disabled(entry) + assert TwoMembers(configs={"screenshot": None}).call(CTX, "screenshot", {}) + + +def test_tool_configs_go_onto_the_entry_and_the_helper_tag_does_not() -> None: + desktop = TwoMembers(tool_configs={"cache_control": {"type": "ephemeral"}}) + assert desktop.to_dict().get("cache_control") == {"type": "ephemeral"} + assert get_helper_tag(desktop) == "computer-toolset" + assert "computer-toolset" not in str(desktop.to_dict()) + cast(Any, desktop.to_dict())["cache_control"]["ttl"] = "1h" + assert desktop.to_dict().get("cache_control") == {"type": "ephemeral"} + + +def test_execute_dispatches_to_the_member_method_and_refuses_unknown_names() -> None: + desktop = TwoMembers() + assert desktop.execute(CTX, "screenshot", BetaComputerScreenshotInput()) == BetaScreenshotResult(data="AAAA") + with pytest.raises(ToolError) as unknown: + desktop.execute(CTX, "__init__", BetaComputerScreenshotInput()) # type: ignore[arg-type] + assert str(unknown.value) == "Error: unknown computer toolset member '__init__'" + # A member the subclass did not implement answers the model rather than raising into the runner. + with pytest.raises(ToolError) as unavailable: + desktop.execute(CTX, "key", BetaComputerKeyInput(text="a")) + assert str(unavailable.value) == "The computer toolset member 'key' is not available in this environment." + + +def test_an_async_member_on_the_sync_class_is_a_contract_error() -> None: + class Mixed(TwoMembers): + @override + async def key(self, context: BetaToolsetCallContext, input: BetaComputerKeyInput) -> str | None: # type: ignore[override] + return None + + # reported when the toolset is built, before any call + with pytest.raises( + ToolsetContractError, + match="computer member 'key' is async on the synchronous toolset.*BetaAsyncAbstractComputer", + ): + Mixed() + + async def prompt(context: Any) -> bool: + return True + + with pytest.raises(ToolsetContractError, match="confirm is async on the synchronous toolset"): + TwoMembers(confirm=prompt) # type: ignore[arg-type] + + +async def test_async_execute_awaits_async_members_and_a_sync_one_is_a_contract_error() -> None: + class Points(BetaAsyncAbstractComputerToolset20260801): + @override + async def cursor_position( + self, context: BetaToolsetCallContext, input: BetaComputerCursorPositionInput + ) -> BetaComputerCursorPositionResult: + return BetaComputerCursorPositionResult(x=1, y=2) + + desktop = Points() + assert await desktop.execute( + CTX, "cursor_position", BetaComputerCursorPositionInput() + ) == BetaComputerCursorPositionResult(x=1, y=2) + with pytest.raises(ToolError, match="^The computer toolset member 'zoom' is not available in this environment\\.$"): + await desktop.execute(CTX, "zoom", BetaComputerZoomInput(region=[0, 0, 1, 1])) + + class SyncMember(Points): + @override + def key(self, context: BetaToolsetCallContext, input: BetaComputerKeyInput) -> str | None: # type: ignore[override] + return None + + # a member written sync on the async class is reported when the toolset is built rather than run on the event loop + with pytest.raises( + ToolsetContractError, + match="computer member 'key' is sync on the asynchronous toolset.*BetaAbstractComputer", + ): + SyncMember() + + +def test_override_detection_sees_intermediate_classes_and_dynamic_assignment() -> None: + class Middle(TwoMembers): + pass + + class Leaf(Middle): + @override + def key(self, context: BetaToolsetCallContext, input: BetaComputerKeyInput) -> str | None: + return None + + assert {"screenshot", "cursor_position", "key"}.isdisjoint(_disabled(Leaf(confirm=_allow).to_dict())) + + def wait(self: Any, context: Any, input: Any) -> None: + return None + + Patched = type("Patched", (TwoMembers,), {"wait": wait}) + assert "wait" not in _disabled(Patched().to_dict()) + + +def test_options_do_not_leak_into_the_entry() -> None: + assert set(TwoMembers(confirm=_allow).to_dict()) == {"type", "configs"} + + +def test_configs_is_a_read_only_copy_of_what_to_dict_sends() -> None: + class AsyncOne(BetaAsyncAbstractComputerToolset20260801): + @override + async def screenshot( + self, context: BetaToolsetCallContext, input: BetaComputerScreenshotInput + ) -> BetaScreenshotResult: + return BetaScreenshotResult(data="AAAA") + + for desktop in (TwoMembers(configs={"zoom": {"enabled": False}}), AsyncOne()): + configs: Any = desktop.configs + assert configs is not None and configs == cast(Any, desktop.to_dict())["configs"] + assert configs["left_click"] == {"enabled": False} # not implemented, so withheld from the model + configs["left_click"] = {"enabled": True} # a copy: nothing the toolset holds changes + assert cast(Any, desktop.configs)["left_click"] == {"enabled": False} + with pytest.raises(AttributeError): + desktop.configs = {} # type: ignore[misc] + + +# --- keyboard input needs a confirm ------------------------------------------------------------ + +KEYBOARD = ("type", "key", "hold_key") + + +def _serving(*members: str, twin: bool = False) -> Any: + """A toolset class that serves `members` and nothing else, on the synchronous class or (`twin`) the async one.""" + + def serve(self: Any, context: Any, input: Any) -> None: + return None + + async def serve_async(self: Any, context: Any, input: Any) -> None: + return None + + base: Any = BetaAsyncAbstractComputerToolset20260801 if twin else BetaAbstractComputerToolset20260801 + return type("Serving", (base,), {name: serve_async if twin else serve for name in members}) + + +@pytest.mark.parametrize("twin", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("member", KEYBOARD) +def test_a_toolset_that_serves_keyboard_input_will_not_build_without_a_confirm(member: str, twin: bool) -> None: + Desktop = _serving(member, twin=twin) + + for options in ({}, {"confirm": None}): + with pytest.raises(ToolsetConfigError, match=re.escape(f"['{member}'] requires a confirm callable")): + Desktop(**options) + assert Desktop(confirm=_allow).to_dict()["type"] == "computer_toolset_20260801" + + +def test_the_refusal_names_the_keyboard_members_still_enabled_and_says_how_to_go_on() -> None: + class Forwarder(BetaAbstractComputerToolset20260801): # an `execute` override serves every member + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return None + + def refused(**options: Any) -> str: + with pytest.raises(ToolsetConfigError) as raised: + Forwarder(**options) + return str(raised.value) + + how = "requires a confirm callable: pass confirm=, or disable it in configs" + assert refused() == f"['hold_key', 'key', 'type'] {how}" + assert refused(configs={"type": {"enabled": False}, "hold_key": {"enabled": False}}) == f"['key'] {how}" + + +@pytest.mark.parametrize("Desktop", [FakeDesktop, AsyncFakeDesktop], ids=["sync", "async"]) +def test_a_toolset_with_all_three_keyboard_members_disabled_needs_no_confirm(Desktop: Any) -> None: + off = {name: {"enabled": False} for name in KEYBOARD} + + desktop = Desktop(configs=off, confirm=None) + + assert all(desktop.configs[name] == {"enabled": False} for name in KEYBOARD) + with pytest.raises(ToolsetConfigError, match=re.escape("['hold_key', 'key', 'type'] requires")): + Desktop(confirm=None) + + +@pytest.mark.parametrize("twin", [False, True], ids=["sync", "async"]) +def test_members_that_do_not_type_need_no_confirm(twin: bool) -> None: + others = ("screenshot", "zoom", "cursor_position", "mouse_move", "left_click", "left_click_drag", "scroll", "wait") + + _serving(*others, twin=twin)() diff --git a/tests/lib/tools/toolsets/test_computer_pipeline.py b/tests/lib/tools/toolsets/test_computer_pipeline.py new file mode 100644 index 000000000..4d470c4a3 --- /dev/null +++ b/tests/lib/tools/toolsets/test_computer_pipeline.py @@ -0,0 +1,537 @@ +# ruff: noqa: ARG001 -- confirm hooks ignore their arguments +"""One computer member call through `call` and `tool_result`: rendering per result kind, input checks, the +confirm gate, the refusals the model reads, and closing — on the sync and the async class.""" + +from __future__ import annotations + +import threading +from typing import Any, cast +from typing_extensions import override + +import anyio +import pytest + +from anthropic.tools import ( + ToolError, + ConfirmFailedError, + ToolsetClosedError, + ConfirmDeclinedError, + ToolsetContractError, + UnavailableMemberError, + InvalidMemberInputError, +) +from anthropic.types.beta import BetaTextBlockParam, BetaImageBlockParam, BetaComputerKeyInput +from anthropic.tools.computer import ( + BetaToolsetCallContext, + BetaComputerConfirmContext, +) +from anthropic.types.beta.beta_tool_use_block import BetaToolUseBlock + +from ._computer_fakes import PNG, Desktop, FakeDesktop, AsyncFakeDesktop + +CTX = BetaToolsetCallContext( + tool_use=BetaToolUseBlock(type="tool_use", id="toolu_7", name="left_click", input={}, toolset_name="computer") +) + + +def _allow(context: BetaComputerConfirmContext) -> bool: + return True + + +def _deny(context: BetaComputerConfirmContext) -> bool: + return False + + +def _not_a_bool(context: BetaComputerConfirmContext) -> str: + return "yes" + + +def _use(name: str, input: dict[str, Any], id: str = "toolu_1") -> BetaToolUseBlock: + return BetaToolUseBlock(type="tool_use", id=id, name=name, input=input, toolset_name="computer") + + +def _error_text(desktop: FakeDesktop, name: str, input: dict[str, Any]) -> str: + with pytest.raises(ToolError) as caught: + desktop.call(CTX, name, input) + return str(caught.value) + + +# --- rendering ------------------------------------------------------------------------------------------------ + + +def test_a_screenshot_renders_as_one_image_block() -> None: + desktop = FakeDesktop() + assert desktop.call(CTX, "screenshot", {}) == [ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG}} + ] + # zoom is rendered the same way, with the media type the driver reported + assert desktop.call(CTX, "zoom", {"region": [0, 0, 10, 10]}) == [ + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": PNG}} + ] + assert desktop.desktop.inputs[-1].region == [0, 0, 10, 10] + # anything but the result model is the driver's bug: a ValueError, which reaches the model as an error result + desktop.desktop.results["screenshot"] = "BBBB" + with pytest.raises(ValueError, match="screenshot returned str, not its result model"): + desktop.call(CTX, "screenshot", {}) + + +def test_cursor_position_renders_as_a_text_line() -> None: + desktop = FakeDesktop() + assert desktop.call(CTX, "cursor_position", {}) == [{"type": "text", "text": "X=0,Y=0"}] + desktop.call(CTX, "mouse_move", {"coordinate": [120, 45]}) + assert desktop.call(CTX, "cursor_position", {}) == [{"type": "text", "text": "X=120,Y=45"}] + desktop.desktop.results["cursor_position"] = (1, 2) # not the result model: the driver's bug, named + with pytest.raises(ValueError, match="cursor_position returned tuple, not its result model"): + desktop.call(CTX, "cursor_position", {}) + + +def test_a_pure_action_renders_its_acknowledgment_then_the_line_it_returned() -> None: + desktop = FakeDesktop() + assert desktop.call(CTX, "left_click", {"coordinate": [1, 2]}) == [{"type": "text", "text": "Clicked."}] + assert desktop.call(CTX, "mouse_move", {"coordinate": [1, 2]}) == [{"type": "text", "text": "Moved the mouse."}] + assert desktop.call(CTX, "key", {"text": "ctrl+s"}) == [{"type": "text", "text": "Pressed ctrl+s."}] + assert desktop.call(CTX, "hold_key", {"text": "shift", "duration": 2}) == [ + {"type": "text", "text": "Held shift for 2s."} + ] + assert desktop.call(CTX, "type", {"text": "hello"}) == [{"type": "text", "text": "Typed."}] + assert desktop.call(CTX, "scroll", {"scroll_direction": "down", "scroll_amount": 3}) == [ + {"type": "text", "text": "Scrolled down."} + ] + assert desktop.call(CTX, "wait", {"duration": 3}) == [{"type": "text", "text": "Waited 3s."}] + + class Buttons(FakeDesktop): + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return None + + buttons = Buttons() + assert buttons.call(CTX, "left_mouse_down", {}) == [{"type": "text", "text": "Left mouse button pressed."}] + assert buttons.call(CTX, "left_mouse_up", {}) == [{"type": "text", "text": "Left mouse button released."}] + assert buttons.call(CTX, "left_click_drag", {"start_coordinate": [1, 1], "coordinate": [2, 2]}) == [ + {"type": "text", "text": "Dragged."} + ] + # a line the action returned follows the acknowledgment, folded to one line and bounded + desktop.desktop.results["left_click"] = "Window\n\u202efocused" + "x" * 5000 + blocks = desktop.call(CTX, "left_click", {}) + assert blocks[0] == {"type": "text", "text": "Clicked."} + line = cast(Any, blocks[1])["text"] + assert line.startswith("Window focusedx") and len(line) == 4096 + desktop.desktop.results["left_click"] = "" # blank: the acknowledgment only + assert desktop.call(CTX, "left_click", {}) == [{"type": "text", "text": "Clicked."}] + + +def test_a_line_that_folds_to_nothing_renders_as_the_acknowledgment_alone() -> None: + # Whitespace and control characters fold away, and a text block that would carry nothing is not sent. + desktop = FakeDesktop() + desktop.desktop.results["left_click"] = "\n \t\x00" + assert desktop.call(CTX, "left_click", {}) == [{"type": "text", "text": "Clicked."}] + block = desktop.tool_result(_use("left_click", {})) + assert block.get("content") == [{"type": "text", "text": "Clicked."}] and "is_error" not in block + + +async def test_a_line_that_folds_to_nothing_renders_as_the_acknowledgment_alone_on_the_async_class() -> None: + desktop = AsyncFakeDesktop() + desktop.desktop.results["left_click"] = " \u2028 " + assert await desktop.call(CTX, "left_click", {}) == [{"type": "text", "text": "Clicked."}] + + +def test_a_template_value_the_model_wrote_stays_data() -> None: + desktop = FakeDesktop() + assert desktop.call(CTX, "key", {"text": "{duration}\n{text}"}) == [ + {"type": "text", "text": "Pressed {duration} {text}."} + ] + + +# --- inputs --------------------------------------------------------------------------------------------------- + + +def test_inputs_are_parsed_into_the_generated_models_and_undeclared_keys_are_kept() -> None: + desktop = FakeDesktop() + desktop.call(CTX, "key", {"text": "a", "repeat": 3, "tab_id": "t1"}) + parsed = desktop.desktop.inputs[-1] + assert isinstance(parsed, BetaComputerKeyInput) and parsed.repeat == 3 + assert cast(Any, parsed).tab_id == "t1" # a key the schema lacks stays on the parsed input + # the member sees the same tool_use the call answers + assert desktop.desktop.contexts[-1].tool_use is CTX.tool_use + + +def test_a_bad_field_is_refused_without_echoing_the_value() -> None: + desktop = FakeDesktop() + text = _error_text(desktop, "wait", {"duration": "long"}) + assert text.startswith("invalid input for computer member 'wait': duration: ") and "long" not in text + assert ( + _error_text(desktop, "mouse_move", {}) + .lower() + .startswith("invalid input for computer member 'mouse_move': coordinate: field required") + ) + assert _error_text(desktop, "scroll", {"scroll_direction": "sideways", "scroll_amount": 1}).startswith( + "invalid input for computer member 'scroll': scroll_direction: " + ) + assert desktop.desktop.calls == [] + + +def test_coordinate_lists_must_have_the_shape_the_model_was_told() -> None: + desktop = FakeDesktop() + assert _error_text(desktop, "mouse_move", {"coordinate": [1]}) == ( + "invalid input for computer member 'mouse_move': coordinate: expected [x, y]" + ) + assert _error_text(desktop, "zoom", {"region": [0, 0, 1]}) == ( + "invalid input for computer member 'zoom': region: expected [x0, y0, x1, y1]" + ) + assert _error_text(desktop, "left_click_drag", {"start_coordinate": [1], "coordinate": [1, 2, 3]}) == ( + "invalid input for computer member 'left_click_drag': coordinate: expected [x, y]; " + "start_coordinate: expected [x, y]" + ) + assert _error_text(desktop, "mouse_move", {"coordinate": [1, "b"]}).startswith( + "invalid input for computer member 'mouse_move': coordinate.1: " + ) + # an omitted optional coordinate means "at the cursor" and is not checked + desktop.call(CTX, "left_click", {}) + assert desktop.desktop.inputs[-1].coordinate is None + assert desktop.desktop.calls == ["left_click"] + + +def test_no_numeric_bound_is_applied_beyond_the_generated_types() -> None: + # The tool's schema tells the model these limits, but nothing enforces them; a driver that needs a cap + # applies its own. + desktop = FakeDesktop() + assert desktop.call(CTX, "wait", {"duration": 1000}) == [{"type": "text", "text": "Waited 1000s."}] + assert desktop.call(CTX, "key", {"text": "a", "repeat": 500}) == [{"type": "text", "text": "Pressed a."}] + assert desktop.desktop.inputs[-1].repeat == 500 + + +# --- refusals and errors -------------------------------------------------------------------------------------- + + +def test_member_resolution_refusals() -> None: + desktop = FakeDesktop(configs={"zoom": {"enabled": False}}) + assert _error_text(desktop, "teleport", {}) == "Error: unknown computer toolset member 'teleport'" + assert _error_text(desktop, "zoom", {"region": [0, 0, 1, 1]}) == ( + "The 'zoom' action is not permitted by this application's permissions and cannot be used in this session." + ) + assert _error_text(desktop, "double_click", {}) == ( + "The computer toolset member 'double_click' is not available in this environment." + ) + with pytest.raises(ToolError) as caught: + desktop.call(CTX, "double_click", {}) + assert isinstance(caught.value.__cause__, UnavailableMemberError) + with pytest.raises(ToolError) as invalid: + desktop.call(CTX, "wait", {}) + assert isinstance(invalid.value.__cause__, InvalidMemberInputError) + assert desktop.desktop.calls == [] + + +def test_a_driver_error_reaches_the_model_as_text_and_a_refusal_is_relayed() -> None: + desktop = FakeDesktop() + desktop.desktop.fail["left_click"] = RuntimeError("the display went away") + with pytest.raises(ToolError) as caught: + desktop.call(CTX, "left_click", {}) + assert caught.value.content == [{"type": "text", "text": "RuntimeError: the display went away"}] + assert isinstance(caught.value.__cause__, ToolError) and isinstance(caught.value.__cause__.__cause__, RuntimeError) + desktop.desktop.fail["left_click"] = ToolError("Nothing to click here.") + with pytest.raises(ToolError) as relayed: + desktop.call(CTX, "left_click", {}) + assert relayed.value.content == [{"type": "text", "text": "Nothing to click here."}] + # a driver's text can carry a screen-sized payload: cut to the field limit, whether raised as ToolError or not + desktop.desktop.fail["left_click"] = RuntimeError("x" * 5000) + assert len(_error_text(desktop, "left_click", {})) == 4096 + desktop.desktop.fail["left_click"] = ToolError("y" * 5000) + assert _error_text(desktop, "left_click", {}) == "y" * 4096 + assert len(cast(Any, desktop.tool_result(_use("left_click", {})))["content"][0]["text"]) == 4096 + # a usage error is the driver's bug and propagates + desktop.desktop.fail["left_click"] = ToolsetContractError("misused") + with pytest.raises(ToolsetContractError, match="misused"): + desktop.call(CTX, "left_click", {}) + + +_IMAGE: BetaImageBlockParam = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG}} +_RAISED: list[BetaTextBlockParam | BetaImageBlockParam] = [ + {"type": "text", "text": "No button here."}, + _IMAGE, + {"type": "text", "text": ""}, +] + + +def test_call_raises_a_tool_errors_blocks_as_they_are_and_tool_result_keeps_only_the_text() -> None: + # The API rejects an error result that holds a non-text block. `tool_result()` keeps only the non-empty text blocks. + desktop = FakeDesktop() + desktop.desktop.fail["left_click"] = ToolError(_RAISED) + with pytest.raises(ToolError) as caught: + desktop.call(CTX, "left_click", {}) + assert caught.value.content == _RAISED + assert cast(Any, desktop.tool_result(_use("left_click", {})))["content"] == [ + {"type": "text", "text": "No button here."} + ] + + +async def test_call_raises_a_tool_errors_blocks_as_they_are_on_the_async_class() -> None: + desktop = AsyncFakeDesktop() + desktop.desktop.fail["left_click"] = ToolError(_RAISED) + with pytest.raises(ToolError) as caught: + await desktop.call(CTX, "left_click", {}) + assert caught.value.content == _RAISED + result = cast(Any, await desktop.tool_result(_use("left_click", {}))) + assert result["content"] == [{"type": "text", "text": "No button here."}] + + +class _NoMessage(Exception): + @override + def __str__(self) -> str: + raise ValueError("no message") + + +@pytest.mark.parametrize( + ("raised", "expected"), + [ + (OSError("display :3 is gone"), "OSError: display :3 is gone"), + (TimeoutError(), "TimeoutError"), + (_NoMessage(), "_NoMessage"), + ], +) +async def test_a_driver_exception_reads_as_its_type_and_message(raised: Exception, expected: str) -> None: + # an empty message, or a `__str__` that raises, gives the type name alone + desktop, async_desktop = FakeDesktop(), AsyncFakeDesktop() + desktop.desktop.fail["left_click"] = async_desktop.desktop.fail["left_click"] = raised + assert cast(Any, desktop.tool_result(_use("left_click", {})))["content"] == [{"type": "text", "text": expected}] + result = cast(Any, await async_desktop.tool_result(_use("left_click", {}))) + assert result["content"] == [{"type": "text", "text": expected}] + + +def test_tool_result_builds_the_block_and_never_raises_a_tool_error() -> None: + desktop = FakeDesktop() + ok = desktop.tool_result(_use("cursor_position", {})) + assert ok == { + "type": "tool_result", + "tool_use_id": "toolu_1", + "toolset_name": "computer", + "content": [{"type": "text", "text": "X=0,Y=0"}], + } + failed = desktop.tool_result(_use("teleport", {}, id="toolu_2")) + assert failed == { + "type": "tool_result", + "tool_use_id": "toolu_2", + "toolset_name": "computer", + "content": [{"type": "text", "text": "Error: unknown computer toolset member 'teleport'"}], + "is_error": True, + } + # a browser block is not a computer member call, whatever its name + with pytest.raises(ToolsetContractError, match="not a member call of the 'computer' toolset"): + desktop.tool_result( + BetaToolUseBlock(type="tool_use", id="x", name="screenshot", input={}, toolset_name="browser") + ) + + +# --- confirm ---------------------------------------------------------------------------------------------------- + + +def test_confirm_sees_every_call_with_the_member_and_its_parsed_input() -> None: + seen: list[BetaComputerConfirmContext] = [] + + def confirm(context: BetaComputerConfirmContext) -> bool: + seen.append(context) + return context.member != "type" + + desktop = FakeDesktop(confirm=confirm) + desktop.call(CTX, "key", {"text": "a"}) + assert _error_text(desktop, "type", {"text": "secret"}) == ( + "The user did not grant permission to run 'type'. Do not retry it unless the user asks you to." + ) + assert [c.member for c in seen] == ["key", "type"] + assert seen[1].input.to_dict() == {"text": "secret"} and seen[1].tool_use is CTX.tool_use + assert desktop.desktop.calls == ["key"] # the declined call never reached the driver + # a refused call is never shown: nothing to approve + _error_text(desktop, "teleport", {}) + assert len(seen) == 2 + + +def test_a_confirm_that_raises_refuses_the_call_and_an_answer_other_than_true_declines() -> None: + def failing(context: BetaComputerConfirmContext) -> bool: + raise RuntimeError("no terminal") + + desktop = FakeDesktop(confirm=failing) + with pytest.raises(ToolError) as caught: + desktop.call(CTX, "key", {"text": "a"}) + assert isinstance(caught.value.__cause__, ConfirmFailedError) + assert str(caught.value).startswith("Permission to run 'key' could not be obtained") + + def refusing(context: BetaComputerConfirmContext) -> bool: + raise ToolError("Ask again later.") + + assert _error_text(FakeDesktop(confirm=refusing), "key", {"text": "a"}) == "Ask again later." + + def long_refusal(context: BetaComputerConfirmContext) -> bool: + raise ToolError("r" * 5000) + + # A refusal raised before the call is held to the field limit, and so is a parse error on a long input. + assert _error_text(FakeDesktop(confirm=long_refusal), "key", {"text": "a"}) == "r" * 4096 + assert len(_error_text(FakeDesktop(), "mouse_move", {"coordinate": ["x"] * 5000})) <= 4096 + for confirm in (_deny, _not_a_bool): # only `True` runs the call + with pytest.raises(ToolError) as declined: + FakeDesktop(confirm=confirm).call(CTX, "key", {"text": "a"}) + assert isinstance(declined.value.__cause__, ConfirmDeclinedError) + + +def test_the_gate_runs_before_an_execute_override() -> None: + class Hooked(FakeDesktop): + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + self.desktop.calls.append(f"execute:{name}") + return super().execute(context, name, input) + + desktop = Hooked(confirm=_deny) + _error_text(desktop, "key", {"text": "a"}) + assert desktop.desktop.calls == [] + + +async def test_the_async_class_cuts_a_refusal_raised_before_the_call() -> None: + async def long_refusal(context: BetaComputerConfirmContext) -> bool: + raise ToolError("r" * 5000) + + with pytest.raises(ToolError) as caught: + await AsyncFakeDesktop(confirm=long_refusal).call(CTX, "key", {"text": "a"}) + assert str(caught.value) == "r" * 4096 + + +async def test_async_confirm_may_be_a_coroutine_or_a_plain_function() -> None: + async def coroutine(context: BetaComputerConfirmContext) -> bool: + return context.member == "key" + + desktop = AsyncFakeDesktop(confirm=coroutine) + assert await desktop.call(CTX, "key", {"text": "a"}) == [{"type": "text", "text": "Pressed a."}] + with pytest.raises(ToolError) as caught: + await desktop.call(CTX, "type", {"text": "b"}) + assert isinstance(caught.value.__cause__, ConfirmDeclinedError) + plain = AsyncFakeDesktop(confirm=_allow) + assert await plain.call(CTX, "type", {"text": "b"}) == [{"type": "text", "text": "Typed."}] + + +_KEYBOARD_OFF = {name: {"enabled": False} for name in ("type", "key", "hold_key")} + + +def test_a_toolset_with_the_keyboard_off_runs_its_calls_with_no_confirm() -> None: + desktop = FakeDesktop(confirm=None, configs=_KEYBOARD_OFF) + + assert desktop.call(CTX, "left_click", {"coordinate": [1, 2]}) == [{"type": "text", "text": "Clicked."}] + assert desktop.call(CTX, "cursor_position", {}) == [{"type": "text", "text": "X=0,Y=0"}] + assert desktop.desktop.calls == ["left_click", "cursor_position"] + + +async def test_the_async_class_with_the_keyboard_off_runs_its_calls_with_no_confirm() -> None: + desktop = AsyncFakeDesktop(confirm=None, configs=_KEYBOARD_OFF) + + assert await desktop.call(CTX, "left_click", {"coordinate": [1, 2]}) == [{"type": "text", "text": "Clicked."}] + assert desktop.desktop.calls == ["left_click"] + + +# --- the async class, closing, serialization ------------------------------------------------------------------ + + +def test_an_async_execute_override_on_the_sync_class_is_a_contract_error() -> None: + class Coroutine(FakeDesktop): + @override + async def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: # type: ignore[override] + return None + + with pytest.raises(ToolsetContractError, match="execute is async on the synchronous toolset"): + Coroutine() + + +async def test_a_plain_execute_override_on_the_async_class_is_a_contract_error() -> None: + # reported when the toolset is built, as for the browser toolset: the override never runs + class Plain(AsyncFakeDesktop): + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return None + + with pytest.raises(ToolsetContractError, match="execute is sync on the asynchronous toolset"): + Plain() + + +async def test_the_async_class_renders_and_refuses_the_same_way() -> None: + desktop = AsyncFakeDesktop() + assert await desktop.call(CTX, "screenshot", {}) == [ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG}} + ] + assert await desktop.call(CTX, "cursor_position", {}) == [{"type": "text", "text": "X=0,Y=0"}] + assert await desktop.call(CTX, "scroll", {"scroll_direction": "up", "scroll_amount": 2}) == [ + {"type": "text", "text": "Scrolled up."} + ] + with pytest.raises(ToolError, match="expected \\[x, y\\]"): + await desktop.call(CTX, "mouse_move", {"coordinate": [1, 2, 3]}) + desktop.desktop.fail["key"] = RuntimeError("boom") + with pytest.raises(ToolError, match=r"^RuntimeError: boom$"): + await desktop.call(CTX, "key", {"text": "a"}) + desktop.desktop.fail["key"] = ToolError("Nothing to press.") + with pytest.raises(ToolError, match="^Nothing to press\\.$"): + await desktop.call(CTX, "key", {"text": "a"}) + result = await desktop.tool_result(_use("triple_click", {})) + assert result["is_error"] is True and "not available" in str(result["content"]) # type: ignore[typeddict-item] + + +def test_a_closed_toolset_refuses_calls_and_a_member_cannot_nest_call() -> None: + desktop = FakeDesktop() + with desktop: + desktop.call(CTX, "key", {"text": "a"}) + with pytest.raises(ToolsetClosedError): + desktop.call(CTX, "key", {"text": "a"}) + with pytest.raises(ToolsetClosedError): + with desktop: + pass + + class Nesting(FakeDesktop): + @override + def key(self, context: BetaToolsetCallContext, input: BetaComputerKeyInput) -> None: + self.call(context, "type", {"text": input.text}) + + with pytest.raises(ToolsetContractError, match="call\\(\\) was invoked from inside a member"): + Nesting().call(CTX, "key", {"text": "a"}) + + +def test_close_waits_for_the_call_in_flight() -> None: + started, release = threading.Event(), threading.Event() + shared = Desktop() + + class Slow(FakeDesktop): + @override + def key(self, context: BetaToolsetCallContext, input: BetaComputerKeyInput) -> None: + started.set() + release.wait(5) + self.desktop.action("key", context, input) + + desktop = Slow(shared) + worker = threading.Thread(target=lambda: desktop.call(CTX, "key", {"text": "a"})) + worker.start() + assert started.wait(5) + closer = threading.Thread(target=desktop.close) + closer.start() + closer.join(0.2) + assert closer.is_alive() # close waits for the member to finish + release.set() + worker.join(5) + closer.join(5) + assert not closer.is_alive() and shared.calls == ["key"] + + +async def test_async_calls_run_one_at_a_time_and_close_waits() -> None: + order: list[str] = [] + + class Slow(AsyncFakeDesktop): + @override + async def key(self, context: BetaToolsetCallContext, input: BetaComputerKeyInput) -> None: + order.append(f"start {input.text}") + await anyio.sleep(0.05) + order.append(f"end {input.text}") + + desktop = Slow() + async with anyio.create_task_group() as tg: + tg.start_soon(desktop.call, CTX, "key", {"text": "a"}) + tg.start_soon(desktop.call, CTX, "key", {"text": "b"}) + await anyio.sleep(0.01) + await desktop.close() + order.append("closed") + assert order[-1] == "closed" and order[:4] in ( + ["start a", "end a", "start b", "end b"], + ["start b", "end b", "start a", "end a"], + ) + with pytest.raises(ToolsetClosedError): + await desktop.call(CTX, "key", {"text": "c"}) diff --git a/tests/lib/tools/toolsets/test_computer_registry.py b/tests/lib/tools/toolsets/test_computer_registry.py new file mode 100644 index 000000000..9374802f6 --- /dev/null +++ b/tests/lib/tools/toolsets/test_computer_registry.py @@ -0,0 +1,93 @@ +"""The computer member registry against the published toolset definition. + +`computer_toolset_20260801.json` is a checked-in copy of the toolset's member set as the API defines it. The +registry derives names and inputs from the generated types and keeps result kinds and templates by hand; these +tests fail when either side moves without the other. +""" + +from __future__ import annotations + +import re +import json +from typing import Any, cast +from pathlib import Path +from typing_extensions import get_args + +from anthropic.types.beta import ( + BetaComputerKeyInput, + BetaComputerZoomInput, + BetaComputerMemberName, + BetaComputerMemberInput, + BetaComputerScreenshotInput, + BetaComputerLeftMouseUpInput, + BetaComputerLeftMouseDownInput, + BetaComputerCursorPositionInput, + BetaComputerToolsetConfigsParam, +) +from anthropic.lib.tools._toolsets._render import TEMPLATE_FIELDS +from anthropic.lib.tools._toolsets._computer_inputs import COMPUTER_INPUT_TYPES, COMPUTER_MEMBER_NAMES +from anthropic.lib.tools._toolsets._computer_registry import COMPUTER, COMPUTER_MEMBERS + +SPEC: dict[str, Any] = json.loads((Path(__file__).parent / "computer_toolset_20260801.json").read_text()) + + +def test_member_set_matches_the_published_toolset_in_its_order() -> None: + assert COMPUTER_MEMBER_NAMES == SPEC["members"] + assert list(COMPUTER_MEMBERS) == SPEC["members"] + assert len(COMPUTER_MEMBER_NAMES) == len(set(COMPUTER_MEMBER_NAMES)) == 17 + + +def test_every_member_is_enabled_by_default() -> None: + assert SPEC["default_disabled"] == [] + assert COMPUTER.default_disabled == frozenset() + assert all(member.enabled_by_default for member in COMPUTER_MEMBERS.values()) + + +def test_member_set_matches_the_generated_configs_param() -> None: + # The wire configs object has one key per member; a member the SDK does not know could not be + # configured, and one the API does not know would be rejected. + assert set(BetaComputerToolsetConfigsParam.__annotations__) == set(COMPUTER_MEMBER_NAMES) + + +def test_static_aliases_agree_with_the_derived_tables() -> None: + assert set(get_args(BetaComputerMemberName)) == set(COMPUTER_MEMBER_NAMES) + assert set(cast("tuple[type, ...]", get_args(BetaComputerMemberInput))) == set(COMPUTER_INPUT_TYPES.values()) + assert COMPUTER_INPUT_TYPES["key"] is BetaComputerKeyInput + assert COMPUTER_INPUT_TYPES["zoom"] is BetaComputerZoomInput + assert COMPUTER_INPUT_TYPES["screenshot"] is BetaComputerScreenshotInput + assert COMPUTER_INPUT_TYPES["cursor_position"] is BetaComputerCursorPositionInput + assert COMPUTER_INPUT_TYPES["left_mouse_down"] is BetaComputerLeftMouseDownInput + assert COMPUTER_INPUT_TYPES["left_mouse_up"] is BetaComputerLeftMouseUpInput + + +def test_result_kinds_and_confirmation_texts() -> None: + for name, member in COMPUTER_MEMBERS.items(): + assert member.input is COMPUTER_INPUT_TYPES[name] + if name in ("screenshot", "zoom"): + assert member.result == "screenshot" and member.text is None + elif name == "cursor_position": + assert member.result == "point" and member.text is None + else: + assert member.result == "none" + assert member.text, f"{name} is a pure action with nothing for the model to read" + + +def test_member_enabled_fails_closed() -> None: + enabled = COMPUTER.member_enabled + assert enabled("zoom", None) is True + assert enabled("zoom", {"zoom": {"enabled": False}}) is False + assert enabled("zoom", {"zoom": {"enabled": True}}) is True + # Anything but a real boolean turns the member off rather than on. + assert enabled("zoom", {"zoom": {"enabled": "yes"}}) is False + assert enabled("zoom", {"zoom": {"defer_loading": True}}) is True + assert enabled("zoom", cast(Any, {"zoom": False})) is False + assert enabled("zoom", {"zoom": None}) is True # None is the wire type's "defaults" + + +def test_every_confirmation_placeholder_has_a_template_field() -> None: + # A `{name}` in a member's confirmation text that `_render` does not know would reach the model as written. + placeholders = { + name for member in COMPUTER_MEMBERS.values() if member.text for name in re.findall(r"{(\w+)}", member.text) + } + assert placeholders <= set(TEMPLATE_FIELDS) + assert placeholders == {"text", "duration", "scroll_direction"} diff --git a/tests/lib/tools/toolsets/test_computer_runner.py b/tests/lib/tools/toolsets/test_computer_runner.py new file mode 100644 index 000000000..e5e39ed59 --- /dev/null +++ b/tests/lib/tools/toolsets/test_computer_runner.py @@ -0,0 +1,316 @@ +"""A computer toolset through `client.beta.messages.tool_runner` with the Messages responses mocked, alone and +beside a browser toolset.""" + +from __future__ import annotations + +import os +import json +from typing import Any, cast + +import httpx2 +import pytest +from respx import MockRouter + +from anthropic import Anthropic, AsyncAnthropic +from anthropic._compat import PYDANTIC_V1 + +from ._fakes import FakeBrowser +from ._computer_fakes import FakeDesktop, AsyncFakeDesktop + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + +pytestmark = [ + pytest.mark.respx(base_url=base_url), + pytest.mark.skipif(PYDANTIC_V1, reason="tool runner not supported with pydantic v1"), +] + +HALT = "Not executed: an earlier computer action in this turn failed." + + +@pytest.fixture +def client(client: Anthropic) -> Anthropic: # the shared client (tests/conftest.py), without retries + return client.with_options(max_retries=0) + + +@pytest.fixture +def async_client(async_client: AsyncAnthropic) -> AsyncAnthropic: + return async_client.with_options(max_retries=0) + + +def _message(*blocks: dict[str, Any], stop: str = "tool_use") -> httpx2.Response: + return httpx2.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": list(blocks), + "stop_reason": stop, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + +def _member_use(name: str, id: str, input: dict[str, Any], family: str = "computer") -> dict[str, Any]: + return {"type": "tool_use", "id": id, "name": name, "input": input, "toolset_name": family} + + +DONE = _message({"type": "text", "text": "Done."}, stop="end_turn") + + +def _requests(respx_mock: MockRouter) -> list[dict[str, Any]]: + calls = cast(list[Any], list(respx_mock.calls)) + return [cast(dict[str, Any], json.loads(call.request.content)) for call in calls] + + +def test_member_calls_round_trip_through_the_runner(respx_mock: MockRouter, client: Anthropic) -> None: + respx_mock.post("/v1/messages").mock( + side_effect=[ + _message( + _member_use("mouse_move", "toolu_move", {"coordinate": [10, 20]}), + _member_use("cursor_position", "toolu_pos", {}), + _member_use("teleport", "toolu_bad", {}), + ), + DONE, + ] + ) + desktop = FakeDesktop() + runner = client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=[desktop], messages=[{"role": "user", "content": "go"}] + ) + runner.until_done() + + first, second = _requests(respx_mock) + # The entry on the wire is exactly to_dict(): the type plus enabled:false for unimplemented members. + assert first["tools"] == [desktop.to_dict()] + assert "computer-toolset" in cast(Any, respx_mock.calls[0]).request.headers["x-stainless-helper"].split(", ") + results = second["messages"][-1]["content"] + assert results[0] == { + "type": "tool_result", + "tool_use_id": "toolu_move", + "toolset_name": "computer", + "content": [{"type": "text", "text": "Moved the mouse."}], + } + assert results[1]["content"] == [{"type": "text", "text": "X=10,Y=20"}] + assert results[2] == { + "type": "tool_result", + "tool_use_id": "toolu_bad", + "toolset_name": "computer", + "content": [{"type": "text", "text": "Error: unknown computer toolset member 'teleport'"}], + "is_error": True, + } + assert desktop.desktop.contexts[0].tool_use is not None and desktop.desktop.contexts[0].tool_use.id == "toolu_move" + + +def test_a_failure_halts_the_later_computer_actions_of_the_turn_with_the_computer_wording( + respx_mock: MockRouter, client: Anthropic +) -> None: + respx_mock.post("/v1/messages").mock( + side_effect=[ + _message( + _member_use("left_click", "toolu_1", {"coordinate": [1, 1]}), + _member_use("key", "toolu_2", {"text": "Return"}), + _member_use("screenshot", "toolu_3", {}), + ), + DONE, + ] + ) + desktop = FakeDesktop() + desktop.desktop.fail["key"] = RuntimeError("keyboard unplugged") + runner = client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=[desktop], messages=[{"role": "user", "content": "go"}] + ) + runner.until_done() + + results = _requests(respx_mock)[1]["messages"][-1]["content"] + assert [r["tool_use_id"] for r in results] == ["toolu_1", "toolu_2", "toolu_3"] + assert "is_error" not in results[0] + assert results[1]["is_error"] is True and results[1]["content"] == [ + {"type": "text", "text": "RuntimeError: keyboard unplugged"} + ] + assert results[2] == { + "type": "tool_result", + "tool_use_id": "toolu_3", + "toolset_name": "computer", + "content": HALT, + "is_error": True, + } + assert desktop.desktop.calls == ["left_click", "key"] # the screenshot was never taken + + +def test_a_computer_and_a_browser_toolset_serve_one_request_by_family( + respx_mock: MockRouter, client: Anthropic +) -> None: + # The families share member names; each block is routed by its toolset_name, and a failure in one family does + # not halt the other (each family keeps its own halt wording). + respx_mock.post("/v1/messages").mock( + side_effect=[ + _message( + _member_use("screenshot", "toolu_c1", {}), + _member_use("screenshot", "toolu_b1", {}, family="browser"), + _member_use("key", "toolu_c2", {"text": "a"}), + _member_use("key", "toolu_c3", {"text": "b"}), + _member_use("navigate", "toolu_b2", {"url": "https://example.com/"}, family="browser"), + _member_use("navigate", "toolu_b3", {"url": "https://example.com/"}, family="browser"), + ), + DONE, + ] + ) + desktop, browser = FakeDesktop(), FakeBrowser() + desktop.desktop.fail["key"] = RuntimeError("stuck") + browser.world.fail["navigate"] = RuntimeError("offline") + runner = client.beta.messages.tool_runner( + max_tokens=1024, + model="claude-haiku-4-5", + tools=[desktop, browser], + messages=[{"role": "user", "content": "go"}], + ) + runner.until_done() + + first, second = _requests(respx_mock) + assert first["tools"] == [desktop.to_dict(), browser.to_dict()] + results = {r["tool_use_id"]: r for r in second["messages"][-1]["content"]} + assert results["toolu_c1"]["content"][0]["type"] == "image" and results["toolu_c1"]["toolset_name"] == "computer" + assert [b["type"] for b in results["toolu_b1"]["content"]] == ["image", "browser_state"] + assert results["toolu_c3"]["content"] == HALT + assert results["toolu_b3"]["content"] == "Not executed: an earlier action in this turn failed." + assert desktop.desktop.calls == ["screenshot", "key"] and browser.world.calls == ["screenshot", "navigate"] + + +def test_a_call_named_after_the_toolset_without_toolset_name_gets_an_error_that_says_what_is_wrong( + respx_mock: MockRouter, client: Anthropic +) -> None: + calls: dict[str, dict[str, Any]] = { + "text": {"actions": '[{"action": "key",}]'}, + "object": {"actions": {"action": "key"}}, + "empty": {"actions": []}, + "no_action": {"actions": [{"action": "screenshot"}, {"text": "a"}]}, + "not_text": {"actions": [{"action": 5}]}, + "not_object": {"actions": ["key"]}, + "flat_number": {"action": 5}, + "null": {"actions": None}, + "odd_name": {"actions": [{"action": "a'b\nc d"}]}, + "well_formed": {"actions": [{"action": "screenshot"}, {"action": "x"}]}, + "many": {"actions": [{"action": f"a{i}"} for i in range(12)]}, + "flat": {"action": "screenshot"}, + "none": {}, + } + respx_mock.post("/v1/messages").mock( + side_effect=[ + _message( + *({"type": "tool_use", "id": key, "name": "computer", "input": input} for key, input in calls.items()), + { + "type": "tool_use", + "id": "null_toolset", + "name": "computer", + "input": {"actions": []}, + "toolset_name": None, + }, + {"type": "tool_use", "id": "calculator", "name": "calculator", "input": {}}, + ), + DONE, + ] + ) + desktop = FakeDesktop() + runner = client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=[desktop], messages=[{"role": "user", "content": "go"}] + ) + with pytest.warns(UserWarning) as warned: + runner.until_done() + + results = {result["tool_use_id"]: result for result in _requests(respx_mock)[1]["messages"][-1]["content"]} + prefix = "Error: the 'computer' toolset could not run this call" + suffix = "; nothing in the batch was executed" + many = ", ".join(f"'a{i}'" for i in range(10)) + assert {key: result["content"] for key, result in results.items()} == { + "text": f"{prefix}: 'actions' is text, not a list of actions{suffix}", + "object": f"{prefix}: 'actions' is not a list of actions{suffix}", + "empty": f"{prefix}: the 'actions' list is empty{suffix}", + "no_action": f"{prefix}: action 1 must be an object with a string 'action' field{suffix}", + "not_text": f"{prefix}: action 0 must be an object with a string 'action' field{suffix}", + "not_object": f"{prefix}: action 0 must be an object with a string 'action' field{suffix}", + "flat_number": f"{prefix}: the call has no 'actions' list{suffix}", + "null": f"{prefix}: 'actions' is not a list of actions{suffix}", + "odd_name": f"{prefix}: its actions could not be run as sent (actions: 'a b c d'){suffix}", + "well_formed": f"{prefix}: its actions could not be run as sent (actions: 'screenshot', 'x'){suffix}", + "many": f"{prefix}: its actions could not be run as sent (actions: {many}, and 2 more){suffix}", + "flat": f"{prefix}: the call has no 'actions' list (action: 'screenshot'){suffix}", + "none": f"{prefix}: the call has no 'actions' list{suffix}", + "null_toolset": f"{prefix}: the 'actions' list is empty{suffix}", + # A name that is neither a tool nor a toolset in the run gets the plain not-found reply. + "calculator": "Error: Tool 'calculator' not found", + } + assert all(result["is_error"] is True and "toolset_name" not in result for result in results.values()) + assert [str(w.message).split(".")[0] for w in warned] == ["Tool 'calculator' not found in tool runner"] + assert desktop.desktop.calls == [] + + +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_a_call_named_after_the_toolset_stops_the_toolsets_later_calls_in_the_reply( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + respx_mock.post("/v1/messages").mock( + side_effect=[ + _message( + _member_use("mouse_move", "toolu_before", {"coordinate": [10, 20]}), + {"type": "tool_use", "id": "toolu_bad", "name": "computer", "input": {"actions": "["}}, + _member_use("cursor_position", "toolu_after", {}), + ), + DONE, + ] + ) + if flavour == "sync": + desktop: Any = FakeDesktop() + client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=[desktop], messages=[{"role": "user", "content": "go"}] + ).until_done() + else: + desktop = AsyncFakeDesktop() + await async_client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=[desktop], messages=[{"role": "user", "content": "go"}] + ).until_done() + + results = _requests(respx_mock)[1]["messages"][-1]["content"] + assert [(r["tool_use_id"], r.get("is_error", False)) for r in results] == [ + ("toolu_before", False), + ("toolu_bad", True), + ("toolu_after", True), + ] + assert results[2]["content"] == HALT + assert desktop.desktop.calls == ["mouse_move"] + + +async def test_member_calls_round_trip_through_the_async_runner( + respx_mock: MockRouter, async_client: AsyncAnthropic +) -> None: + respx_mock.post("/v1/messages").mock( + side_effect=[_message(_member_use("zoom", "toolu_z", {"region": [0, 0, 4, 4]})), DONE] + ) + desktop = AsyncFakeDesktop() + runner = async_client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=[desktop], messages=[{"role": "user", "content": "go"}] + ) + await runner.until_done() + + results = _requests(respx_mock)[1]["messages"][-1]["content"] + assert results[0]["content"][0]["source"]["media_type"] == "image/jpeg" + + +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_a_toolset_instance_stands_in_tools_for_create_and_count_tokens( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + respx_mock.post("/v1/messages").mock(return_value=DONE) + respx_mock.post("/v1/messages/count_tokens").mock(return_value=httpx2.Response(200, json={"input_tokens": 3})) + messages: Any = [{"role": "user", "content": "hi"}] + if flavour == "sync": + desktop: Any = FakeDesktop() + client.beta.messages.create(model="m", max_tokens=8, messages=messages, tools=[desktop]) + client.beta.messages.count_tokens(model="m", messages=messages, tools=[desktop]) + else: + desktop = AsyncFakeDesktop() + await async_client.beta.messages.create(model="m", max_tokens=8, messages=messages, tools=[desktop]) + await async_client.beta.messages.count_tokens(model="m", messages=messages, tools=[desktop]) + assert [body["tools"] for body in _requests(respx_mock)] == [[desktop.to_dict()], [desktop.to_dict()]] diff --git a/tests/lib/tools/toolsets/test_computer_surface.py b/tests/lib/tools/toolsets/test_computer_surface.py new file mode 100644 index 000000000..0d749818b --- /dev/null +++ b/tests/lib/tools/toolsets/test_computer_surface.py @@ -0,0 +1,104 @@ +"""The computer toolset's public surface: every name the guide documents imports from `anthropic.tools.computer`, +the abstract classes carry the constructor options and member methods a driver overrides, and what is shared with +the browser toolset is the same object in both modules.""" + +from __future__ import annotations + +import inspect +from typing_extensions import get_args, get_type_hints + +import anthropic.tools as tools +import anthropic.types.beta as beta +import anthropic.tools.browser as browser +import anthropic.tools.computer as surface +from anthropic.tools import ( + ToolError, + ToolsetUsageError, + ToolsetClosedError, + ToolsetConfigError, + ToolsetContractError, +) +from anthropic._compat import field_outer_type, get_model_fields +from anthropic.types.beta import BetaBase64ImageSourceParam +from anthropic.tools.computer import ( + BetaScreenshotResult, + BetaComputerCursorPositionResult, + BetaAbstractComputerToolset20260801, + BetaAsyncAbstractComputerToolset20260801, +) +from anthropic.lib.tools._toolsets._render import image_block +from anthropic.lib.tools._toolsets._computer_inputs import COMPUTER_MEMBER_NAMES + +REFUSALS = [ + "UnknownMemberError", + "DisabledMemberError", + "UnavailableMemberError", + "InvalidMemberInputError", + "ConfirmDeclinedError", + "ConfirmFailedError", +] + + +def test_every_documented_name_is_exported() -> None: + for name in surface.__all__: + assert hasattr(surface, name), name + assert getattr(tools, name) is getattr(surface, name), name # re-exported by the package too + assert not hasattr(surface, "COMPUTER_MEMBER_NAMES") + assert not hasattr(tools, "COMPUTER_MEMBER_NAMES") + generated = {name for name in dir(beta) if name.startswith("BetaComputer")} + assert generated.isdisjoint(dir(surface)), "import generated types from anthropic.types.beta only" + assert generated.isdisjoint(dir(tools)) + + +def test_the_error_family_is_one_hierarchy_shared_with_the_browser_toolset() -> None: + for cls in (ToolsetClosedError, ToolsetConfigError, ToolsetContractError): + assert issubclass(cls, ToolsetUsageError) + for name in REFUSALS: + cls = getattr(tools, name) + assert issubclass(cls, ToolError) and cls is not ToolError + # the shared errors are exported from `anthropic.tools` only, as for the browser toolset + shared = ["ToolError", "ToolsetUsageError", "ToolsetClosedError", "ToolsetConfigError", "ToolsetContractError"] + for name in (*shared, *REFUSALS): + assert hasattr(tools, name), name + assert not hasattr(surface, name) and not hasattr(browser, name), name + for name in ("BetaToolsetCallContext", "BetaToolsetContent", "BetaToolConfigs"): + assert getattr(surface, name) is getattr(browser, name) + + +def test_the_abstract_classes_declare_every_member_and_the_constructor_options() -> None: + for cls in (BetaAbstractComputerToolset20260801, BetaAsyncAbstractComputerToolset20260801): + assert set(inspect.signature(cls.__init__).parameters) - {"self"} == {"configs", "confirm", "tool_configs"} + for member in COMPUTER_MEMBER_NAMES: + assert callable(getattr(cls, member)), member + for method in ("call", "execute", "to_dict", "tool_result", "close"): + assert callable(getattr(cls, method)), method + sync_members = { + m + for m in COMPUTER_MEMBER_NAMES + if not inspect.iscoroutinefunction(getattr(BetaAbstractComputerToolset20260801, m)) + } + async_members = { + m + for m in COMPUTER_MEMBER_NAMES + if inspect.iscoroutinefunction(getattr(BetaAsyncAbstractComputerToolset20260801, m)) + } + assert sync_members == async_members == set(COMPUTER_MEMBER_NAMES) + + +def test_value_types_construct() -> None: + assert BetaScreenshotResult(data="AAAA").media_type == "image/png" + assert BetaComputerCursorPositionResult(x=1, y=2).to_dict() == {"x": 1, "y": 2} + + +def test_a_toolset_instance_is_a_tool_like() -> None: + from anthropic.lib.tools._tool_params import SupportsToDict + + assert issubclass(BetaAbstractComputerToolset20260801, SupportsToDict) + + +def test_image_media_types_track_the_generated_image_source() -> None: + # BetaScreenshotResult.media_type (_computer_results.py) and image_block's media_type (_render.py) copy + # BetaBase64ImageSourceParam's media types by hand. When this fails, make both Literals match it. + expected = set(get_args(get_type_hints(BetaBase64ImageSourceParam)["media_type"])) + assert set(get_args(field_outer_type(get_model_fields(BetaScreenshotResult)["media_type"]))) == expected + assert set(get_args(get_type_hints(image_block)["media_type"])) == expected diff --git a/tests/lib/tools/toolsets/test_confirm.py b/tests/lib/tools/toolsets/test_confirm.py new file mode 100644 index 000000000..c4efd065f --- /dev/null +++ b/tests/lib/tools/toolsets/test_confirm.py @@ -0,0 +1,327 @@ +# ruff: noqa: ARG001 -- stub members and hooks ignore their arguments +"""The confirm approval gate.""" + +from __future__ import annotations + +import functools +from typing import Any, cast +from urllib.parse import urlsplit +from typing_extensions import override + +import pytest + +from anthropic.tools import ( + ToolError, + ToolsetConfigError, + ToolsetContractError, +) +from anthropic.types.beta import ( + BetaToolUseBlock, + BetaBrowserLeftClickInput, +) +from anthropic.tools.browser import ( + BetaConfirmContext, + BetaLocalFilePolicy, + BetaToolsetCallContext, +) + +from ._fakes import CLICK, World, FakeBrowser, AsyncFakeBrowser, texts, approve, refusal + +CTX = BetaToolsetCallContext( + tool_use=BetaToolUseBlock(type="tool_use", id="toolu_7", name="left_click", input={}, toolset_name="browser") +) +DECLINED = "The user did not grant permission to run 'left_click'. Do not retry it unless the user asks you to." +FAILED = ( + "Permission to run 'left_click' could not be obtained (the confirmation prompt failed). " + "Do not retry it unless the user asks you to." +) + + +def _deny(context: BetaConfirmContext) -> bool: + return False + + +def test_confirm_sees_every_call_and_a_decline_never_reaches_the_driver() -> None: + # The callable is asked before every member call that is about to run and decides by name (or anything else) + # which ones it gates; a False never reaches the driver. + asked: list[str] = [] + + def confirm(context: BetaConfirmContext) -> bool: + asked.append(context.member) + return context.member != "left_click" or len(asked) > 2 + + browser = FakeBrowser(confirm=confirm) + assert texts(browser.call(CTX, "get_page_text", {})) == ["Hello"] + assert refusal(browser, CTX, "left_click", CLICK) == DECLINED + assert browser.world.calls == ["get_page_text"] + assert texts(browser.call(CTX, "left_click", CLICK)) == ["Clicked."] + assert browser.world.calls == ["get_page_text", "left_click"] and asked == [ + "get_page_text", + "left_click", + "left_click", + ] + + # a call refused before dispatch (unknown member, disabled member) is never asked about + with pytest.raises(ToolError): + browser.call(CTX, "teleport", {}) + assert asked == ["get_page_text", "left_click", "left_click"] + gated = FakeBrowser(confirm=confirm, configs={"left_click": {"enabled": False}}) + with pytest.raises(ToolError): + gated.call(CTX, "left_click", CLICK) + assert asked == ["get_page_text", "left_click", "left_click"] and gated.world.calls == [] + + +def test_enabling_file_upload_or_javascript_exec_requires_confirm() -> None: + class AnyPath(BetaLocalFilePolicy): + @override + def resolve_upload_paths(self, context: Any, paths: Any) -> list[str]: + return list(paths) + + upload: dict[str, Any] = {"target": {"type": "ref", "ref": "e1"}, "paths": ["/f"]} + options: dict[str, Any] = {"configs": {"file_upload": {"enabled": True}}, "file_policy": AnyPath()} + + with pytest.raises(ToolsetConfigError, match="file_upload.*confirm"): + FakeBrowser(**options) + + asked: list[str] = [] + + def gate_uploads(context: BetaConfirmContext) -> bool: + asked.append(context.member) + return context.member != "file_upload" + + browser = FakeBrowser(**options, confirm=gate_uploads) + assert refusal(browser, CTX, "file_upload", upload) == DECLINED.replace("left_click", "file_upload") + assert asked == ["file_upload"] and browser.world.calls == [] + + FakeBrowser(**options, confirm=approve).call(CTX, "file_upload", upload) # approving everything is the opt-out + + +def test_confirm_context_describes_the_target_tab_as_of_the_last_block_and_costs_nothing_unread() -> None: + seen: list[Any] = [] + + def by_name(context: BetaConfirmContext) -> bool: + seen.append((context.member, context.tool_use, context.input)) + return True + + browser = FakeBrowser(confirm=by_name) + use = BetaToolsetCallContext( + tool_use=BetaToolUseBlock(type="tool_use", id="toolu_c", name="left_click", input=CLICK, toolset_name="browser") + ) + + browser.call(use, "left_click", CLICK) + assert seen[-1] == ("left_click", use.tool_use, browser.world.inputs[-1]) + + # the context carries the very objects the call was given — the parsed input a gate narrows with isinstance, and + # the tool_use block — not validated copies (pydantic v1 would otherwise retype the input as a sibling model) + assert seen[-1][1] is use.tool_use and seen[-1][2] is browser.world.inputs[-1] + assert type(seen[-1][2]) is BetaBrowserLeftClickInput + + # the prompt costs no state read: one per call, as without confirm + assert len(browser.world.state_contexts) == 1 + + def by_tab(context: BetaConfirmContext) -> bool: + seen.append((context.tab_url, context.tab_id)) + return True + + browser = FakeBrowser(confirm=by_tab) + browser.call(CTX, "left_click", CLICK) # before any report there is no tab to describe + assert seen[-1] == (None, None) + + browser.call(CTX, "navigate", {"url": "https://shop.example.com/cart?x=1"}) + browser.call(CTX, "left_click", CLICK) + assert seen[-1] == ("https://shop.example.com/cart?x=1", "tab_1") + + # reading the tab costs nothing either: it is described from the last report, with no page read + assert len(browser.world.state_contexts) == 1 + 1 + 1 + + # A call that names a tab is described by that tab, not the active one. + browser.world.tabs["tab_2"] = {"title": "docs", "url": "https://docs.example.com/"} + browser.call(CTX, "list_tabs", {}) + browser.call(CTX, "left_click", {**CLICK, "tab_id": "tab_2"}) + assert seen[-1] == ("https://docs.example.com/", "tab_2") + + # tab_url is the URL as the driver reported it and the model read it — no rewriting — folded to one line and held + # to 4,096 characters. A longer one is cut and starts with `…`. + browser.call(CTX, "navigate", {"url": "HTTPS://trusted.example@Example.COM:443/cart"}) + browser.call(CTX, "left_click", CLICK) + assert seen[-1][0] == "HTTPS://trusted.example@Example.COM:443/cart" + + browser.world.tabs["tab_1"] = {"title": "t", "url": "https://example.com/a\u202e)b \u2028" + "c" * 5000} + browser.call(CTX, "list_tabs", {}) + browser.call(CTX, "left_click", CLICK) + tab_url, _ = seen[-1] + assert tab_url.startswith("\u2026https://example.com/a )b c") and len(tab_url) == 4096 and "\u202e" not in tab_url + + # A page can pad its URL so that its first 4,096 characters alone parse to another host. The leading `…` leaves + # no host to parse. + browser.world.tabs["tab_1"] = {"title": "t", "url": "https://trusted.bank.com:" + "0" * 4100 + "@evil.example/"} + browser.call(CTX, "list_tabs", {}) + browser.call(CTX, "left_click", CLICK) + cut_url: str = seen[-1][0] + assert cut_url.startswith("\u2026") and urlsplit(cut_url).hostname is None + + +def test_an_approval_covers_the_last_report_and_the_page_is_not_read_again() -> None: + # confirm describes the tab as of the last block the model saw; the page can move after that report, and the + # approved call runs wherever the tab is by then. + world = World() + looked: list[Any] = [] + + def confirm(context: BetaConfirmContext) -> bool: + looked.append(context.tab_url) + return True + + browser = FakeBrowser(world, confirm=confirm) + browser.call(CTX, "navigate", {"url": "https://shop.example.com/cart"}) + + world.tabs["tab_1"]["url"] = "https://elsewhere.example.net/next" + assert texts(browser.call(CTX, "left_click", CLICK)) == ["Clicked."] + assert looked[-1] == "https://shop.example.com/cart" and browser.world.calls == ["navigate", "left_click"] + assert len(browser.world.state_contexts) == 2 # one report per call, none for the prompt + + # The block that click produced showed the new page, so the next prompt describes it. + browser.call(CTX, "left_click", CLICK) + assert looked[-1] == "https://elsewhere.example.net/next" + + +def test_a_confirm_that_raises_refuses_the_call_and_a_tool_error_is_relayed() -> None: + def broken(context: BetaConfirmContext) -> bool: + raise RuntimeError("tty closed") + + assert refusal(FakeBrowser(confirm=broken), CTX, "left_click", CLICK) == FAILED + + def refusing(context: BetaConfirmContext) -> bool: + raise ToolError("clicks are disabled after 5pm") + + assert refusal(FakeBrowser(confirm=refusing), CTX, "left_click", CLICK) == "clicks are disabled after 5pm" + + +def test_a_confirm_answer_other_than_true_declines_the_call() -> None: + def vague(context: BetaConfirmContext) -> Any: + return "yes" + + browser = FakeBrowser(confirm=vague) + assert refusal(browser, CTX, "left_click", CLICK) == DECLINED and browser.world.calls == [] + + async def coroutine(context: BetaConfirmContext) -> bool: + return True + + class Prompt: + async def __call__(self, context: BetaConfirmContext) -> bool: + return context.member == "left_click" + + # an `async def`, an object whose `__call__` is one, and a partial of either are all seen when the toolset is built + for confirm in (coroutine, Prompt(), functools.partial(coroutine)): + with pytest.raises(ToolsetContractError, match="confirm is async on the synchronous toolset"): + FakeBrowser(confirm=cast(Any, confirm)) + + +def test_the_gate_runs_before_an_execute_override() -> None: + reached: list[str] = [] + + class Hooked(FakeBrowser): + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + reached.append(name) + return super().execute(context, name, input) + + with pytest.raises(ToolError): + Hooked(confirm=_deny).call(CTX, "left_click", CLICK) + assert reached == [] + + +def test_the_url_policy_runs_before_confirm() -> None: + asked: list[str] = [] + + def no_internal(context: Any, url: str) -> None: + if "internal" in url: + raise ToolError("blocked: internal") + + def confirm(context: BetaConfirmContext) -> bool: + asked.append(context.member) + return True + + browser = FakeBrowser(url_policy=no_internal, confirm=confirm) + with pytest.raises(ToolError, match="blocked"): + browser.call(CTX, "navigate", {"url": "https://wiki.internal/"}) + assert asked == [] + + +async def test_async_confirm_may_be_a_coroutine_or_a_plain_function() -> None: + async def deny(context: BetaConfirmContext) -> bool: + return False + + browser = AsyncFakeBrowser(confirm=deny) + with pytest.raises(ToolError) as caught: + await browser.call(CTX, "left_click", CLICK) + assert texts(caught.value.content) == [DECLINED] + assert browser.world.calls == [] + + allow = AsyncFakeBrowser(confirm=approve) + assert texts(await allow.call(CTX, "left_click", CLICK)) == ["Clicked."] + + # the tab description works the same way on the async class + seen: list[Any] = [] + + async def by_tab(context: BetaConfirmContext) -> bool: + seen.append((context.tab_url, context.tab_id)) + return True + + looking = AsyncFakeBrowser(confirm=by_tab) + await looking.call(CTX, "navigate", {"url": "https://shop.example.com/"}) + await looking.call(CTX, "left_click", CLICK) + assert seen[-1] == ("https://shop.example.com/", "tab_1") + + def vague(context: BetaConfirmContext) -> Any: + return None + + with pytest.raises(ToolError) as caught: + await AsyncFakeBrowser(confirm=vague).call(CTX, "left_click", CLICK) + assert texts(caught.value.content) == [DECLINED] + + +async def test_an_async_confirm_that_raises_refuses_the_call_and_a_tool_error_is_relayed() -> None: + async def broken(context: BetaConfirmContext) -> bool: + raise RuntimeError("tty closed") + + with pytest.raises(ToolError) as failed: + await AsyncFakeBrowser(confirm=broken).call(CTX, "left_click", CLICK) + assert texts(failed.value.content) == [FAILED] + + async def refusing(context: BetaConfirmContext) -> bool: + raise ToolError("clicks are disabled after 5pm") + + with pytest.raises(ToolError) as relayed: + await AsyncFakeBrowser(confirm=refusing).call(CTX, "left_click", CLICK) + assert texts(relayed.value.content) == ["clicks are disabled after 5pm"] + + +async def test_the_async_gate_runs_before_an_execute_override() -> None: + reached: list[str] = [] + + class Hooked(AsyncFakeBrowser): + @override + async def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + reached.append(name) + return await super().execute(context, name, input) + + with pytest.raises(ToolError): + await Hooked(confirm=_deny).call(CTX, "left_click", CLICK) + assert reached == [] + + +async def test_the_async_url_policy_runs_before_confirm() -> None: + asked: list[str] = [] + + async def no_internal(context: Any, url: str) -> None: + if "internal" in url: + raise ToolError("blocked: internal") + + async def confirm(context: BetaConfirmContext) -> bool: + asked.append(context.member) + return True + + browser = AsyncFakeBrowser(url_policy=no_internal, confirm=confirm) + with pytest.raises(ToolError, match="blocked"): + await browser.call(CTX, "navigate", {"url": "https://wiki.internal/"}) + assert asked == [] diff --git a/tests/lib/tools/toolsets/test_enforcement.py b/tests/lib/tools/toolsets/test_enforcement.py new file mode 100644 index 000000000..d9849e31d --- /dev/null +++ b/tests/lib/tools/toolsets/test_enforcement.py @@ -0,0 +1,613 @@ +# ruff: noqa: ARG001, ARG005 -- stub members and hooks ignore their arguments +"""URL policy and file policy enforcement in the call pipeline: the `url_policy` hook on `navigate`, and upload and +download path handling.""" + +from __future__ import annotations + +from typing import Any +from pathlib import Path +from typing_extensions import override + +import pytest + +from anthropic.tools import ( + ToolError, + ToolsetContractError, +) +from anthropic.types.beta import BetaToolUseBlock +from anthropic.tools.browser import ( + BetaURLContext, + BetaConfirmContext, + BetaDialogDismissed, + BetaLocalFilePolicy, + BetaToolsetCallContext, + BetaBrowserNavigateResult, +) + +from ._fakes import CLICK, UPLOAD, World, FakeBrowser, AsyncFakeBrowser, texts, approve, refusal, state_block + +CTX = BetaToolsetCallContext( + tool_use=BetaToolUseBlock(type="tool_use", id="toolu_9", name="navigate", input={}, toolset_name="browser") +) + + +def _on(world: World, tab_id: str, url: str, *, active: bool = False) -> None: + world.tabs[tab_id] = {"title": f"title of {url}", "url": url} + if active: + world.active = tab_id + + +# --- url_policy ----------------------------------------------------------------------------------- + + +def test_the_policy_is_called_once_per_navigate_with_the_url_as_written_before_the_driver_runs() -> None: + seen: list[Any] = [] + + def policy(ctx: BetaURLContext, url: str) -> None: + seen.append((ctx, url, list(world.calls))) + if "logout" in url: + raise ToolError("blocked: logout") + + world = World() + browser = FakeBrowser(world, url_policy=policy) + + for written in ( + "https://example.com/", + "example.com/path", + " Http://Exa\tmple.COM./x ", + "javascript:alert(1)", + "backwards", + ): + browser.call(CTX, "navigate", {"url": written, "tab_id": "tab_1"}) + + # once per call, with the string exactly as the model wrote it, and before the driver heard of it; the driver then + # receives that same string + assert [url for _, url, _ in seen] == [ + "https://example.com/", + "example.com/path", + " Http://Exa\tmple.COM./x ", + "javascript:alert(1)", + "backwards", + ] + assert [len(calls) for _, _, calls in seen] == [0, 1, 2, 3, 4] + assert [i.url for i in world.inputs] == [url for _, url, _ in seen] + assert all(ctx == BetaURLContext(member="navigate", tab_id="tab_1", tool_use_id="toolu_9") for ctx, _, _ in seen) + + # the history words are navigate's own vocabulary, not addresses: the policy is not asked, and the driver receives + # the canonical lower-case word + for written in ("back", " Forward ", "RELOAD"): + browser.call(CTX, "navigate", {"url": written}) + assert len(seen) == 5 and [i.url for i in world.inputs[-3:]] == ["back", "forward", "reload"] + + # a ToolError it raises is the refusal the model reads, and the driver is not called + assert refusal(browser, CTX, "navigate", {"url": "https://example.com/logout"}) == "blocked: logout" + assert len(seen) == 6 and world.calls.count("navigate") == 8 + + # no other member consults it, and nothing a result or a report carries does + _on(world, "tab_2", "https://example.com/logout") + world.results["navigate"] = BetaBrowserNavigateResult(url="https://example.com/logout", title="bye") + for name, given in [ + ("get_page_text", {}), + ("left_click", CLICK), + ("list_tabs", {}), + ("new_tab", {}), + ("switch_tab", {"tab_id": "tab_2"}), + ]: + browser.call(CTX, name, given) + assert texts(browser.call(CTX, "navigate", {"url": "https://example.com/in"}))[0] == ( + "Navigated to https://example.com/logout — bye" + ) + assert len(seen) == 7 + + # a call without a tab_id says so + browser.call(CTX, "navigate", {"url": "https://example.com/"}) + assert seen[-1][0].tab_id is None + + +def test_a_policy_that_raises_something_else_refuses_with_the_sdks_text_and_logs( + caplog: pytest.LogCaptureFixture, +) -> None: + def policy(ctx: BetaURLContext, url: str) -> None: + raise TypeError(f"could not parse {url}") + + browser = FakeBrowser(url_policy=policy) + # its own message (which names the URL) goes to the log, not the model + assert refusal(browser, CTX, "navigate", {"url": "https://example.com/oops"}) == "refused by the URL policy" + assert "could not parse https://example.com/oops" in caplog.text + assert browser.world.calls == [] + + # a ToolsetUsageError out of the policy is the developer's and stops the run + def misuse(ctx: BetaURLContext, url: str) -> None: + raise ToolsetContractError("wired wrong") + + with pytest.raises(ToolsetContractError, match="wired wrong"): + FakeBrowser(url_policy=misuse).call(CTX, "navigate", {"url": "https://example.com/"}) + + +def test_a_predicate_shaped_or_async_policy_on_the_sync_class_is_a_contract_error() -> None: + def predicate(ctx: BetaURLContext, url: str) -> Any: + return False + + with pytest.raises(ToolsetContractError, match="return None to allow.*; got bool"): + FakeBrowser(url_policy=predicate).call(CTX, "navigate", {"url": "https://example.com/"}) + + async def async_policy(ctx: BetaURLContext, url: str) -> None: + return None + + with pytest.raises(ToolsetContractError, match="url_policy is async on the synchronous toolset"): + FakeBrowser(url_policy=async_policy) + + +def test_unset_checks_nothing() -> None: + # left unset, navigate is not checked: whatever the model wrote reaches the driver as written + browser = FakeBrowser() + for url in ("javascript:alert(1)", "file:///etc/passwd", "http://10.0.0.5/", "chrome://settings", "not a url", ""): + browser.call(CTX, "navigate", {"url": url}) + assert [i.url for i in browser.world.inputs] == [ + "javascript:alert(1)", + "file:///etc/passwd", + "http://10.0.0.5/", + "chrome://settings", + "not a url", + "", + ] + + # a URL that is not a string is the model's input error and never reaches the driver + refusal(browser, CTX, "navigate", {"url": ["x"]}) + assert len(browser.world.calls) == 6 + + # and a reported address reaches the model as the driver reported it + for reported in ("file:///etc/passwd", "blob:null/1", "view-source:http://10.0.0.5/", "about:blank", ""): + _on(browser.world, "tab_1", reported, active=True) + content = browser.call(CTX, "get_page_text", {}) + assert texts(content) == ["Hello"] and state_block(content)["tabs"][0]["url"] == reported + + +async def test_an_explicit_none_policy_refuses_navigate_on_both_classes() -> None: + # None is not a way to leave navigate unchecked: it is called like any other policy, the call raises, and the + # navigate is refused with the SDK's own text before the driver hears of it. + sync = FakeBrowser(url_policy=None) # pyright: ignore[reportArgumentType] + assert refusal(sync, CTX, "navigate", {"url": "file:///etc/passwd"}) == "refused by the URL policy" + assert sync.world.calls == [] + + async_browser = AsyncFakeBrowser(url_policy=None) # pyright: ignore[reportArgumentType] + with pytest.raises(ToolError) as caught: + await async_browser.call(CTX, "navigate", {"url": "file:///etc/passwd"}) + assert texts(caught.value.content) == ["refused by the URL policy"] and async_browser.world.calls == [] + + +def test_a_call_naming_a_tab_the_last_report_did_not_list_goes_to_the_driver() -> None: + # The driver owns tab ids and answers an unknown one itself. `confirm` is asked about the call first, with no URL + # for a tab it was never shown. + asked: list[BetaConfirmContext] = [] + + def confirm(context: BetaConfirmContext) -> bool: + asked.append(context) + return True + + browser = FakeBrowser(confirm=confirm) + _on(browser.world, "tab_1", "https://example.com/", active=True) + browser.call(CTX, "list_tabs", {}) + assert ( + refusal(browser, CTX, "navigate", {"url": "x", "tab_id": "tab_nope"}) == "No open tab with tab_id 'tab_nope'." + ) + assert browser.world.calls[-1] == "navigate" + assert asked[-1].tab_id == "tab_nope" and asked[-1].tab_url is None + + +def test_a_download_change_without_a_url_is_the_drivers_bug() -> None: + # Built through pydantic, a download change without its url fails in the driver, or, where the model lets it + # through, when the SDK reads the report. + browser = FakeBrowser() + browser.world.changes = [{"type": "download_started", "download_id": "dl_1"}] # pyright: ignore[reportAttributeAccessIssue] + with pytest.raises(ToolsetContractError, match="_browser_state raised"): + browser.call(CTX, "get_page_text", {}) + + +def test_a_long_reported_tab_url_is_cut_to_the_field_limit_and_its_controls_folded() -> None: + browser = FakeBrowser() + long_url = "https://" + "u" * 4090 + "@example.com/admin\n\x0bnext\u202e" + _on(browser.world, "tab_1", "https://example.com/", active=True) + _on(browser.world, "tab_2", long_url) + + tabs = {t["tab_id"]: t["url"] for t in state_block(browser.call(CTX, "list_tabs", {}))["tabs"]} + assert tabs["tab_2"] == "\u2026" + long_url[:4095] and len(tabs["tab_2"]) == 4096 + + _on(browser.world, "tab_2", "https://exa\nmple.com/a b\x0b)\u202e\u2028c") + tabs = {t["tab_id"]: t["url"] for t in state_block(browser.call(CTX, "get_page_text", {}))["tabs"]} + assert tabs["tab_2"] == "https://exa mple.com/a b ) c" + + +# --- file policy ---------------------------------------------------------------------------------- + + +def test_uploads_are_refused_without_a_file_policy_and_resolved_with_one(tmp_path: Path) -> None: + root = tmp_path / "uploads" + root.mkdir() + (root / "form.pdf").write_text("x") + enable: Any = {"configs": {"file_upload": {"enabled": True}}, "confirm": approve} + + bare = FakeBrowser(**enable) + assert ( + refusal(bare, CTX, "file_upload", {**UPLOAD, "paths": [str(root / "form.pdf")]}) + == "file_upload has no configured upload roots" + ) + # A staged document is contained the same way: no policy, no upload; a policy lists the ids it allows. + assert ( + refusal(bare, CTX, "file_upload", {**UPLOAD, "document_ids": ["doc_1"]}) + == "file_upload has no configured document allowlist" + ) + + policy = BetaLocalFilePolicy(upload_roots=[str(root)], upload_document_ids=["doc_1"]) + browser = FakeBrowser(file_policy=policy, **enable) + assert texts(browser.call(CTX, "file_upload", {**UPLOAD, "paths": [str(root / "form.pdf")]})) == ["Uploaded."] + assert browser.world.inputs[-1].paths == [str((root / "form.pdf").resolve())] + assert texts(browser.call(CTX, "file_upload", {**UPLOAD, "document_ids": ["doc_1"]})) == ["Uploaded."] + assert browser.world.inputs[-1].document_ids == ["doc_1"] + assert refusal(browser, CTX, "file_upload", {**UPLOAD, "document_ids": ["doc_1", "doc_2"]}) == ( + "document not in the upload allowlist" + ) + assert refusal(browser, CTX, "file_upload", {**UPLOAD, "paths": [str(tmp_path / "secret")]}) == ( + "file_upload path is outside the configured upload roots" + ) + assert refusal(browser, CTX, "file_upload", {**UPLOAD, "paths": [str(root / ".." / "secret")]}) == ( + "file_upload path must not contain a '..' component" + ) + assert refusal(browser, CTX, "file_upload", {**UPLOAD, "paths": "form.pdf"}).startswith("invalid input") + # Only the two accepted uploads reached the driver. + assert browser.world.calls.count("file_upload") == 2 + + # an upload with no paths and no document ids names nothing to vet, so it is the driver's to answer + assert texts(browser.call(CTX, "file_upload", dict(UPLOAD))) == ["Uploaded."] + assert texts(bare.call(CTX, "file_upload", dict(UPLOAD))) == ["Uploaded."] + + +def test_a_file_policy_hook_may_return_any_iterable() -> None: + class Lazy(BetaLocalFilePolicy): + @override + def resolve_upload_documents(self, context: Any, document_ids: Any) -> Any: + return (d for d in document_ids) # a generator: read once, handed to the driver as a list + + browser = FakeBrowser(configs={"file_upload": {"enabled": True}}, confirm=approve, file_policy=Lazy()) + assert texts(browser.call(CTX, "file_upload", {**UPLOAD, "document_ids": ["doc_9"]})) == ["Uploaded."] + assert browser.world.inputs[-1].document_ids == ["doc_9"] + + +def test_download_paths_reach_the_model_only_through_an_exposing_file_policy() -> None: + def completed(world: World) -> None: + world.changes = [ + { + "type": "download_completed", + "download_id": "dl_1", + "url": "https://example.com/a", + "path": "/dl/x/a.bin", + }, + { + "type": "download_completed", + "download_id": "dl_2", + "url": "https://example.com/b", + "path": "/elsewhere/b.bin", + }, + ] + + def paths(browser: FakeBrowser) -> list[str | None]: + completed(browser.world) + return [c.get("path") for c in state_block(browser.call(CTX, "get_page_text", {}))["state_changes"]] + + assert paths(FakeBrowser()) == [None, None] + assert paths(FakeBrowser(file_policy=BetaLocalFilePolicy(download_dir="/dl"))) == [None, None] + assert paths(FakeBrowser(file_policy=BetaLocalFilePolicy(download_dir="/dl", expose_download_paths=True))) == [ + "/dl/x/a.bin", + None, + ] + + # A custom predicate that fails hides the path and the report still reaches the model. + class Flaky(BetaLocalFilePolicy): + @override + def is_path_visible(self, path: str) -> bool: + raise OSError("disk on fire") + + assert paths(FakeBrowser(file_policy=Flaky(download_dir="/dl", expose_download_paths=True))) == [None, None] + + # An exposed path reaches the block exactly as the driver reported it, spaces and parentheses kept. A path with a + # line break stays hidden, because folding it to one line would name a file the policy never checked. A path too + # long for the policy to resolve on this filesystem stays hidden too, because the policy cannot vouch for it. + exposing = FakeBrowser(file_policy=BetaLocalFilePolicy(download_dir="/dl", expose_download_paths=True)) + exposing.world.changes = [ + {"type": "download_completed", "download_id": "dl_3", "url": "https://example.com/c", "path": "/dl/x/c\n.bin"}, + { + "type": "download_completed", + "download_id": "dl_4", + "url": "https://example.com/d", + "path": "/dl/x/" + "d" * 5000, + }, + {"type": "download_completed", "download_id": "dl_5", "url": "https://example.com/e", "path": "/dl/x/e.bin"}, + { + "type": "download_completed", + "download_id": "dl_6", + "url": "https://example.com/f", + "path": "/dl/my file (1).pdf", + }, + ] + changes = state_block(exposing.call(CTX, "get_page_text", {}))["state_changes"] + assert [(c["download_id"], c.get("path")) for c in changes] == [ + ("dl_3", None), + ("dl_4", None), + ("dl_5", "/dl/x/e.bin"), + ("dl_6", "/dl/my file (1).pdf"), + ] + + +def test_a_file_policy_that_fails_refuses_the_upload_and_the_call_is_still_observed(tmp_path: Path) -> None: + class Broken(BetaLocalFilePolicy): + @override + def resolve_upload_paths(self, context: Any, paths: Any) -> list[str]: + raise OSError("disk on fire") + + enable: Any = {"configs": {"file_upload": {"enabled": True}}, "confirm": approve} + browser = FakeBrowser(file_policy=Broken(upload_roots=[str(tmp_path)]), **enable) + assert refusal(browser, CTX, "file_upload", {**UPLOAD, "paths": [str(tmp_path / "a.pdf")]}) == ( + "the file policy could not vet the upload" + ) + assert browser.world.calls.count("file_upload") == 0 + + # a generator does its work when the SDK reads it; a failure there fails closed the same way + class LazyBroken(BetaLocalFilePolicy): + @override + def resolve_upload_paths(self, context: Any, paths: Any) -> Any: + for path in paths: + raise FileNotFoundError(path) + yield path + + lazy = FakeBrowser(file_policy=LazyBroken(upload_roots=[str(tmp_path)]), **enable) + assert refusal(lazy, CTX, "file_upload", {**UPLOAD, "paths": [str(tmp_path / "a.pdf")]}) == ( + "the file policy could not vet the upload" + ) + assert lazy.world.calls.count("file_upload") == 0 + + +# --- error text ----------------------------------------------------------------------------------- + + +def test_a_drivers_error_text_reaches_the_model_as_written_and_cut_to_the_field_limit() -> None: + # The SDK does not look for local paths in a driver's text: a driver that must keep one from the model leaves it + # out. Only the length is bounded. + browser = FakeBrowser() + _on(browser.world, "tab_1", "https://example.com/", active=True) + browser.world.fail["left_click"] = ToolError("saved to /var/task/dl/f8a1.bin") + assert refusal(browser, CTX, "left_click", CLICK) == "saved to /var/task/dl/f8a1.bin" + browser.world.fail["left_click"] = ToolError("x" * 5000) + assert refusal(browser, CTX, "left_click", CLICK) == "x" * 4096 + + # a dialog's message and a failed download's error reach the model as written too, folded to one line + browser.world.fail.clear() + browser.world.changes = [ + {"type": "download_failed", "download_id": "dl_1", "url": "https://example.com/f", "error": "at /var/dl/x"}, + BetaDialogDismissed(kind="alert", message="see /var/task/secret"), + ] + content = browser.call(CTX, "left_click", CLICK) + assert texts(content)[1] == 'An alert dialog "see /var/task/secret" was dismissed.' + assert state_block(content)["state_changes"][0]["error"] == "at /var/dl/x" + + +def test_a_raised_exception_reads_as_its_type_and_message_only() -> None: + class AuthFailed(Exception): + @override + def __str__(self) -> str: + return "authentication failed" + + class BrokenMessage(Exception): + @override + def __str__(self) -> str: + raise ValueError("no message") + + browser = FakeBrowser() + _on(browser.world, "tab_1", "https://example.com/", active=True) + + # a constructor argument the message leaves out, such as a token, does not reach the model + browser.world.fail["left_click"] = AuthFailed("token-123") + assert refusal(browser, CTX, "left_click", CLICK) == "AuthFailed: authentication failed" + # with no message, or a message that cannot be read, the model gets the type + browser.world.fail["left_click"] = TimeoutError() + assert refusal(browser, CTX, "left_click", CLICK) == "TimeoutError" + browser.world.fail["left_click"] = BrokenMessage("x") + assert refusal(browser, CTX, "left_click", CLICK) == "BrokenMessage" + + +# --- async parity --------------------------------------------------------------------------------- + + +async def test_the_async_class_takes_a_plain_or_a_coroutine_policy_and_calls_it_the_same_way() -> None: + seen: list[Any] = [] + + def plain(ctx: BetaURLContext, url: str) -> None: + seen.append(("plain", ctx, url)) + if "internal" in url: + raise ToolError("blocked: internal") + + async def coroutine(ctx: BetaURLContext, url: str) -> None: + seen.append(("coroutine", ctx, url)) + if "internal" in url: + raise ToolError("blocked: internal") + + for policy in (plain, coroutine): + browser = AsyncFakeBrowser(url_policy=policy) + with pytest.raises(ToolError) as caught: + await browser.call(CTX, "navigate", {"url": "https://wiki.internal/", "tab_id": "tab_1"}) + assert texts(caught.value.content) == ["blocked: internal"] and browser.world.calls == [] + await browser.call(CTX, "navigate", {"url": " Example.COM/x "}) + await browser.call(CTX, "get_page_text", {}) + assert browser.world.calls == ["navigate", "get_page_text"] and browser.world.inputs[0].url == " Example.COM/x " + + assert [(kind, url) for kind, _, url in seen] == [ + ("plain", "https://wiki.internal/"), + ("plain", " Example.COM/x "), + ("coroutine", "https://wiki.internal/"), + ("coroutine", " Example.COM/x "), + ] + assert seen[0][1] == BetaURLContext(member="navigate", tab_id="tab_1", tool_use_id="toolu_9") + + async def predicate(ctx: BetaURLContext, url: str) -> Any: + return True + + with pytest.raises(ToolsetContractError, match="got bool"): + await AsyncFakeBrowser(url_policy=predicate).call(CTX, "navigate", {"url": "https://example.com/"}) + + unset = AsyncFakeBrowser() + await unset.call(CTX, "navigate", {"url": "javascript:alert(1)"}) + assert unset.world.inputs[-1].url == "javascript:alert(1)" + + +async def test_the_async_class_enforces_upload_roots_too(tmp_path: Path) -> None: + policy = BetaLocalFilePolicy(upload_roots=[str(tmp_path)]) + uploads = AsyncFakeBrowser(file_policy=policy, configs={"file_upload": {"enabled": True}}, confirm=approve) + + with pytest.raises(ToolError) as caught: + await uploads.call(CTX, "file_upload", {**UPLOAD, "paths": ["/etc/passwd"]}) + assert texts(caught.value.content) == ["file_upload path is outside the configured upload roots"] + assert uploads.world.calls == [] + + +async def test_a_policy_that_raises_something_else_refuses_with_the_sdks_text_and_logs_on_the_async_class( + caplog: pytest.LogCaptureFixture, +) -> None: + def plain(ctx: BetaURLContext, url: str) -> None: + raise TypeError(f"could not parse {url}") + + async def coroutine(ctx: BetaURLContext, url: str) -> None: + raise TypeError(f"could not parse {url}") + + for policy in (plain, coroutine): + caplog.clear() + browser = AsyncFakeBrowser(url_policy=policy) + + with pytest.raises(ToolError) as caught: + await browser.call(CTX, "navigate", {"url": "https://example.com/oops"}) + + # its own message (which names the URL) goes to the log, not the model + assert texts(caught.value.content) == ["refused by the URL policy"] + assert "could not parse https://example.com/oops" in caplog.text + assert browser.world.calls == [] + + # a ToolsetUsageError out of the policy is the developer's and stops the run + async def misuse(ctx: BetaURLContext, url: str) -> None: + raise ToolsetContractError("wired wrong") + + with pytest.raises(ToolsetContractError, match="wired wrong"): + await AsyncFakeBrowser(url_policy=misuse).call(CTX, "navigate", {"url": "https://example.com/"}) + + +async def test_a_file_policy_that_fails_refuses_the_upload_on_the_async_class(tmp_path: Path) -> None: + class Broken(BetaLocalFilePolicy): + @override + def resolve_upload_paths(self, context: Any, paths: Any) -> list[str]: + raise OSError("disk on fire") + + # a generator does its work when the SDK reads it; a failure there fails closed the same way + class LazyBroken(BetaLocalFilePolicy): + @override + def resolve_upload_paths(self, context: Any, paths: Any) -> Any: + for path in paths: + raise FileNotFoundError(path) + yield path + + class Misused(BetaLocalFilePolicy): + @override + def resolve_upload_paths(self, context: Any, paths: Any) -> list[str]: + raise ToolsetContractError("wired wrong") + + enable: Any = {"configs": {"file_upload": {"enabled": True}}, "confirm": approve} + for policy in (Broken(upload_roots=[str(tmp_path)]), LazyBroken(upload_roots=[str(tmp_path)])): + browser = AsyncFakeBrowser(file_policy=policy, **enable) + + with pytest.raises(ToolError) as caught: + await browser.call(CTX, "file_upload", {**UPLOAD, "paths": [str(tmp_path / "a.pdf")]}) + + assert texts(caught.value.content) == ["the file policy could not vet the upload"] + assert browser.world.calls.count("file_upload") == 0 + + # a ToolsetUsageError out of the policy is the developer's and stops the run + misused = AsyncFakeBrowser(file_policy=Misused(upload_roots=[str(tmp_path)]), **enable) + with pytest.raises(ToolsetContractError, match="wired wrong"): + await misused.call(CTX, "file_upload", {**UPLOAD, "paths": [str(tmp_path / "a.pdf")]}) + assert misused.world.calls.count("file_upload") == 0 + + +async def test_a_file_policy_hook_may_return_any_iterable_on_the_async_class() -> None: + enable: Any = {"configs": {"file_upload": {"enabled": True}}, "confirm": approve} + + class Lazy(BetaLocalFilePolicy): + @override + def resolve_upload_documents(self, context: Any, document_ids: Any) -> Any: + return (d for d in document_ids) # a generator: read once, handed to the driver as a list + + browser = AsyncFakeBrowser(file_policy=Lazy(), **enable) + assert texts(await browser.call(CTX, "file_upload", {**UPLOAD, "document_ids": ["doc_9"]})) == ["Uploaded."] + assert browser.world.inputs[-1].document_ids == ["doc_9"] + + +async def test_download_paths_reach_the_model_only_through_an_exposing_file_policy_on_the_async_class() -> None: + def completed(world: World) -> None: + world.changes = [ + { + "type": "download_completed", + "download_id": "dl_1", + "url": "https://example.com/a", + "path": "/dl/x/a.bin", + }, + { + "type": "download_completed", + "download_id": "dl_2", + "url": "https://example.com/b", + "path": "/elsewhere/b.bin", + }, + ] + + async def paths(browser: AsyncFakeBrowser) -> list[str | None]: + completed(browser.world) + return [c.get("path") for c in state_block(await browser.call(CTX, "get_page_text", {}))["state_changes"]] + + assert await paths(AsyncFakeBrowser()) == [None, None] + assert await paths(AsyncFakeBrowser(file_policy=BetaLocalFilePolicy(download_dir="/dl"))) == [None, None] + assert await paths( + AsyncFakeBrowser(file_policy=BetaLocalFilePolicy(download_dir="/dl", expose_download_paths=True)) + ) == ["/dl/x/a.bin", None] + + # A custom predicate that fails hides the path and the report still reaches the model. + class Flaky(BetaLocalFilePolicy): + @override + def is_path_visible(self, path: str) -> bool: + raise OSError("disk on fire") + + assert await paths(AsyncFakeBrowser(file_policy=Flaky(download_dir="/dl", expose_download_paths=True))) == [ + None, + None, + ] + + # An exposed path reaches the block exactly as the driver reported it, spaces and parentheses kept. A path with a + # line break stays hidden, because folding it to one line would name a file the policy never checked. A path too + # long for the policy to resolve on this filesystem stays hidden too, because the policy cannot vouch for it. + exposing = AsyncFakeBrowser(file_policy=BetaLocalFilePolicy(download_dir="/dl", expose_download_paths=True)) + exposing.world.changes = [ + {"type": "download_completed", "download_id": "dl_3", "url": "https://example.com/c", "path": "/dl/x/c\n.bin"}, + { + "type": "download_completed", + "download_id": "dl_4", + "url": "https://example.com/d", + "path": "/dl/x/" + "d" * 5000, + }, + {"type": "download_completed", "download_id": "dl_5", "url": "https://example.com/e", "path": "/dl/x/e.bin"}, + { + "type": "download_completed", + "download_id": "dl_6", + "url": "https://example.com/f", + "path": "/dl/my file (1).pdf", + }, + ] + changes = state_block(await exposing.call(CTX, "get_page_text", {}))["state_changes"] + assert [(c["download_id"], c.get("path")) for c in changes] == [ + ("dl_3", None), + ("dl_4", None), + ("dl_5", "/dl/x/e.bin"), + ("dl_6", "/dl/my file (1).pdf"), + ] diff --git a/tests/lib/tools/toolsets/test_interface.py b/tests/lib/tools/toolsets/test_interface.py new file mode 100644 index 000000000..8cbd67e3c --- /dev/null +++ b/tests/lib/tools/toolsets/test_interface.py @@ -0,0 +1,383 @@ +# ruff: noqa: ARG001, ARG005 -- stub members and hooks ignore their arguments +"""Construction rules and the dispatch surface of the abstract browser toolset classes.""" + +from __future__ import annotations + +from typing import Any, cast +from typing_extensions import override + +import pytest + +from anthropic.tools import ( + ToolError, + ToolsetConfigError, + ToolsetContractError, +) +from anthropic.types.beta import ( + BetaBrowserNavigateInput, + BetaBrowserScreenshotInput, + BetaBrowserGetPageTextInput, +) +from anthropic.tools.browser import ( + BetaBrowserState, + BetaScreenshotResult, + BetaToolsetCallContext, + BetaBrowserNavigateResult, + BetaAbstractBrowserToolset20260801, + BetaAsyncAbstractBrowserToolset20260801, +) +from anthropic.lib._stainless_helpers import get_helper_tag + +from ._fakes import approve + +CTX = BetaToolsetCallContext() + + +class _Base(BetaAbstractBrowserToolset20260801): + @override + def _browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + return BetaBrowserState(tabs=[]) + + +class _AsyncBase(BetaAsyncAbstractBrowserToolset20260801): + @override + async def _browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + return BetaBrowserState(tabs=[]) + + +class TwoMembers(_Base): + @override + def navigate(self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput) -> BetaBrowserNavigateResult: + return BetaBrowserNavigateResult(url=input.url, status=200, title="T") + + @override + def screenshot(self, context: BetaToolsetCallContext, input: BetaBrowserScreenshotInput) -> BetaScreenshotResult: + return BetaScreenshotResult(data="AAAA") + + +class AsyncTwoMembers(_AsyncBase): + @override + async def navigate( + self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput + ) -> BetaBrowserNavigateResult: + return BetaBrowserNavigateResult(url=input.url, status=200, title="T") + + @override + async def screenshot( + self, context: BetaToolsetCallContext, input: BetaBrowserScreenshotInput + ) -> BetaScreenshotResult: + return BetaScreenshotResult(data="AAAA") + + +def _disabled(entry: Any) -> list[str]: + configs = cast("dict[str, dict[str, object]]", entry.get("configs") or {}) + return sorted(name for name, config in configs.items() if (config or {}).get("enabled") is False) + + +def test_browser_state_is_abstract() -> None: + # A driver that does not implement _browser_state cannot be constructed: the report is the model's only view of + # the tabs, so a silently empty one would hide every driver bug behind a working-looking toolset. + class NoState(BetaAbstractBrowserToolset20260801): + pass + + with pytest.raises(TypeError, match="_browser_state"): + NoState() # pyright: ignore[reportAbstractUsage] + + +def test_unimplemented_members_are_sent_as_disabled_and_implemented_ones_left_to_the_api() -> None: + entry: Any = TwoMembers().to_dict() + assert entry["type"] == "browser_toolset_20260801" + disabled = _disabled(entry) + assert "navigate" not in disabled and "screenshot" not in disabled + + # Everything the subclass did not override is withheld, default-disabled members included. + assert len(disabled) == 29 and "left_click" in disabled and "javascript_exec" in disabled + + # Nothing SDK-side leaks into the entry. + assert set(entry) == {"type", "configs"} + assert all(set(config) == {"enabled"} for config in entry["configs"].values()) + + +def test_the_callers_configs_pass_through_untouched_and_are_copied() -> None: + configs: Any = {"navigate": {"defer_loading": True}, "javascript_exec": {"enabled": False, "defer_loading": True}} + browser = TwoMembers(configs=configs) + entry: Any = browser.to_dict() + assert entry["configs"]["navigate"] == {"defer_loading": True} + assert entry["configs"]["javascript_exec"] == {"enabled": False, "defer_loading": True} + + # Enabling a member the subclass does not implement would offer the model something that can only + # answer "not available": refused at construction. + with pytest.raises(ToolsetConfigError, match="does not implement"): + TwoMembers(configs={"javascript_exec": {"enabled": True}}) + + configs["navigate"]["enabled"] = False + assert browser.to_dict()["configs"]["navigate"] == {"defer_loading": True} # pyright: ignore[reportTypedDictNotRequiredAccess, reportOptionalSubscript] + assert browser.to_dict() is not browser.to_dict() + + +def test_an_execute_only_subclass_serves_every_member_and_turns_members_off_with_configs() -> None: + class Forwarder(_Base): + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return f"forwarded {name}" + + # A class that overrides execute serves every member, whether or not it also implements member methods: the SDK + # cannot tell which members the override answers, so it offers them all (the wire entry disables nothing) and the + # driver turns off what it does not serve through configs. + entry = Forwarder().to_dict() + assert _disabled(entry) == [] + trimmed = Forwarder(configs={"screenshot": {"enabled": False}, "zoom": {"enabled": False}}).to_dict() + assert _disabled(trimmed) == ["screenshot", "zoom"] + + # A subclass that only overrides member methods serves only those. + assert "zoom" in _disabled(TwoMembers().to_dict()) + + # One that adds hooks around super().execute also serves every member: a member it implements runs, one it does + # not answers from its default body inside super().execute, and configs turns off the rest. + class Hooked(TwoMembers): + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return super().execute(context, name, input) + + assert _disabled(Hooked().to_dict()) == [] + assert Hooked().execute(CTX, "navigate", BetaBrowserNavigateInput(url="https://example.com/")).status == 200 + with pytest.raises(ToolError, match="not available"): + Hooked().execute(CTX, "zoom", cast(Any, {"region": [0, 0, 1, 1]})) + assert _disabled(Hooked(configs={"zoom": {"enabled": False}}).to_dict()) == ["zoom"] + + +def test_a_null_configs_entry_leaves_the_member_at_its_defaults_and_goes_to_the_api_as_given() -> None: + # The wire type spells "this member's defaults" as null as well as by leaving the key out. + entry = cast(Any, TwoMembers(configs={"navigate": None, "screenshot": None}).to_dict()) + assert entry["configs"]["navigate"] is None and "navigate" not in _disabled(entry) + assert TwoMembers(configs={"navigate": None}).call(CTX, "navigate", {"url": "https://example.com/"}) + # (an entry that is not a mapping, or an unknown member key, is the type checker's to catch) + + +def test_configs_are_gated_on_the_copy_that_is_sent() -> None: + # A mapping that answers one thing to a lookup and another when copied cannot leave a member on that reads as + # off: the dispatch gate reads the plain copy the wire entry carries, and an `enabled` the types do not allow + # fails closed there (anything but True or None reads as off) rather than being re-checked at construction. + class Shifty(dict[str, object]): + @override + def get(self, key: str, default: object = None) -> object: + return False if key == "enabled" else super().get(key, default) + + shifty = TwoMembers(configs=cast(Any, {"navigate": Shifty(enabled="no")})) + assert cast(Any, shifty.to_dict())["configs"]["navigate"] == {"enabled": "no"} # what the API is told (and rejects) + + with pytest.raises(ToolError, match="not permitted"): + shifty.call(CTX, "navigate", {"url": "https://example.com/"}) + + off = cast(Any, TwoMembers(configs=cast(Any, {"navigate": Shifty(enabled=False)})).to_dict()) + assert off["configs"]["navigate"] == {"enabled": False} and type(off["configs"]["navigate"]) is dict + + # an entry that is not a mapping at all fails loudly rather than collapsing to {} (member left on) + with pytest.raises(TypeError): + TwoMembers(configs=cast(Any, {"navigate": []})) + + # a mapping that claims to be empty but iterates entries is read by its entries, not its truthiness + class Sly(dict[str, object]): + @override + def __len__(self) -> int: + return 0 + + sly = cast(Any, TwoMembers(configs=cast(Any, Sly(navigate={"enabled": False}))).to_dict()) + assert sly["configs"]["navigate"] == {"enabled": False} + + +def test_url_policy_is_a_callable_or_unset() -> None: + def policy(ctx: object, url: str) -> None: + return None + + TwoMembers(url_policy=policy) + TwoMembers() + + # (what url_policy, confirm, file_policy and tool_configs take is the type checker's to enforce) + + +def test_tool_configs_go_onto_the_entry_and_the_helper_tag_does_not() -> None: + given: Any = {"cache_control": {"type": "ephemeral"}} + browser = TwoMembers(tool_configs=given) + assert browser.to_dict().get("cache_control") == {"type": "ephemeral"} + assert get_helper_tag(browser) == "browser-toolset" + assert "browser-toolset" not in str(browser.to_dict()) + + # copied at construction and per call: nothing done to the dicts afterwards reaches the entry + given["cache_control"]["type"] = "changed" + cast(Any, browser.to_dict())["cache_control"]["ttl"] = "1h" + assert browser.to_dict().get("cache_control") == {"type": "ephemeral"} + + with pytest.raises(ToolsetConfigError, match="tool_configs cannot set 'configs'; pass configs= instead"): + TwoMembers(tool_configs=cast(Any, {"configs": {"navigate": {"enabled": False}}})) + + assert ( + TwoMembers(tool_configs=cast(Any, {"type": "browser_toolset_20990101"})).to_dict()["type"] + == "browser_toolset_20260801" + ) + + +def test_execute_dispatches_to_the_member_method_and_refuses_unknown_names() -> None: + browser = TwoMembers() + result = browser.execute(CTX, "navigate", BetaBrowserNavigateInput(url="https://example.com/")) + assert result == BetaBrowserNavigateResult(url="https://example.com/", status=200, title="T") + + with pytest.raises(ToolError) as unknown: + browser.execute(CTX, "__init__", BetaBrowserNavigateInput(url="x")) # pyright: ignore[reportArgumentType] + assert str(unknown.value) == "Error: unknown browser toolset member '__init__'" + + # A member the subclass did not implement answers the model rather than raising into the runner. + with pytest.raises(ToolError) as unavailable: + browser.execute(CTX, "get_page_text", BetaBrowserGetPageTextInput()) + assert str(unavailable.value) == "The browser toolset member 'get_page_text' is not available in this environment." + + +def test_unknown_member_name_is_sanitised_in_the_refusal() -> None: + with pytest.raises(ToolError) as caught: + TwoMembers().execute(CTX, "x'\n\u202eevil", BetaBrowserNavigateInput(url="x")) # pyright: ignore[reportArgumentType] + assert str(caught.value) == "Error: unknown browser toolset member 'x evil'" # one line, no quote, runs collapsed + + +def test_an_async_member_on_the_sync_class_is_a_contract_error() -> None: + class Mixed(_Base): + @override + async def navigate( # pyright: ignore[reportIncompatibleMethodOverride] + self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput + ) -> BetaBrowserNavigateResult: + return BetaBrowserNavigateResult(url=input.url) + + # reported when the toolset is built, before any call + with pytest.raises(ToolsetContractError, match="browser member 'navigate' is async on the synchronous toolset"): + Mixed() + + +async def test_async_execute_awaits_async_members_and_a_sync_one_is_a_contract_error() -> None: + class Navigates(_AsyncBase): + @override + async def navigate( + self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput + ) -> BetaBrowserNavigateResult: + return BetaBrowserNavigateResult(url=input.url) + + browser = Navigates() + assert await browser.execute(CTX, "navigate", BetaBrowserNavigateInput(url="u")) == BetaBrowserNavigateResult( + url="u" + ) + + class SyncMember(Navigates): + @override + def get_page_text(self, context: BetaToolsetCallContext, input: BetaBrowserGetPageTextInput) -> str: # pyright: ignore[reportIncompatibleMethodOverride] + return "text" + + # a member written sync on the async class is a wiring mistake, reported when the toolset is built rather than + # run on the event loop + with pytest.raises( + ToolsetContractError, match="browser member 'get_page_text' is sync on the asynchronous toolset" + ): + SyncMember() + + +def test_override_detection_sees_dynamic_assignment_and_intermediate_classes() -> None: + class Middle(TwoMembers): + pass + + class Leaf(Middle): + @override + def get_page_text(self, context: BetaToolsetCallContext, input: BetaBrowserGetPageTextInput) -> str: + return "t" + + disabled = _disabled(Leaf().to_dict()) + assert {"navigate", "screenshot", "get_page_text"}.isdisjoint(disabled) + + def wait(self: Any, context: Any, input: Any) -> None: + return None + + Patched = type("Patched", (TwoMembers,), {"wait": wait}) + assert "wait" not in _disabled(Patched().to_dict()) + + +def test_options_do_not_leak_into_the_entry() -> None: + entry = TwoMembers(confirm=approve, url_policy=lambda ctx, url: None).to_dict() + assert set(entry) == {"type", "configs"} + + +def test_configs_is_a_read_only_copy_of_what_to_dict_sends() -> None: + class AsyncOne(_AsyncBase): + @override + async def navigate( + self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput + ) -> BetaBrowserNavigateResult: + return BetaBrowserNavigateResult(url=input.url) + + for browser in (TwoMembers(configs={"javascript_exec": {"enabled": False}}), AsyncOne()): + configs: Any = browser.configs + assert configs is not None and configs == cast(Any, browser.to_dict())["configs"] + assert configs["left_click"] == {"enabled": False} # not implemented, so withheld from the model + configs["left_click"] = {"enabled": True} # a copy: nothing the toolset holds changes + assert cast(Any, browser.configs)["left_click"] == {"enabled": False} + assert browser.configs is not browser.configs + with pytest.raises(AttributeError): + browser.configs = {} # pyright: ignore[reportAttributeAccessIssue] + + +def test_async_unimplemented_members_are_sent_as_disabled_and_implemented_ones_left_to_the_api() -> None: + entry: Any = AsyncTwoMembers().to_dict() + assert entry["type"] == "browser_toolset_20260801" + disabled = _disabled(entry) + assert "navigate" not in disabled and "screenshot" not in disabled + + # Everything the subclass did not override is withheld, default-disabled members included. + assert len(disabled) == 29 and "left_click" in disabled and "javascript_exec" in disabled + + # Nothing SDK-side leaks into the entry. + assert set(entry) == {"type", "configs"} + assert all(set(config) == {"enabled"} for config in entry["configs"].values()) + + +async def test_async_execute_dispatches_to_the_member_method_and_refuses_unknown_names() -> None: + browser = AsyncTwoMembers() + result = await browser.execute(CTX, "navigate", BetaBrowserNavigateInput(url="https://example.com/")) + assert result == BetaBrowserNavigateResult(url="https://example.com/", status=200, title="T") + + with pytest.raises(ToolError) as unknown: + await browser.execute(CTX, "__init__", BetaBrowserNavigateInput(url="x")) # pyright: ignore[reportArgumentType] + assert str(unknown.value) == "Error: unknown browser toolset member '__init__'" + + # A member the subclass did not implement answers the model rather than raising into the runner. + with pytest.raises(ToolError) as unavailable: + await browser.execute(CTX, "get_page_text", BetaBrowserGetPageTextInput()) + assert str(unavailable.value) == "The browser toolset member 'get_page_text' is not available in this environment." + + +async def test_async_unknown_member_name_is_sanitised_in_the_refusal() -> None: + with pytest.raises(ToolError) as caught: + await AsyncTwoMembers().execute(CTX, "x'\n\u202eevil", BetaBrowserNavigateInput(url="x")) # pyright: ignore[reportArgumentType] + assert str(caught.value) == "Error: unknown browser toolset member 'x evil'" + + +async def test_an_async_execute_override_serves_every_member_and_turns_members_off_with_configs() -> None: + class Forwarder(_AsyncBase): + @override + async def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return f"forwarded {name}" + + # A class that overrides execute serves every member, so the wire entry disables nothing and the driver turns off + # what it does not serve through configs. + assert _disabled(Forwarder().to_dict()) == [] + trimmed = Forwarder(configs={"screenshot": {"enabled": False}, "zoom": {"enabled": False}}).to_dict() + assert _disabled(trimmed) == ["screenshot", "zoom"] + # A subclass that only overrides member methods serves only those. + assert "zoom" in _disabled(AsyncTwoMembers().to_dict()) + + # One that adds hooks around super().execute also serves every member: a member it implements runs, one it does + # not answers from its default body inside super().execute, and configs turns off the rest. + class Hooked(AsyncTwoMembers): + @override + async def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return await super().execute(context, name, input) + + assert _disabled(Hooked().to_dict()) == [] + assert (await Hooked().execute(CTX, "navigate", BetaBrowserNavigateInput(url="https://example.com/"))).status == 200 + with pytest.raises(ToolError, match="not available"): + await Hooked().execute(CTX, "zoom", cast(Any, {"region": [0, 0, 1, 1]})) + assert _disabled(Hooked(configs={"zoom": {"enabled": False}}).to_dict()) == ["zoom"] diff --git a/tests/lib/tools/toolsets/test_pipeline.py b/tests/lib/tools/toolsets/test_pipeline.py new file mode 100644 index 000000000..06fa46f8a --- /dev/null +++ b/tests/lib/tools/toolsets/test_pipeline.py @@ -0,0 +1,1503 @@ +# ruff: noqa: ARG002 -- stub members and hooks ignore their arguments +"""The call pipeline: resolve → parse → execute → browser_state → render, on both classes.""" + +from __future__ import annotations + +import re +import json +import inspect +import threading +from typing import Any, cast +from pathlib import Path +from collections.abc import Sequence +from typing_extensions import override + +import anyio +import pytest +import pydantic + +from anthropic.tools import ( + ToolError, + ToolsetClosedError, + ToolsetConfigError, + ToolsetContractError, +) +from anthropic._compat import PYDANTIC_V1 +from anthropic.types.beta import ( + BetaToolUseBlock, + BetaBrowserNavigateInput, + BetaBrowserJavascriptExecInput, + BetaBrowserStateChangeDownloadCompletedParam, +) +from anthropic.tools.browser import ( + BetaURLContext, + BetaBrowserState, + BetaConfirmContext, + BetaDialogDismissed, + BetaLocalFilePolicy, + BetaScreenshotResult, + BetaNavigationRefused, + BetaToolsetCallContext, + BetaBrowserNavigateResult, + BetaAbstractBrowserToolset20260801, +) +from anthropic.lib.tools._toolsets._runnable import unvalidated +from anthropic.lib.tools._toolsets._sanitize import one_line, quoted_name, bounded_tab_url + +from ._fakes import CLICK, World, FakeBrowser, AsyncFakeBrowser, texts, approve, refusal, state_block + +CTX = BetaToolsetCallContext() + + +def _use(name: str, input: dict[str, Any], id: str = "toolu_1") -> BetaToolUseBlock: + return BetaToolUseBlock(type="tool_use", id=id, name=name, input=input, toolset_name="browser") + + +# --- rendering ------------------------------------------------------------------------------------ + + +def test_navigate_renders_one_line_and_attaches_the_browser_state_block() -> None: + browser = FakeBrowser() + content = browser.call(CTX, "navigate", {"url": "https://example.com/"}) + assert content == [ + {"type": "text", "text": "Navigated to https://example.com/ — Title of https://example.com/ (HTTP 200)"}, + { + "type": "browser_state", + "tabs": [ + { + "tab_id": "tab_1", + "title": "Title of https://example.com/", + "url": "https://example.com/", + "active": True, + } + ], + }, + ] + + +def test_navigate_line_folds_control_characters_and_omits_absent_fields() -> None: + class Bare(FakeBrowser): + @override + def navigate( + self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput + ) -> BetaBrowserNavigateResult: + return BetaBrowserNavigateResult(url=input.url, title="A\nB\u202eC") + + (line,) = texts(Bare().call(CTX, "navigate", {"url": "https://x.test/"})) + assert line == "Navigated to https://x.test/ — A B C" + + # the landed address is the page's choice through redirects: it reaches the line as the driver reported it, held + # to the field limit + class Redirecting(FakeBrowser): + @override + def navigate( + self, context: BetaToolsetCallContext, input: BetaBrowserNavigateInput + ) -> BetaBrowserNavigateResult: + return BetaBrowserNavigateResult(url="https://accounts.example.com/" + "a" * 5000) + + (line,) = texts(Redirecting().call(CTX, "navigate", {"url": "https://x.test/"})) + assert line == "Navigated to \u2026" + ("https://accounts.example.com/" + "a" * 5000)[:4095] + + +def test_pure_actions_render_their_confirmation_from_the_input() -> None: + browser = FakeBrowser() + assert texts(browser.call(CTX, "left_click", {"target": {"type": "coordinate", "x": 1, "y": 2}})) == ["Clicked."] + assert texts( + browser.call(CTX, "scroll", {"scroll_direction": "down", "target": {"type": "coordinate", "x": 0, "y": 0}}) + ) == ["Scrolled down."] + assert texts(browser.call(CTX, "scroll_to", {"target": {"type": "ref", "ref": "e12"}})) == ["Scrolled to e12."] + assert texts(browser.call(CTX, "key", {"text": "ctrl+a"})) == ["Pressed ctrl+a."] + assert texts(browser.call(CTX, "wait", {"duration": 1.5})) == ["Waited 1.5s."] + assert texts(browser.call(CTX, "wait", {"duration": 2})) == ["Waited 2s."] + + # A model-supplied value that looks like a placeholder stays data. + assert texts(browser.call(CTX, "key", {"text": "{duration}"})) == ["Pressed {duration}."] + + +def test_a_pure_action_may_return_one_line_the_model_reads_after_the_acknowledgment() -> None: + # The line is page-shaped, so it is folded to one line as a title is. It is its own text block, after the + # acknowledgment's. + browser = FakeBrowser() + browser.world.results["left_click"] = "Opened menu\nwith 3 items from /var/task/menu.json" + content = browser.call(CTX, "left_click", CLICK) + assert texts(content) == ["Clicked.", "Opened menu with 3 items from /var/task/menu.json"] + assert [block["type"] for block in content] == ["text", "text", "browser_state"] + + # an address stays as the driver wrote it + browser.world.results["left_click"] = "Opened https://u:p@example.com/menu" + assert texts(browser.call(CTX, "left_click", CLICK)) == ["Clicked.", "Opened https://u:p@example.com/menu"] + + # nothing returned, or anything but a non-empty string, is the acknowledgment alone + for returned in (None, "", " \n ", 3, ["Opened"]): + browser.world.results["left_click"] = returned + assert texts(browser.call(CTX, "left_click", CLICK)) == ["Clicked."] + + # the line is bounded like every other page-supplied value the SDK writes into its own text + browser.world.results["left_click"] = "x" * 10_000 + assert texts(browser.call(CTX, "left_click", CLICK)) == ["Clicked.", "x" * 4096] + + +async def test_an_async_pure_action_may_return_one_line_too() -> None: + browser = AsyncFakeBrowser() + browser.world.results["left_click"] = "Opened menu\u2028at /var/task/menu.json" + assert texts(await browser.call(CTX, "left_click", CLICK)) == ["Clicked.", "Opened menu at /var/task/menu.json"] + + browser.world.results["left_click"] = None + assert texts(await browser.call(CTX, "left_click", CLICK)) == ["Clicked."] + + # bounded as on the sync path + browser.world.results["left_click"] = "x" * 10_000 + assert texts(await browser.call(CTX, "left_click", CLICK)) == ["Clicked.", "x" * 4096] + + +def test_a_duration_is_not_bounded_by_the_sdk() -> None: + # as in TypeScript: the value reaches the driver as sent, and a driver's refusal is what the model reads + browser = FakeBrowser() + assert texts(browser.call(CTX, "wait", {"duration": 60})) == ["Waited 60s."] + assert texts(browser.call(CTX, "hold_key", {"text": "a", "duration": -1})) == ["Held a for -1s."] + assert browser.world.calls == ["wait", "hold_key"] + + browser.world.fail["wait"] = ToolError("duration: must be between 0 and 30 seconds") + assert refusal(browser, CTX, "wait", {"duration": 60}) == "duration: must be between 0 and 30 seconds" + + +def test_screenshot_renders_one_image_block_as_returned() -> None: + content = FakeBrowser().call(CTX, "screenshot", {}) + assert content[0] == { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="}, + } + assert content[1]["type"] == "browser_state" + + +def test_text_members_render_verbatim_and_empty_text_gets_a_placeholder() -> None: + browser = FakeBrowser() + assert texts(browser.call(CTX, "get_page_text", {})) == ["Hello"] + browser.world.page_text = "" + assert texts(browser.call(CTX, "get_page_text", {})) == ["(empty)"] + + # page text can carry an unpaired surrogate (legal in the DOM); it could not be sent, so it becomes U+FFFD + browser.world.page_text = "a\ud83dz" + assert texts(browser.call(CTX, "get_page_text", {})) == ["a\ufffdz"] + + +def test_tab_members_render_the_browser_state_block_alone() -> None: + browser = FakeBrowser() + content = browser.call(CTX, "new_tab", {}) + assert content == [ + { + "type": "browser_state", + "tabs": [ + {"tab_id": "tab_1", "title": "Blank", "url": "about:blank", "active": False}, + {"tab_id": "tab_2", "title": "", "url": "about:blank", "active": True}, + ], + "state_changes": [{"type": "tab_opened", "tab_id": "tab_2"}], + } + ] + + assert [b["type"] for b in browser.call(CTX, "list_tabs", {})] == ["browser_state"] + assert [b["type"] for b in browser.call(CTX, "switch_tab", {"tab_id": "tab_1"})] == ["browser_state"] + assert [b["type"] for b in browser.call(CTX, "close_tab", {"tab_id": "tab_2"})] == ["browser_state"] + assert [t["tab_id"] for t in state_block(browser.call(CTX, "list_tabs", {}))["tabs"]] == ["tab_1"] + + +def test_state_changes_pass_through_and_download_paths_stay_hidden_without_a_file_policy() -> None: + browser = FakeBrowser() + browser.world.changes = [ # pyright: ignore[reportAttributeAccessIssue] + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a.pdf", "path": "/tmp/a"}, + { + "type": "download_completed", + "download_id": "dl_2", + "url": "https://example.com/b.pdf", + "path": "/tmp/b", + "size_bytes": 3, + }, + {"type": "download_failed", "download_id": "dl_3", "url": "https://example.com/c.pdf", "path": "/tmp/c"}, + ] + + block = state_block(browser.call(CTX, "get_page_text", {})) + # a path is dropped from whichever download change carries it, not only the completed one + assert block["state_changes"] == [ + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a.pdf"}, + {"type": "download_completed", "download_id": "dl_2", "url": "https://example.com/b.pdf", "size_bytes": 3}, + {"type": "download_failed", "download_id": "dl_3", "url": "https://example.com/c.pdf"}, + ] + + # Nothing to report is an absent field, never an empty list. + assert "state_changes" not in state_block(browser.call(CTX, "get_page_text", {})) + + +def test_a_download_path_is_exposed_only_when_the_file_policy_answers_true() -> None: + change: BetaBrowserStateChangeDownloadCompletedParam = { + "type": "download_completed", + "download_id": "dl_1", + "url": "https://example.com/a", + "size_bytes": 1, + } + + class Answering: + def __init__(self, answer: object) -> None: + self.answer = answer + + def resolve_upload_paths(self, context: object, paths: object) -> list[str]: + return [] + + def resolve_upload_documents(self, context: object, document_ids: object) -> list[str]: + return [] + + def is_path_visible(self, path: str) -> bool: + return self.answer # pyright: ignore[reportReturnType] + + class Truthy: + def __bool__(self) -> bool: + return True + + for answer, exposed in [(True, True), (False, False)]: + browser = FakeBrowser(file_policy=Answering(answer)) + browser.world.changes = [{**change, "path": "/tmp/a"}] + (reported,) = state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] + assert ("path" in reported) is exposed + + # any other answer hides it, even a truthy one + for answer in (Truthy(), 1, None): + browser = FakeBrowser(file_policy=Answering(answer)) + browser.world.changes = [{**change, "path": "/tmp/a"}] + assert state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] == [change] + + # so does what a policy written `async def is_path_visible` returns, and the coroutine is closed, so no "never + # awaited" warning is left behind + async def predicate() -> bool: + return True + + coroutine = predicate() + browser = FakeBrowser(file_policy=Answering(coroutine)) + browser.world.changes = [{**change, "path": "/tmp/a"}] + assert state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] == [change] + assert inspect.getcoroutinestate(coroutine) == "CORO_CLOSED" + + # a policy that raises on a path fails closed: the path is hidden and the change still reaches the model + class Raising(Answering): + @override + def is_path_visible(self, path: str) -> bool: + raise RuntimeError(path) + + browser = FakeBrowser(file_policy=Raising(True)) + browser.world.changes = [{**change, "path": "/tmp/a"}] + assert state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] == [change] + + # A server picks a download's file name (its Content-Disposition). If folding the path to one line or cutting it + # would change it, the path is hidden, since the changed text could name a file the policy never checked. + browser = FakeBrowser(file_policy=Answering(True)) + browser.world.changes = [{**change, "path": "/tmp/evil\n- tab_9 x\u202e" + "p" * 5000}] + (reported,) = state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] + assert "path" not in reported + + +async def test_an_async_file_policy_hides_the_path_on_the_async_class_too() -> None: + class AsyncPolicy(BetaLocalFilePolicy): + @override + async def is_path_visible(self, path: str) -> bool: # pyright: ignore[reportIncompatibleMethodOverride] + return True + + browser = AsyncFakeBrowser(file_policy=AsyncPolicy(download_dir="/dl", expose_download_paths=True)) + browser.world.changes = [ # pyright: ignore[reportAttributeAccessIssue] + {"type": "download_completed", "download_id": "dl_1", "url": "https://example.com/a", "path": "/dl/a"} + ] + (reported,) = state_block(await browser.call(CTX, "get_page_text", {}))["state_changes"] + assert "path" not in reported + + +# --- refusals ------------------------------------------------------------------------------------- + + +def test_refusals_before_dispatch_read_as_tool_errors_and_never_reach_the_driver() -> None: + browser = FakeBrowser(configs={"navigate": {"enabled": False}}) + assert refusal(browser, CTX, "teleport", {}) == "Error: unknown browser toolset member 'teleport'" + assert ( + refusal(browser, CTX, "navigate", {"url": "https://example.com/"}) + == "The 'navigate' action is not permitted by this application's permissions and cannot be used in this session." + ) + + # Not implemented by the fake and not configured: not available, rather than not permitted. + assert ( + refusal(browser, CTX, "javascript_exec", {"text": "1"}) + == "The browser toolset member 'javascript_exec' is not available in this environment." + ) + assert ( + refusal(browser, CTX, "find", {"query": "q"}) + == "The browser toolset member 'find' is not available in this environment." + ) + + # pydantic v1 and v2 word the detail differently ("field required" / "Field required"). + assert ( + refusal(browser, CTX, "wait", {}).lower() == "invalid input for browser member 'wait': duration: field required" + ) + assert refusal(browser, CTX, "wait", {"duration": "long"}).startswith( + "invalid input for browser member 'wait': duration:" + ) + + # every bad field is reported, not just the first + both = refusal(browser, CTX, "scroll", {"scroll_direction": "diagonal", "target": {"type": "ref"}}) + assert "scroll_direction" in both and "target" in both + assert browser.world.calls == [] + + +async def _call(browser: FakeBrowser | AsyncFakeBrowser, name: str, input: dict[str, Any]) -> Any: + result: Any = browser.call(CTX, name, input) + return await result if inspect.isawaitable(result) else result + + +BOTH_CLASSES = pytest.mark.parametrize("flavour", [FakeBrowser, AsyncFakeBrowser], ids=["sync", "async"]) + + +@BOTH_CLASSES +async def test_a_key_the_schema_does_not_declare_reaches_the_driver_and_confirm( + flavour: type[FakeBrowser] | type[AsyncFakeBrowser], +) -> None: + asked: list[BetaConfirmContext] = [] + + def confirm(context: BetaConfirmContext) -> bool: + asked.append(context) + return True + + browser = flavour(confirm=confirm) + await _call(browser, "wait", {"duration": 1, "also_run": "rm -rf /", "url": "http://10.0.0.5/"}) + + received = browser.world.inputs[-1] + assert received.duration == 1 and received.also_run == "rm -rf /" and received.url == "http://10.0.0.5/" + assert received.to_dict()["also_run"] == "rm -rf /" + + (context,) = asked + assert context.input is received + + +@pytest.mark.skipif(not PYDANTIC_V1, reason="on pydantic v2 an undeclared key cannot replace a model attribute") +@BOTH_CLASSES +async def test_an_undeclared_key_named_after_a_model_attribute_is_dropped( + flavour: type[FakeBrowser] | type[AsyncFakeBrowser], +) -> None: + # On pydantic v1 an undeclared key becomes an instance attribute, so `to_dict` or `copy` sent by the model would + # replace the method on the parsed input. Those keys are dropped, and the declared field and any other key pass. + browser = flavour() + await _call(browser, "navigate", {"url": "https://example.com/", "to_dict": 1, "copy": 2, "note": "x"}) + + received = browser.world.inputs[-1] + assert received.url == "https://example.com/" and received.note == "x" + assert received.to_dict()["note"] == "x" and callable(received.copy) # both methods still work + + +@BOTH_CLASSES +async def test_a_tab_id_on_a_member_that_declares_none_names_no_tab( + flavour: type[FakeBrowser] | type[AsyncFakeBrowser], +) -> None: + asked: list[BetaConfirmContext] = [] + + def confirm(context: BetaConfirmContext) -> bool: + asked.append(context) + return True + + browser = flavour(confirm=confirm) + await _call(browser, "list_tabs", {}) # a report is on hand now + + for name in ("new_tab", "list_tabs"): + active = browser.world.active + await _call(browser, name, {"tab_id": "tab_gone"}) + + # the key still reaches the driver, but it is not a tab: confirm is shown the tab the call really targets + assert browser.world.inputs[-1].tab_id == "tab_gone" + assert asked[-1].tab_id == active + assert asked[-1].tab_url == browser.world.tabs[cast(str, active)]["url"] + + # a member that does declare `tab_id` names its tab, even one the report does not list + await _call(browser, "get_page_text", {"tab_id": "tab_gone"}) + assert asked[-1].tab_id == "tab_gone" and asked[-1].tab_url is None + + +@BOTH_CLASSES +async def test_a_url_or_paths_on_another_member_is_not_policy_checked( + flavour: type[FakeBrowser] | type[AsyncFakeBrowser], +) -> None: + checked: list[str] = [] + + def url_policy(_context: BetaURLContext, url: str) -> None: + checked.append(url) + raise ToolError("refused by the test's URL policy") + + class Recording(BetaLocalFilePolicy): + @override + def resolve_upload_paths(self, context: BetaURLContext, paths: Sequence[str]) -> list[str]: + checked.extend(paths) + raise ToolError("refused by the test's file policy") + + browser = flavour(url_policy=url_policy, file_policy=Recording()) + plain = await _call(browser, "wait", {"duration": 1}) + with_url = await _call(browser, "wait", {"duration": 1, "url": "http://10.0.0.5/"}) + await _call(browser, "new_tab", {"url": "http://10.0.0.5/"}) + await _call(browser, "left_click", {**CLICK, "paths": ["/etc/passwd"]}) + + # neither policy is asked, and the extra key does not change what the model reads + assert checked == [] and with_url == plain + + # each key reaches the driver + _, wait_url, new_tab, click = browser.world.inputs + assert wait_url.url == "http://10.0.0.5/" and new_tab.url == "http://10.0.0.5/" + assert click.paths == ["/etc/passwd"] + + +def test_an_enabled_the_types_do_not_allow_fails_closed_if_it_gets_past_them() -> None: + # {"enabled": "yes"} is the type checker's to catch; construction does not re-check it, and the dispatch gate + # reads anything but True or None as off, so it can never turn a member on. + browser = FakeBrowser(configs=cast(Any, {"screenshot": {"enabled": "yes"}})) + assert "not permitted" in refusal(browser, CTX, "screenshot", {}) + + FakeBrowser(configs={"screenshot": {"enabled": None}}) # None = the member's default + FakeBrowser(configs={"screenshot": {}}) + + # an entry that is not a mapping at all cannot be copied into the wire configs: construction raises + with pytest.raises(TypeError): + FakeBrowser(configs=cast(Any, {"screenshot": False})) + + +def test_enabling_an_unimplemented_member_is_a_configuration_error() -> None: + # The model would be offered a member that can only ever answer "not available". + with pytest.raises(ToolsetConfigError, match="does not implement.*javascript_exec"): + FakeBrowser(configs={"javascript_exec": {"enabled": True}}) + + +def test_an_execute_only_subclass_serves_every_member_and_turns_members_off_with_configs() -> None: + # A driver that forwards every call elsewhere overrides execute and no member method: it serves them all, and + # switches off what it does not serve through configs like any other member. + class Forwarder(BetaAbstractBrowserToolset20260801): + @override + def _browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: + return BetaBrowserState(tabs=[]) + + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return f"forwarded {name}" + + forwarder = Forwarder( + configs={"screenshot": {"enabled": False}, "javascript_exec": {"enabled": True}}, confirm=approve + ) + assert texts(forwarder.call(CTX, "get_page_text", {})) == ["forwarded get_page_text"] + assert texts(forwarder.call(CTX, "javascript_exec", {"text": "1"})) == ["forwarded javascript_exec"] + assert "not permitted" in refusal(cast(Any, forwarder), CTX, "screenshot", {}) + + +def test_enabling_javascript_exec_or_file_upload_requires_a_confirm_callable() -> None: + # file_upload is implemented by the fake; enabling it without a confirm callable to see its calls is refused up + # front. A confirm that approves everything is the explicit way to run it unattended; disabled, it needs none. + with pytest.raises(ToolsetConfigError, match="file_upload.*confirm callable"): + FakeBrowser(configs={"file_upload": {"enabled": True}}) + + FakeBrowser(configs={"file_upload": {"enabled": True}}, confirm=approve) + FakeBrowser() + + # the fake does not serve javascript_exec, and enabling a member the driver does not serve is a different error + class Scripted(FakeBrowser): + @override + def javascript_exec(self, context: BetaToolsetCallContext, input: BetaBrowserJavascriptExecInput) -> str: + return "" + + with pytest.raises(ToolsetConfigError, match="javascript_exec.*confirm callable"): + Scripted(configs={"javascript_exec": {"enabled": True}}) + Scripted(configs={"javascript_exec": {"enabled": True}}, confirm=approve) + + +def test_a_member_without_fields_takes_an_empty_object() -> None: + assert [b["type"] for b in FakeBrowser().call(CTX, "new_tab", {})] == ["browser_state"] + + +# --- errors out of the driver --------------------------------------------------------------------- + + +def test_driver_tool_error_is_the_error_text_and_any_other_exception_is_reported_too() -> None: + browser = FakeBrowser() + browser.world.fail["left_click"] = ToolError("element is covered") + assert refusal(browser, CTX, "left_click", {"target": {"type": "ref", "ref": "e1"}}) == "element is covered" + browser.world.fail["left_click"] = TypeError("driver bug") + assert refusal(browser, CTX, "left_click", {"target": {"type": "ref", "ref": "e1"}}) == "TypeError: driver bug" + + +def test_the_developer_error_class_propagates_out_of_call() -> None: + browser = FakeBrowser() + browser.world.fail["left_click"] = ToolsetContractError("misuse") + with pytest.raises(ToolsetContractError): + browser.call(CTX, "left_click", {"target": {"type": "ref", "ref": "e1"}}) + + +def test_a_browser_state_hook_that_raises_is_a_developer_error() -> None: + browser = FakeBrowser() + browser.world.state_error = RuntimeError("page crashed") + + with pytest.raises(ToolsetContractError, match="browser_state raised RuntimeError"): + browser.call(CTX, "get_page_text", {}) + + # a hook that returns nothing, or a plain dict, is the same developer error, never a result for the model + bad: list[Any] = [None, {"tabs": [], "state_changes": []}] + for returned in bad: + + class BadState(FakeBrowser): + @override + def _browser_state(self, context: BetaToolsetCallContext, returned: Any = returned) -> Any: + return returned + + with pytest.raises(ToolsetContractError, match="browser_state raised"): + BadState().tool_result(_use("get_page_text", {})) + + +MALFORMED_REPORTS: list[dict[str, Any]] = [ + # built unvalidated, as pydantic v1 or `model_construct` would let them through + {"tabs": [{"tab_id": "t", "url": "https://a.test/", "title": None, "active": True}]}, + {"tabs": [], "state_changes": ["download_started"]}, + {"tabs": [], "state_changes": [{"type": "tab_opened"}]}, +] + + +@pytest.mark.parametrize("fields", MALFORMED_REPORTS, ids=range(len(MALFORMED_REPORTS))) +def test_a_report_missing_a_field_the_sdk_reads_is_the_drivers_contract_error_not_the_models( + fields: dict[str, Any], +) -> None: + browser = FakeBrowser() + browser.world.state_override = unvalidated(BetaBrowserState, **{"state_changes": [], **fields}) + with pytest.raises(ToolsetContractError, match="browser_state"): + browser.call(CTX, "left_click", CLICK) + with pytest.raises(ToolsetContractError): + browser.tool_result(_use("left_click", CLICK)) + if fields.get("state_changes"): + # a failed call holds the state changes back for the next block, so it reads them too + browser.world.fail["left_click"] = ToolError("element gone") + with pytest.raises(ToolsetContractError, match="browser_state"): + browser.call(CTX, "left_click", CLICK) + + +async def test_a_malformed_report_is_a_contract_error_on_the_async_class_too() -> None: + for fields in MALFORMED_REPORTS: + browser = AsyncFakeBrowser() + browser.world.state_override = unvalidated(BetaBrowserState, **{"state_changes": [], **fields}) + with pytest.raises(ToolsetContractError, match="browser_state"): + await browser.call(CTX, "get_page_text", {}) + + if fields.get("state_changes"): + # a failed call holds the state changes back for the next block, so it reads them too + browser.world.fail["left_click"] = ToolError("element gone") + with pytest.raises(ToolsetContractError, match="browser_state"): + await browser.call(CTX, "left_click", CLICK) + + +def test_a_new_tab_result_that_is_not_a_tab_entry_still_renders_the_block() -> None: + for returned in (None, "tab_2"): + browser = FakeBrowser() + browser.world.results["new_tab"] = returned + browser.world.tabs["tab_2"] = {"title": "", "url": "about:blank"} + browser.world.changes = [{"type": "tab_opened", "tab_id": "tab_2"}] + (block,) = browser.call(CTX, "new_tab", {}) + assert block["type"] == "browser_state" + assert [c["tab_id"] for c in cast(Any, block)["state_changes"]] == ["tab_2"] + + +def test_tool_result_maps_the_pipeline_onto_a_result_block_and_passes_the_tool_use_along() -> None: + browser = FakeBrowser() + use = _use("get_page_text", {}) + + result = browser.tool_result(use) + assert result["content"][0] == {"type": "text", "text": "Hello"} # pyright: ignore[reportTypedDictNotRequiredAccess, reportIndexIssue] + assert "is_error" not in result + assert browser.world.contexts[-1].tool_use is use + assert browser.world.state_contexts[-1].tool_use is use + + failed = browser.tool_result(_use("teleport", {})) + assert failed.get("is_error") is True + assert failed["content"] == [{"type": "text", "text": "Error: unknown browser toolset member 'teleport'"}] # pyright: ignore[reportTypedDictNotRequiredAccess] + + +# --- state changes across failed calls ------------------------------------------------------------ + + +def test_changes_drained_by_a_failed_call_ride_the_next_block_that_reaches_the_model() -> None: + browser = FakeBrowser() + browser.world.changes = [ + {"type": "tab_opened", "tab_id": "tab_9"}, # its tab is not in the inventory by the next call + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a"}, + {"type": "download_started", "download_id": "dl_2", "url": "https://example.com/b"}, + ] + browser.world.fail["left_click"] = ToolError("nope") + + with pytest.raises(ToolError): + browser.call(CTX, "left_click", {"target": {"type": "ref", "ref": "e1"}}) + + # The failed call still consulted browser_state, so the driver's queue was drained. + assert browser.world.changes == [] + + browser.world.changes = [ + {"type": "download_completed", "download_id": "dl_2", "url": "https://example.com/b", "size_bytes": 1}, + ] + + block = state_block(browser.call(CTX, "get_page_text", {})) + assert block["state_changes"] == [ + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a"}, + # dl_2's held-back "started" is superseded by this report's "completed"; tab_9 is gone. + {"type": "download_completed", "download_id": "dl_2", "url": "https://example.com/b", "size_bytes": 1}, + ] + + assert "state_changes" not in state_block(browser.call(CTX, "get_page_text", {})) + + +def test_of_the_changes_held_for_one_download_the_newest_is_the_one_carried_forward() -> None: + # One failed call drains both the start and the completion of dl_1; a second failed call holds them + # again. The block that finally reaches the model carries the completion, not the stale start. + browser = FakeBrowser() + browser.world.changes = [ + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a"}, + {"type": "download_completed", "download_id": "dl_1", "url": "https://example.com/a", "size_bytes": 7}, + ] + browser.world.fail["left_click"] = ToolError("nope") + + for _ in range(2): + with pytest.raises(ToolError): + browser.call(CTX, "left_click", {"target": {"type": "ref", "ref": "e1"}}) + + browser.world.fail = {} + assert state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] == [ + {"type": "download_completed", "download_id": "dl_1", "url": "https://example.com/a", "size_bytes": 7}, + ] + + +def test_of_one_reports_changes_for_a_download_only_the_latest_reaches_the_model() -> None: + # A driver that reports every event need not deduplicate: the block carries one change per download, the latest, + # where that one stood. + browser = FakeBrowser() + browser.world.changes = [ + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a"}, + {"type": "download_started", "download_id": "dl_2", "url": "https://example.com/b"}, + {"type": "download_completed", "download_id": "dl_1", "url": "https://example.com/a", "size_bytes": 7}, + ] + assert state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] == [ + {"type": "download_started", "download_id": "dl_2", "url": "https://example.com/b"}, + {"type": "download_completed", "download_id": "dl_1", "url": "https://example.com/a", "size_bytes": 7}, + ] + + +def test_a_tab_opened_for_a_tab_the_report_does_not_list_is_dropped_and_a_repeated_one_sent_once() -> None: + browser = FakeBrowser() + browser.world.tabs["tab_2"] = {"title": "popup", "url": "https://example.com/popup"} + browser.world.changes = [ + {"type": "tab_opened", "tab_id": "tab_9"}, # opened and closed again within the call: not in `tabs` + {"type": "tab_opened", "tab_id": "tab_2"}, + {"type": "tab_opened", "tab_id": "tab_2"}, # reported twice + ] + + assert state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] == [ + {"type": "tab_opened", "tab_id": "tab_2"}, + ] + + browser.world.changes = [{"type": "tab_opened", "tab_id": "tab_8"}] + assert "state_changes" not in state_block(browser.call(CTX, "get_page_text", {})) + + +def test_a_held_change_and_a_replayed_one_give_way_to_the_newest_for_that_download() -> None: + # The start of dl_1 is drained by a failed call and held; a driver that replays what the model never saw reports + # it again next to the completion. One change for dl_1 reaches the model: the completion. + browser = FakeBrowser() + browser.call(CTX, "list_tabs", {}) + browser.world.changes = [{"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a"}] + + browser.world.fail["get_page_text"] = ToolError("page gone") + with pytest.raises(ToolError): + browser.call(CTX, "get_page_text", {}) # failed; the report is still read + del browser.world.fail["get_page_text"] + + browser.world.changes = [ + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a"}, + {"type": "download_completed", "download_id": "dl_1", "url": "https://example.com/a", "size_bytes": 7}, + ] + + assert state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] == [ + {"type": "download_completed", "download_id": "dl_1", "url": "https://example.com/a", "size_bytes": 7}, + ] + + +def test_a_new_tab_result_carries_only_its_own_tab_opened_and_holds_a_popups_for_the_next_block() -> None: + # A page opened a popup (tab_2) just before the model asked for a new tab (tab_3): the API accepts + # exactly one tab_opened on a new_tab result, so the popup's waits for the next block. + browser = FakeBrowser() + browser.world.tabs["tab_2"] = {"title": "popup", "url": "https://example.com/popup"} + browser.world.changes = [{"type": "tab_opened", "tab_id": "tab_2"}] + browser.world.next_tab = 3 + + block = state_block(browser.call(CTX, "new_tab", {})) + assert block["state_changes"] == [{"type": "tab_opened", "tab_id": "tab_3"}] + nxt = state_block(browser.call(CTX, "get_page_text", {})) + assert nxt["state_changes"] == [{"type": "tab_opened", "tab_id": "tab_2"}] + + # The same when the popup's tab_opened was held back from a failed call and merged into the new_tab block. + browser.world.tabs["tab_4"] = {"title": "popup 2", "url": "https://example.com/p2"} + browser.world.changes = [{"type": "tab_opened", "tab_id": "tab_4"}] + browser.world.fail["left_click"] = ToolError("nope") + + with pytest.raises(ToolError): + browser.call(CTX, "left_click", {"target": {"type": "ref", "ref": "e1"}}) + + browser.world.fail.clear() + browser.world.next_tab = 5 + block = state_block(browser.call(CTX, "new_tab", {})) + assert block["state_changes"] == [{"type": "tab_opened", "tab_id": "tab_5"}] + assert state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] == [ + {"type": "tab_opened", "tab_id": "tab_4"} + ] + + +NEW_TAB_NOT_ACTIVE = ( + "new_tab opened a tab, but the browser does not report it as the only active tab. Call list_tabs to see the tabs." +) + + +def test_a_new_tab_result_gets_a_tab_opened_when_the_driver_reported_none() -> None: + browser = FakeBrowser() + browser.world.results["new_tab"] = {"tab_id": "tab_2", "title": "", "url": "about:blank", "active": True} + browser.world.tabs["tab_2"] = {"title": "", "url": "about:blank"} + browser.world.active = "tab_2" + block = state_block(browser.call(CTX, "new_tab", {})) + assert block["state_changes"] == [{"type": "tab_opened", "tab_id": "tab_2"}] + + +def test_a_new_tab_whose_tab_is_not_the_active_one_fails_and_its_tab_opened_waits_for_the_next_block() -> None: + # the driver opened tab_2 in the background, or a popup took focus before the report + browser = FakeBrowser() + browser.world.results["new_tab"] = {"tab_id": "tab_2", "title": "", "url": "about:blank", "active": False} + browser.world.tabs["tab_2"] = {"title": "", "url": "about:blank"} + browser.world.changes = [{"type": "tab_opened", "tab_id": "tab_2"}] + assert refusal(browser, CTX, "new_tab", {}) == NEW_TAB_NOT_ACTIVE + assert state_block(browser.call(CTX, "get_page_text", {}))["state_changes"] == [ + {"type": "tab_opened", "tab_id": "tab_2"} + ] + + +async def test_an_async_new_tab_whose_tab_is_not_the_active_one_fails_too() -> None: + browser = AsyncFakeBrowser() + browser.world.results["new_tab"] = {"tab_id": "tab_2", "title": "", "url": "about:blank", "active": False} + browser.world.tabs["tab_2"] = {"title": "", "url": "about:blank"} + with pytest.raises(ToolError) as caught: + await browser.call(CTX, "new_tab", {}) + assert texts(caught.value.content) == [NEW_TAB_NOT_ACTIVE] + + +def test_navigate_and_screenshot_results_render_from_their_models() -> None: + browser = FakeBrowser() + + browser.world.results["navigate"] = BetaBrowserNavigateResult(url="https://example.com/a", title="A", status=200) + assert texts(browser.call(CTX, "navigate", {"url": "https://example.com"}))[0] == ( + "Navigated to https://example.com/a — A (HTTP 200)" + ) + + browser.world.results["screenshot"] = BetaScreenshotResult(data="QUJD") + image = cast(dict[str, Any], browser.call(CTX, "screenshot", {})[0]) + assert image["type"] == "image" and image["source"] == {"type": "base64", "media_type": "image/png", "data": "QUJD"} + + +@BOTH_CLASSES +@pytest.mark.parametrize(("tool", "given"), [("navigate", {"url": "https://example.com"}), ("screenshot", {})]) +async def test_a_bare_string_from_navigate_or_screenshot_reaches_the_model_as_an_error_naming_its_type( + flavour: type[FakeBrowser] | type[AsyncFakeBrowser], tool: str, given: dict[str, Any] +) -> None: + # a bare string is not a result model: the call fails, and the model reads an error that names what the driver + # returned, not one that blames the browser_state report + browser = flavour() + browser.world.results[tool] = "https://example.com/c" if tool == "navigate" else "QUJD" + failed: Any = browser.tool_result(_use(tool, given)) + if inspect.isawaitable(failed): + failed = await failed + assert failed.get("is_error") is True + assert failed.get("content") == f"ValueError('{tool} returned str, not its result model')" + + +def test_a_dismissed_dialog_is_a_line_of_text_with_its_message_capped() -> None: + # A driver reports a native dialog it dismissed as a BetaDialogDismissed change; the model reads one line per dialog + # with the next result that can carry text (a click's, an error's, or after a tab member the one after), the + # page-supplied message capped, and never in the browser_state block. + browser = FakeBrowser() + browser.world.changes = [BetaDialogDismissed(kind="confirm", message="Delete\neverything?")] + content = browser.call(CTX, "left_click", CLICK) + assert texts(content) == ["Clicked.", 'A confirm dialog "Delete everything?" was dismissed.'] + assert "state_changes" not in state_block(content) + + browser.world.changes = [BetaDialogDismissed(kind="alert", message="x" * 500), BetaDialogDismissed(kind="prompt")] + browser.world.fail["left_click"] = ToolError("element gone") + lines = texts(pytest.raises(ToolError, browser.call, CTX, "left_click", CLICK).value.content) + assert lines == ["element gone", f'An alert dialog "{"x" * 200}…" was dismissed.', "A prompt dialog was dismissed."] + browser.world.fail.clear() + + # a blank or control-only kind reads as none; a long one is capped at a word's length (it lands unquoted) + browser.world.changes = [BetaDialogDismissed(kind="\n"), BetaDialogDismissed(kind="y" * 50)] + assert texts(browser.call(CTX, "left_click", CLICK))[1:] == [ + "A dialog was dismissed.", + f"A {'y' * 20} dialog was dismissed.", + ] + + # A tab member cannot carry text, so the dialogs are held; the cap applies when the lines are finally written. + browser.world.changes = [BetaDialogDismissed(kind="confirm", message=str(i)) for i in range(2)] + assert [b["type"] for b in browser.call(CTX, "list_tabs", {})] == ["browser_state"] + browser.world.changes = [BetaDialogDismissed(kind="confirm", message=str(i)) for i in range(2, 5)] + assert texts(browser.call(CTX, "get_page_text", {}))[1:] == [ + 'A confirm dialog "0" was dismissed.', + 'A confirm dialog "1" was dismissed.', + 'A confirm dialog "2" was dismissed.', + "2 more dialogs were dismissed.", + ] + + browser.world.changes = [BetaDialogDismissed(kind="confirm", message="Löschen?")] + assert texts(browser.call(CTX, "get_page_text", {}))[1:] == ['A confirm dialog "Löschen?" was dismissed.'] + + +def _text_cases() -> list[tuple[str, str, str]]: + """`text_cases.json`, the table both SDKs' tests read for the text folds: `{c*N}` stands for N copies of the + character c, and where the SDKs differ on purpose a row gives `{"py", "ts"}`.""" + rows: list[dict[str, Any]] = json.loads((Path(__file__).parent / "text_cases.json").read_text()) + + def expand(text: str) -> str: + return re.sub(r"\{(.)\*(\d+)\}", lambda m: m.group(1) * int(m.group(2)), text) + + return [ + (r["fn"], expand(r["input"]), expand(r["shown"] if isinstance(r["shown"], str) else r["shown"]["py"])) + for r in rows + ] + + +TEXT_CASES = _text_cases() + + +@pytest.mark.parametrize("fn, raw, shown", TEXT_CASES, ids=range(len(TEXT_CASES))) +def test_page_and_model_text_is_folded_the_same_in_both_sdks(fn: str, raw: str, shown: str) -> None: + assert {"one_line": one_line, "quoted_name": quoted_name}[fn](raw) == shown + + +def test_a_reported_url_is_folded_to_one_line_and_cut_to_the_field_limit_and_nothing_else() -> None: + # A tab's or download's address is page-supplied: line breaks, controls and bidi characters are folded to a space, + # the ends trimmed and the whole held to 4,096 characters. Nothing else is parsed or re-encoded. + long_url = "https://evil.test/" + "(" * 5000 + assert bounded_tab_url(long_url) == "\u2026" + long_url[:4095] + fits = "https://x.test/" + "a" * (4096 - len("https://x.test/")) + assert bounded_tab_url(fits) == fits # exactly at the limit: untouched + assert bounded_tab_url(fits + "b") == "\u2026" + fits[:4095] + assert bounded_tab_url("https://exa\nmple.test/a b)\x0bc\u202e\u2028d\t") == "https://exa mple.test/a b) c d" + assert ( + bounded_tab_url("https://user:secret@example.com:8443/p?to=a@b#c") + == "https://user:secret@example.com:8443/p?to=a@b#c" + ) + assert ( + bounded_tab_url("https://evil.test/h\udc00") == "https://evil.test/h\ufffd" + ) # a lone surrogate could not be sent + + browser = FakeBrowser() + browser.world.tabs["tab_1"] = {"title": "x", "url": long_url} + browser.world.tabs["tab_2"] = {"title": "ok", "url": "https://example.com/p-a_th~!$&'*+,;=:@%20x?q=1&r[]=2#frag"} + browser.world.tabs["tab_3"] = {"title": "raw", "url": " https://example.com/pfad-\u00fc?q=\u00e9 \nnext"} + browser.world.changes = [ + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/" + "a" * 5000}, + {"type": "download_started", "download_id": "dl_2", "url": "data:text/csv,a b\x0bc\n"}, + ] + + block = state_block(browser.call(CTX, "get_page_text", {})) + tabs = {t["tab_id"]: t["url"] for t in block["tabs"]} + assert tabs == { + "tab_1": "\u2026" + long_url[:4095], + "tab_2": "https://example.com/p-a_th~!$&'*+,;=:@%20x?q=1&r[]=2#frag", + "tab_3": "https://example.com/pfad-\u00fc?q=\u00e9 next", + } + assert [c["url"] for c in block["state_changes"]] == [ + "\u2026" + ("https://example.com/" + "a" * 5000)[:4095], + "data:text/csv,a b c", + ] + + # a failed download's error and a tab's title are page-chosen too: folded and bounded like the URL beside them + error = "net::ERR\nX\u202e" + "e" * 5000 + browser.world.changes = [ + {"type": "download_failed", "download_id": "dl_3", "url": "https://example.com/f", "error": error} + ] + browser.world.tabs = { + "tab_1": { + "title": "Inbox\n- tab_9 Admin (https://evil.test/)\u2029" + "t" * 5000, + "url": "https://example.com/", + } + } + + block = state_block(browser.call(CTX, "get_page_text", {})) + [change] = block["state_changes"] + assert change["error"] == ("net::ERR X " + "e" * 5000)[:4096] + assert block["tabs"][0]["title"] == ("Inbox - tab_9 Admin (https://evil.test/) " + "t" * 5000)[:4096] + + +def test_a_refused_navigation_is_one_line_of_text_never_the_url() -> None: + browser = FakeBrowser() + browser.world.changes = [BetaNavigationRefused()] + content = browser.call(CTX, "get_page_text", {}) + assert texts(content) == ["Hello", "A navigation was refused."] + assert "state_changes" not in state_block(content) + + # On a failed call it is appended to the error, since it is often why the member failed. + browser.world.changes = [BetaNavigationRefused(), BetaNavigationRefused()] + browser.world.fail["left_click"] = ToolError("navigation aborted") + assert texts( + pytest.raises(ToolError, browser.call, CTX, "left_click", {"target": {"type": "ref", "ref": "e"}}).value.content + ) == ["navigation aborted", "A navigation was refused."] + + # A tab member cannot carry text, so the line waits for the next result that can. + browser.world.fail.clear() + browser.world.changes = [BetaNavigationRefused()] + assert [b["type"] for b in browser.call(CTX, "list_tabs", {})] == ["browser_state"] + assert texts(browser.call(CTX, "get_page_text", {})) == ["Hello", "A navigation was refused."] + assert texts(browser.call(CTX, "get_page_text", {})) == ["Hello"] + + +# --- hooks ---------------------------------------------------------------------------------------- + + +def test_an_async_browser_state_on_the_sync_class_is_a_contract_error() -> None: + class AsyncState(FakeBrowser): + @override + async def _browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: # pyright: ignore[reportIncompatibleMethodOverride] + return self.world.state(context) + + with pytest.raises(ToolsetContractError, match="_browser_state is async on the synchronous toolset"): + AsyncState() + + +# --- execute as the before/after hook ------------------------------------------------------------- + + +def test_an_execute_override_wraps_dispatch_and_the_pipeline_wraps_the_override() -> None: + seen: list[Any] = [] + + class Hooked(FakeBrowser): + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + seen.append((name, type(input).__name__)) + result = super().execute(context, name, input) + if name == "get_page_text": + return cast(str, result).upper() + return result + + browser = Hooked() + assert texts(browser.call(CTX, "get_page_text", {})) == ["HELLO"] + assert seen == [("get_page_text", "BetaBrowserGetPageTextInput")] + + # A call refused before dispatch never reaches the override. + with pytest.raises(ToolError): + browser.call(CTX, "teleport", {}) + assert len(seen) == 1 + + +def test_an_async_execute_override_on_the_sync_class_is_a_contract_error() -> None: + # reported when the toolset is built: never a coroutine rendered as a result, nor an is_error the model reads + class AsyncExecute(FakeBrowser): + @override + async def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: # pyright: ignore[reportIncompatibleMethodOverride] + return "never awaited" + + with pytest.raises(ToolsetContractError, match="execute is async on the synchronous toolset"): + AsyncExecute() + + +async def test_a_plain_execute_override_on_the_async_class_is_a_contract_error() -> None: + class SyncExecute(AsyncFakeBrowser): + @override + def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + return "plain" + + with pytest.raises(ToolsetContractError, match="execute is sync on the asynchronous toolset"): + SyncExecute() + + +# --- serialization -------------------------------------------------------------------------------- + + +def test_calls_run_one_at_a_time() -> None: + def max_overlap(browser: FakeBrowser) -> int: + inside = 0 + peak = 0 + gate = threading.Lock() + + class Slow(World): + @override + def get_page_text(self, context: BetaToolsetCallContext, input: Any) -> str: + nonlocal inside, peak + with gate: + inside += 1 + peak = max(peak, inside) + threading.Event().wait(0.05) + with gate: + inside -= 1 + return "t" + + browser.world.__class__ = Slow + threads = [threading.Thread(target=browser.call, args=(CTX, "get_page_text", {})) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + return peak + + assert max_overlap(FakeBrowser()) == 1 + + +def test_call_from_inside_a_member_is_a_contract_error_not_a_deadlock() -> None: + # call() is the tool runner's entry point; a member that composes other members calls their methods directly. + class Nested(FakeBrowser): + @override + def get_page_text(self, context: BetaToolsetCallContext, input: Any) -> str: + self.call(context, "list_tabs", {}) + return "unreachable" + + with pytest.raises(ToolsetContractError, match="from inside a member"): + Nested().call(CTX, "get_page_text", {}) + + +# --- async parity --------------------------------------------------------------------------------- + + +async def test_async_pipeline_parity() -> None: + browser = AsyncFakeBrowser() + content = await browser.call(CTX, "navigate", {"url": "https://example.com/"}) + assert texts(content) == ["Navigated to https://example.com/ — Title of https://example.com/ (HTTP 200)"] + assert state_block(content)["tabs"][0]["url"] == "https://example.com/" + + # the async class reads the report through the same bound + browser.world.tabs["tab_1"]["url"] = "https://u:p@example.com/\n" + "a" * 5000 + bounded = state_block(await browser.call(CTX, "get_page_text", {}))["tabs"][0]["url"] + assert bounded == "\u2026" + ("https://u:p@example.com/ " + "a" * 5000)[:4095] + + assert [b["type"] for b in await browser.call(CTX, "new_tab", {})] == ["browser_state"] + + with pytest.raises(ToolError, match="not available"): + await browser.call(CTX, "javascript_exec", {"text": "1"}) + + browser.world.fail["left_click"] = ValueError("bad") + result = await browser.tool_result(_use("left_click", {"target": {"type": "ref", "ref": "e"}})) + assert result.get("is_error") is True and result["content"] == [{"type": "text", "text": "ValueError: bad"}] # pyright: ignore[reportTypedDictNotRequiredAccess] + + browser.world.state_error = RuntimeError("gone") + with pytest.raises(ToolsetContractError): + await browser.call(CTX, "get_page_text", {}) + + bad: list[Any] = [None, {"tabs": [], "state_changes": []}] + for returned in bad: + + class BadState(AsyncFakeBrowser): + @override + async def _browser_state(self, context: BetaToolsetCallContext, returned: Any = returned) -> Any: + return returned + + with pytest.raises(ToolsetContractError, match="browser_state raised"): + await BadState().tool_result(_use("get_page_text", {})) + + +async def test_a_sync_member_or_browser_state_on_the_async_class_is_a_contract_error() -> None: + # The async class awaits its members and _browser_state; a plain def would run blocking browser I/O on the event + # loop, so it is refused as the wiring mistake it is, as an async member is on the sync class. + class SyncMember(AsyncFakeBrowser): + @override + def get_page_text(self, context: BetaToolsetCallContext, input: Any) -> str: # pyright: ignore[reportIncompatibleMethodOverride] + return "sync body" + + with pytest.raises(ToolsetContractError, match="'get_page_text' is sync on the asynchronous toolset"): + SyncMember() + + class SyncState(AsyncFakeBrowser): + @override + def _browser_state(self, context: BetaToolsetCallContext) -> BetaBrowserState: # pyright: ignore[reportIncompatibleMethodOverride] + return self.world.state(context) + + with pytest.raises(ToolsetContractError, match="_browser_state is sync on the asynchronous toolset"): + SyncState() + + +async def test_async_call_from_inside_a_member_is_a_contract_error_and_cancellation_releases_the_lock() -> None: + class Nested(AsyncFakeBrowser): + @override + async def get_page_text(self, context: BetaToolsetCallContext, input: Any) -> str: + await self.call(context, "list_tabs", {}) + return "unreachable" + + @override + async def wait(self, context: BetaToolsetCallContext, input: Any) -> None: + await anyio.sleep(10) + + browser = Nested() + with pytest.raises(ToolsetContractError, match="from inside a member"): + await browser.call(CTX, "get_page_text", {}) + + with anyio.move_on_after(0.05) as scope: + await browser.call(CTX, "wait", {"duration": 10}) + assert scope.cancelled_caught + # The lock was released by the cancelled call, so the next one runs. + with anyio.fail_after(1): + assert [b["type"] for b in await browser.call(CTX, "list_tabs", {})] == ["browser_state"] + + +def test_driver_values_are_validated_where_the_driver_builds_them_and_forwarded_as_built() -> None: + # BetaBrowserState and the result models are real pydantic models: a report that breaks their field types fails in + # the driver, where it is built. What a driver did build reaches the block as it is — keys the SDK does not know + # included — because the SDK's own copies (the judged state, the contexts) are made without re-validating. + with pytest.raises(pydantic.ValidationError): + BetaBrowserState(tabs=cast(Any, "tab_1")) + + browser = FakeBrowser() + tab: Any = {"tab_id": "tab_1", "title": "T", "url": "https://example.com/", "active": True, "surprise": ["x"]} + change: Any = { + "type": "download_completed", + "download_id": "d1", + "url": "https://example.com/f", + "filename": "f", + "sha256": "abc", + } + + browser.world.state_override = BetaBrowserState(tabs=[tab], state_changes=[change]) + block = state_block(browser.call(CTX, "get_page_text", {})) + assert block["tabs"] == [tab] and block["state_changes"] == [change] + + +def test_a_closed_toolset_answers_no_member_call() -> None: + browser = FakeBrowser() + browser.call(CTX, "get_page_text", {}) + browser.close() + with pytest.raises(ToolsetClosedError): + browser.call(CTX, "get_page_text", {}) + assert browser.world.calls == ["get_page_text"] + + +async def test_a_closed_async_toolset_answers_no_member_call() -> None: + browser = AsyncFakeBrowser() + await browser.close() + with pytest.raises(ToolsetClosedError): + await browser.call(CTX, "get_page_text", {}) + assert browser.world.calls == [] + + +def test_close_waits_for_accepted_calls_to_settle() -> None: + # close() marks the toolset closed at once (a call that arrives later is refused) and then waits for the calls + # already accepted — the one in flight and the one queued behind it — so an override that calls super().close() + # first tears the browser down with nothing still using it. + entered, release = threading.Event(), threading.Event() + events: list[str] = [] + + class Slow(FakeBrowser): + @override + def get_page_text(self, context: BetaToolsetCallContext, input: Any) -> str: + entered.set() + release.wait(5) + return "text" + + browser = Slow() + + first = threading.Thread(target=lambda: (browser.call(CTX, "get_page_text", {}), events.append("first done"))) + first.start() + assert entered.wait(5) + + queued = threading.Thread(target=lambda: (browser.call(CTX, "get_page_text", {}), events.append("queued done"))) + queued.start() + + closer = threading.Thread(target=lambda: (browser.close(), events.append("closed"))) + closer.start() + closer.join(0.2) + assert closer.is_alive() # still waiting: a call is in flight and another is queued + + with pytest.raises(ToolsetClosedError): + browser.call(CTX, "get_page_text", {}) # arrives after close(): refused, never queued + + release.set() + for thread in (first, queued, closer): + thread.join(5) + assert sorted(events[:2]) == ["first done", "queued done"] and events[2] == "closed" + + +def test_close_from_inside_a_member_does_not_wait_for_itself() -> None: + class ClosesItself(FakeBrowser): + @override + def get_page_text(self, context: BetaToolsetCallContext, input: Any) -> str: + self.close() + return "closing" + + browser = ClosesItself() + assert "closing" in texts(browser.call(CTX, "get_page_text", {}))[0] + with pytest.raises(ToolsetClosedError): + browser.call(CTX, "get_page_text", {}) + + +async def test_async_close_waits_for_accepted_calls_to_settle() -> None: + entered, release = anyio.Event(), anyio.Event() + events: list[str] = [] + + class Slow(AsyncFakeBrowser): + @override + async def get_page_text(self, context: BetaToolsetCallContext, input: Any) -> str: + entered.set() + await release.wait() + return "text" + + browser = Slow() + + async def member(label: str) -> None: + await browser.call(CTX, "get_page_text", {}) + events.append(label) + + async def closer() -> None: + await browser.close() + events.append("closed") + + async with anyio.create_task_group() as tasks: + tasks.start_soon(member, "first done") + await entered.wait() + + tasks.start_soon(member, "queued done") + await anyio.wait_all_tasks_blocked() + + tasks.start_soon(closer) + await anyio.wait_all_tasks_blocked() + assert "closed" not in events # still waiting + + with pytest.raises(ToolsetClosedError): + await browser.call(CTX, "get_page_text", {}) + + release.set() + + assert sorted(events[:2]) == ["first done", "queued done"] and events[2] == "closed" + + +async def test_async_close_from_inside_a_member_does_not_wait_for_itself() -> None: + class ClosesItself(AsyncFakeBrowser): + @override + async def get_page_text(self, context: BetaToolsetCallContext, input: Any) -> str: + await self.close() + return "closing" + + browser = ClosesItself() + assert "closing" in texts(await browser.call(CTX, "get_page_text", {}))[0] + with pytest.raises(ToolsetClosedError): + await browser.call(CTX, "get_page_text", {}) + + +# --- the same behaviours on the async class --------------------------------------------------------- + + +async def test_changes_drained_by_a_failed_call_ride_the_next_block_on_the_async_class() -> None: + browser = AsyncFakeBrowser() + browser.world.changes = [ + {"type": "tab_opened", "tab_id": "tab_9"}, # its tab is not in the inventory by the next call + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a"}, + {"type": "download_started", "download_id": "dl_2", "url": "https://example.com/b"}, + ] + browser.world.fail["left_click"] = ToolError("nope") + + with pytest.raises(ToolError): + await browser.call(CTX, "left_click", {"target": {"type": "ref", "ref": "e1"}}) + + # The failed call still consulted browser_state, so the driver's queue was drained. + assert browser.world.changes == [] + browser.world.changes = [ + {"type": "download_completed", "download_id": "dl_2", "url": "https://example.com/b", "size_bytes": 1}, + ] + + block = state_block(await browser.call(CTX, "get_page_text", {})) + assert block["state_changes"] == [ + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a"}, + # dl_2's held-back "started" is superseded by this report's "completed"; tab_9 is gone. + {"type": "download_completed", "download_id": "dl_2", "url": "https://example.com/b", "size_bytes": 1}, + ] + assert "state_changes" not in state_block(await browser.call(CTX, "get_page_text", {})) + + +async def test_an_execute_override_wraps_dispatch_on_the_async_class() -> None: + seen: list[Any] = [] + + class Hooked(AsyncFakeBrowser): + @override + async def execute(self, context: BetaToolsetCallContext, name: Any, input: Any) -> Any: + seen.append((name, type(input).__name__)) + result = await super().execute(context, name, input) + if name == "get_page_text": + return cast(str, result).upper() + return result + + browser = Hooked() + assert texts(await browser.call(CTX, "get_page_text", {})) == ["HELLO"] + assert seen == [("get_page_text", "BetaBrowserGetPageTextInput")] + + # A call refused before dispatch never reaches the override. + with pytest.raises(ToolError): + await browser.call(CTX, "teleport", {}) + assert len(seen) == 1 + + +async def test_async_calls_run_one_at_a_time() -> None: + inside = 0 + peak = 0 + + class Slow(AsyncFakeBrowser): + @override + async def get_page_text(self, context: BetaToolsetCallContext, input: Any) -> str: + nonlocal inside, peak + inside += 1 + peak = max(peak, inside) + await anyio.sleep(0.05) + inside -= 1 + return "t" + + browser = Slow() + + async def read_page() -> None: + await browser.call(CTX, "get_page_text", {}) + + async with anyio.create_task_group() as tasks: + for _ in range(4): + tasks.start_soon(read_page) + + assert peak == 1 + + +async def test_download_paths_stay_hidden_on_the_async_class_without_a_file_policy() -> None: + browser = AsyncFakeBrowser() + browser.world.changes = [ # pyright: ignore[reportAttributeAccessIssue] + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a.pdf", "path": "/tmp/a"}, + { + "type": "download_completed", + "download_id": "dl_2", + "url": "https://example.com/b.pdf", + "path": "/tmp/b", + "size_bytes": 3, + }, + {"type": "download_failed", "download_id": "dl_3", "url": "https://example.com/c.pdf", "path": "/tmp/c"}, + ] + + block = state_block(await browser.call(CTX, "get_page_text", {})) + # a path is dropped from whichever download change carries it, not only the completed one + assert block["state_changes"] == [ + {"type": "download_started", "download_id": "dl_1", "url": "https://example.com/a.pdf"}, + {"type": "download_completed", "download_id": "dl_2", "url": "https://example.com/b.pdf", "size_bytes": 3}, + {"type": "download_failed", "download_id": "dl_3", "url": "https://example.com/c.pdf"}, + ] + + # Nothing to report is an absent field, never an empty list. + assert "state_changes" not in state_block(await browser.call(CTX, "get_page_text", {})) + + +async def test_a_download_path_is_exposed_on_the_async_class_only_when_the_file_policy_answers_true() -> None: + change: BetaBrowserStateChangeDownloadCompletedParam = { + "type": "download_completed", + "download_id": "dl_1", + "url": "https://example.com/a", + "size_bytes": 1, + } + + class Answering: + def __init__(self, answer: object) -> None: + self.answer = answer + + def resolve_upload_paths(self, context: object, paths: object) -> list[str]: + return [] + + def resolve_upload_documents(self, context: object, document_ids: object) -> list[str]: + return [] + + def is_path_visible(self, path: str) -> bool: + return self.answer # pyright: ignore[reportReturnType] + + class Truthy: + def __bool__(self) -> bool: + return True + + for answer, exposed in [(True, True), (False, False)]: + browser = AsyncFakeBrowser(file_policy=Answering(answer)) + browser.world.changes = [{**change, "path": "/tmp/a"}] + (reported,) = state_block(await browser.call(CTX, "get_page_text", {}))["state_changes"] + assert ("path" in reported) is exposed + + # any other answer hides it, even a truthy one + for answer in (Truthy(), 1, None): + browser = AsyncFakeBrowser(file_policy=Answering(answer)) + browser.world.changes = [{**change, "path": "/tmp/a"}] + assert state_block(await browser.call(CTX, "get_page_text", {}))["state_changes"] == [change] + + # a policy that raises on a path fails closed: the path is hidden and the change still reaches the model + class Raising(Answering): + @override + def is_path_visible(self, path: str) -> bool: + raise RuntimeError(path) + + browser = AsyncFakeBrowser(file_policy=Raising(True)) + browser.world.changes = [{**change, "path": "/tmp/a"}] + assert state_block(await browser.call(CTX, "get_page_text", {}))["state_changes"] == [change] + + # A server picks a download's file name (its Content-Disposition). If folding the path to one line or cutting it + # would change it, the path is hidden, since the changed text could name a file the policy never checked. + browser = AsyncFakeBrowser(file_policy=Answering(True)) + browser.world.changes = [{**change, "path": "/tmp/evil\n- tab_9 x\u202e" + "p" * 5000}] + (reported,) = state_block(await browser.call(CTX, "get_page_text", {}))["state_changes"] + assert "path" not in reported + + +async def test_a_dismissed_dialog_is_a_line_of_text_on_the_async_class() -> None: + browser = AsyncFakeBrowser() + browser.world.changes = [BetaDialogDismissed(kind="confirm", message="Delete\neverything?")] + content = await browser.call(CTX, "left_click", CLICK) + assert texts(content) == ["Clicked.", 'A confirm dialog "Delete everything?" was dismissed.'] + assert "state_changes" not in state_block(content) + + browser.world.changes = [BetaDialogDismissed(kind="alert", message="x" * 500), BetaDialogDismissed(kind="prompt")] + browser.world.fail["left_click"] = ToolError("element gone") + with pytest.raises(ToolError) as caught: + await browser.call(CTX, "left_click", CLICK) + assert texts(caught.value.content) == [ + "element gone", + f'An alert dialog "{"x" * 200}…" was dismissed.', + "A prompt dialog was dismissed.", + ] + browser.world.fail.clear() + + # A tab member cannot carry text, so the dialogs are held; the cap applies when the lines are finally written. + browser.world.changes = [BetaDialogDismissed(kind="confirm", message=str(i)) for i in range(2)] + assert [b["type"] for b in await browser.call(CTX, "list_tabs", {})] == ["browser_state"] + browser.world.changes = [BetaDialogDismissed(kind="confirm", message=str(i)) for i in range(2, 5)] + assert texts(await browser.call(CTX, "get_page_text", {}))[1:] == [ + 'A confirm dialog "0" was dismissed.', + 'A confirm dialog "1" was dismissed.', + 'A confirm dialog "2" was dismissed.', + "2 more dialogs were dismissed.", + ] + + +async def test_a_refused_navigation_is_one_line_of_text_on_the_async_class() -> None: + browser = AsyncFakeBrowser() + browser.world.changes = [BetaNavigationRefused()] + content = await browser.call(CTX, "get_page_text", {}) + assert texts(content) == ["Hello", "A navigation was refused."] + assert "state_changes" not in state_block(content) + + # On a failed call it is appended to the error, since it is often why the member failed. + browser.world.changes = [BetaNavigationRefused(), BetaNavigationRefused()] + browser.world.fail["left_click"] = ToolError("navigation aborted") + with pytest.raises(ToolError) as caught: + await browser.call(CTX, "left_click", {"target": {"type": "ref", "ref": "e"}}) + assert texts(caught.value.content) == ["navigation aborted", "A navigation was refused."] + + # A tab member cannot carry text, so the line waits for the next result that can. + browser.world.fail.clear() + browser.world.changes = [BetaNavigationRefused()] + assert [b["type"] for b in await browser.call(CTX, "list_tabs", {})] == ["browser_state"] + assert texts(await browser.call(CTX, "get_page_text", {})) == ["Hello", "A navigation was refused."] + assert texts(await browser.call(CTX, "get_page_text", {})) == ["Hello"] + + +async def test_driver_values_reach_the_block_as_built_on_the_async_class() -> None: + browser = AsyncFakeBrowser() + tab: Any = {"tab_id": "tab_1", "title": "T", "url": "https://example.com/", "active": True, "surprise": ["x"]} + change: Any = { + "type": "download_completed", + "download_id": "d1", + "url": "https://example.com/f", + "filename": "f", + "sha256": "abc", + } + + browser.world.state_override = BetaBrowserState(tabs=[tab], state_changes=[change]) + block = state_block(await browser.call(CTX, "get_page_text", {})) + assert block["tabs"] == [tab] and block["state_changes"] == [change] diff --git a/tests/lib/tools/toolsets/test_refusals.py b/tests/lib/tools/toolsets/test_refusals.py new file mode 100644 index 000000000..a3115ba48 --- /dev/null +++ b/tests/lib/tools/toolsets/test_refusals.py @@ -0,0 +1,258 @@ +# ruff: noqa: ARG001 -- stub hooks ignore their arguments +"""The SDK's own refusals are `ToolError` subclasses: each site raises its class, the model reads the same text as +ever, and the `ToolError` that `call` raises names the refusal as its cause.""" + +from __future__ import annotations + +from typing import Any, cast +from pathlib import Path +from typing_extensions import override + +import pytest + +from anthropic.tools import ( + ToolError, + ConfirmFailedError, + UnknownMemberError, + DisabledMemberError, + ConfirmDeclinedError, + UnavailableMemberError, + InvalidMemberInputError, +) +from anthropic.tools.browser import ( + BetaURLContext, + URLRefusedError, + BetaConfirmContext, + UploadRefusedError, + BetaLocalFilePolicy, + BetaToolsetCallContext, + beta_check_upload_path, +) + +from ._fakes import CLICK, UPLOAD, FakeBrowser, AsyncFakeBrowser, texts, approve + +CTX = BetaToolsetCallContext() + + +async def _async_refusal(browser: AsyncFakeBrowser, name: str, input: Any) -> ToolError: + """The same as `_refusal`, for a call on the async class.""" + with pytest.raises(ToolError) as caught: + await browser.call(CTX, name, input) + cause = caught.value.__cause__ + assert isinstance(cause, ToolError) + assert texts(caught.value.content)[0] == str(cause) + return cause + + +def _refusal(browser: FakeBrowser, name: str, input: Any) -> ToolError: + """The refusal behind the `ToolError` a call raises: its cause, whose own text is what the model reads.""" + with pytest.raises(ToolError) as caught: + browser.call(CTX, name, input) + cause = caught.value.__cause__ + assert isinstance(cause, ToolError) + assert texts(caught.value.content)[0] == str(cause) + return cause + + +def test_member_resolution_and_input_refusals() -> None: + browser = FakeBrowser(configs={"scroll": {"enabled": False}}) + + unknown = _refusal(browser, "teleport", {}) + assert isinstance(unknown, UnknownMemberError) + assert str(unknown) == "Error: unknown browser toolset member 'teleport'" + + disabled = _refusal(browser, "scroll", {"scroll_direction": "down", **CLICK}) + assert isinstance(disabled, DisabledMemberError) + assert str(disabled) == ( + "The 'scroll' action is not permitted by this application's permissions and cannot be used in this session." + ) + + unavailable = _refusal(browser, "hover", CLICK) # the fake does not implement hover + assert isinstance(unavailable, UnavailableMemberError) + assert str(unavailable) == "The browser toolset member 'hover' is not available in this environment." + + invalid = _refusal(browser, "left_click", {}) + assert isinstance(invalid, InvalidMemberInputError) + assert str(invalid).startswith("invalid input for browser member 'left_click': target: ") + assert str(InvalidMemberInputError("wait", "duration: too long", family="browser")) == ( + "invalid input for browser member 'wait': duration: too long" + ) + # family has no default, so none of the three can fall back to the browser's wording + for refusal, args in ( + (UnknownMemberError, ("x",)), + (UnavailableMemberError, ("x",)), + (InvalidMemberInputError, ("x", "y")), + ): + with pytest.raises(TypeError): + cast(Any, refusal)(*args) + + assert browser.world.calls == [] + + +def test_url_refusals() -> None: + def no_logout(context: BetaURLContext, url: str) -> None: + if "logout" in url: + raise ToolError("blocked: logout") + + browser = FakeBrowser(url_policy=no_logout) + own = _refusal(browser, "navigate", {"url": "https://example.com/logout"}) + assert type(own) is ToolError and str(own) == "blocked: logout" # the policy's own ToolError, as raised + + # a custom policy that raised something other than a ToolError refused too, with the SDK's text + def broken(context: BetaURLContext, url: str) -> None: + raise RuntimeError(url) + + fallback = _refusal(FakeBrowser(url_policy=broken), "navigate", {"url": "https://example.com/"}) + assert isinstance(fallback, URLRefusedError) and str(fallback) == "refused by the URL policy" + + # the reason is one bounded line + assert str(URLRefusedError("blocked: " + "x" * 500)) == ("blocked: " + "x" * 500)[:200] + + assert browser.world.calls == [] + + +def test_confirm_refusals() -> None: + def deny(context: BetaConfirmContext) -> bool: + return False + + declined = _refusal(FakeBrowser(confirm=deny), "left_click", CLICK) + assert isinstance(declined, ConfirmDeclinedError) + assert str(declined) == ( + "The user did not grant permission to run 'left_click'. Do not retry it unless the user asks you to." + ) + + def broken(context: BetaConfirmContext) -> bool: + raise RuntimeError("tty closed") + + failed = _refusal(FakeBrowser(confirm=broken), "left_click", CLICK) + assert isinstance(failed, ConfirmFailedError) + assert str(failed) == ( + "Permission to run 'left_click' could not be obtained (the confirmation prompt failed). " + "Do not retry it unless the user asks you to." + ) + + +def test_a_hook_refusal_is_cut_to_the_field_limit_and_stays_the_cause() -> None: + def echo(context: BetaURLContext, url: str) -> None: + raise ToolError(f"blocked: {url}") + + def refuse(context: BetaConfirmContext) -> bool: + raise ToolError("declined: " + "c" * 5000) + + url = "https://example.com/" + "a" * 5000 + cases: list[tuple[FakeBrowser, str, Any, str]] = [ + (FakeBrowser(url_policy=echo), "navigate", {"url": url}, f"blocked: {url}"), + (FakeBrowser(confirm=refuse), "left_click", CLICK, "declined: " + "c" * 5000), + ] + for browser, name, input, text in cases: + with pytest.raises(ToolError) as caught: + browser.call(CTX, name, input) + assert texts(caught.value.content)[0] == text[:4096] + assert str(caught.value.__cause__) == text # the refusal as raised, uncut + + +def test_upload_refusals(tmp_path: Path) -> None: + root = tmp_path / "uploads" + root.mkdir() + (root / "form.pdf").write_text("x") + enable: Any = {"configs": {"file_upload": {"enabled": True}}, "confirm": approve} + bare = FakeBrowser(**enable) + + no_roots = _refusal(bare, "file_upload", {**UPLOAD, "paths": [str(root / "form.pdf")]}) + assert isinstance(no_roots, UploadRefusedError) and str(no_roots) == "file_upload has no configured upload roots" + + no_documents = _refusal(bare, "file_upload", {**UPLOAD, "document_ids": ["doc_1"]}) + assert isinstance(no_documents, UploadRefusedError) + + policy = BetaLocalFilePolicy(upload_roots=[str(root)], upload_document_ids=["doc_1"]) + browser = FakeBrowser(file_policy=policy, **enable) + + outside = _refusal(browser, "file_upload", {**UPLOAD, "paths": [str(tmp_path / "secret")]}) + assert isinstance(outside, UploadRefusedError) + assert str(outside) == "file_upload path is outside the configured upload roots" + + unlisted = _refusal(browser, "file_upload", {**UPLOAD, "document_ids": ["doc_2"]}) + assert isinstance(unlisted, UploadRefusedError) and str(unlisted) == "document not in the upload allowlist" + + class Crashing(BetaLocalFilePolicy): + @override + def resolve_upload_paths(self, context: Any, paths: Any) -> list[str]: + raise RuntimeError(paths) + + crashed = _refusal(FakeBrowser(file_policy=Crashing(), **enable), "file_upload", {**UPLOAD, "paths": ["/f"]}) + assert isinstance(crashed, UploadRefusedError) + assert str(crashed) == "the file policy could not vet the upload" + + with pytest.raises(UploadRefusedError, match="outside the configured upload roots"): + beta_check_upload_path(str(tmp_path / "secret"), [str(root)]) + + assert bare.world.calls == [] and browser.world.calls == [] + + +async def test_the_async_class_names_its_refusals_the_same_way() -> None: + def broken(context: BetaURLContext, url: str) -> None: + raise RuntimeError(url) + + browser = AsyncFakeBrowser(url_policy=broken) + + with pytest.raises(ToolError) as caught: + await browser.call(CTX, "navigate", {"url": "http://10.0.0.5/"}) + assert isinstance(caught.value.__cause__, URLRefusedError) + + def echo(context: BetaURLContext, url: str) -> None: + raise ToolError(f"blocked: {url}") + + url = "https://example.com/" + "a" * 5000 + with pytest.raises(ToolError) as cut: + await AsyncFakeBrowser(url_policy=echo).call(CTX, "navigate", {"url": url}) + assert texts(cut.value.content)[0] == f"blocked: {url}"[:4096] and str(cut.value.__cause__) == f"blocked: {url}" + + +async def test_the_async_class_names_its_confirm_refusals_the_same_way() -> None: + async def deny(context: BetaConfirmContext) -> bool: + return False + + declined = await _async_refusal(AsyncFakeBrowser(confirm=deny), "left_click", CLICK) + assert isinstance(declined, ConfirmDeclinedError) + assert ( + str(declined) + == "The user did not grant permission to run 'left_click'. Do not retry it unless the user asks you to." + ) + + async def broken(context: BetaConfirmContext) -> bool: + raise RuntimeError("tty closed") + + failed = await _async_refusal(AsyncFakeBrowser(confirm=broken), "left_click", CLICK) + assert isinstance(failed, ConfirmFailedError) + assert str(failed) == ( + "Permission to run 'left_click' could not be obtained (the confirmation prompt failed). " + "Do not retry it unless the user asks you to." + ) + + +async def test_the_async_class_names_its_upload_refusals_the_same_way(tmp_path: Path) -> None: + enable: Any = {"configs": {"file_upload": {"enabled": True}}, "confirm": approve} + bare = AsyncFakeBrowser(**enable) + no_roots = await _async_refusal(bare, "file_upload", {**UPLOAD, "paths": [str(tmp_path / "form.pdf")]}) + assert isinstance(no_roots, UploadRefusedError) and str(no_roots) == "file_upload has no configured upload roots" + + class Crashing(BetaLocalFilePolicy): + @override + def resolve_upload_paths(self, context: Any, paths: Any) -> list[str]: + raise RuntimeError(paths) + + crashed = await _async_refusal( + AsyncFakeBrowser(file_policy=Crashing(), **enable), "file_upload", {**UPLOAD, "paths": ["/f"]} + ) + assert isinstance(crashed, UploadRefusedError) + assert str(crashed) == "the file policy could not vet the upload" + + policy = BetaLocalFilePolicy(upload_roots=[str(tmp_path / "uploads")], upload_document_ids=["doc_1"]) + browser = AsyncFakeBrowser(file_policy=policy, **enable) + outside = await _async_refusal(browser, "file_upload", {**UPLOAD, "paths": [str(tmp_path / "secret")]}) + assert isinstance(outside, UploadRefusedError) + assert str(outside) == "file_upload path is outside the configured upload roots" + + unlisted = await _async_refusal(browser, "file_upload", {**UPLOAD, "document_ids": ["doc_2"]}) + assert isinstance(unlisted, UploadRefusedError) and str(unlisted) == "document not in the upload allowlist" + assert bare.world.calls == [] and browser.world.calls == [] diff --git a/tests/lib/tools/toolsets/test_registry.py b/tests/lib/tools/toolsets/test_registry.py new file mode 100644 index 000000000..55d728b35 --- /dev/null +++ b/tests/lib/tools/toolsets/test_registry.py @@ -0,0 +1,127 @@ +"""The member registry against the published toolset definition. + +`BROWSER_TOOLSET_MEMBERS` is the toolset's member table, generated from the API's definition. The +registry derives names and inputs from the generated types. It keeps result kinds, templates and +default-disabled flags by hand. These tests fail when either side moves without the other. +""" + +from __future__ import annotations + +import re +from typing import Any, cast +from typing_extensions import get_args, get_type_hints + +from anthropic._data import BROWSER_TOOLSET_MEMBERS +from anthropic.types.beta import ( + BetaBrowserMemberName, + BetaBrowserMemberInput, + BetaBrowserNewTabInput, + BetaBrowserListTabsInput, + BetaBrowserNavigateInput, + BetaBrowserStateChangeParam, + BetaBrowserToolsetConfigsParam, +) +from anthropic.lib.tools._toolsets._inputs import BROWSER_INPUT_TYPES, BROWSER_MEMBER_NAMES +from anthropic.lib.tools._toolsets._render import DOWNLOAD_TYPES, TEMPLATE_FIELDS +from anthropic.lib.tools._toolsets._registry import ( + ROWS, + BROWSER_MEMBERS, + CONFIRM_REQUIRED, + STATE_ONLY_MEMBERS, + BROWSER_DEFAULT_DISABLED_MEMBERS, + member_enabled, +) + + +def test_member_set_matches_the_published_toolset() -> None: + assert set(BROWSER_MEMBER_NAMES) == set(BROWSER_TOOLSET_MEMBERS) + assert set(BROWSER_MEMBERS) == set(BROWSER_TOOLSET_MEMBERS) + assert len(BROWSER_MEMBER_NAMES) == len(set(BROWSER_MEMBER_NAMES)) == 31 + + +def test_default_disabled_members_match_the_published_toolset() -> None: + assert BROWSER_DEFAULT_DISABLED_MEMBERS == frozenset( + name for name, member in BROWSER_TOOLSET_MEMBERS.items() if not member["enabled_by_default"] + ) + + +def test_result_kinds_confirmations_and_default_flags_match_the_published_toolset() -> None: + generated = { + name: (member["result"], member["confirmation"], member["enabled_by_default"]) + for name, member in BROWSER_TOOLSET_MEMBERS.items() + } + assert ROWS == generated, ( + "ROWS in lib/tools/_toolsets/_registry.py has drifted from the generated BROWSER_TOOLSET_MEMBERS: " + "update ROWS to match (result kind, confirmation text, enabled by default)" + ) + + +def test_members_that_need_a_confirm_match_the_published_toolset() -> None: + generated = frozenset(name for name, member in BROWSER_TOOLSET_MEMBERS.items() if member["confirm_required"]) + assert CONFIRM_REQUIRED == generated, ( + "CONFIRM_REQUIRED in lib/tools/_toolsets/_registry.py has drifted from the generated BROWSER_TOOLSET_MEMBERS: " + "update it to the members marked confirm_required, because a member missing from it runs without a `confirm`" + ) + + +def test_member_set_matches_the_generated_configs_param() -> None: + # The wire configs object has one key per member; a member the SDK does not know could not be + # configured, and one the API does not know would be rejected. + assert set(BetaBrowserToolsetConfigsParam.__annotations__) == set(BROWSER_MEMBER_NAMES) + + +def test_static_aliases_agree_with_the_derived_tables() -> None: + assert set(get_args(BetaBrowserMemberName)) == set(BROWSER_MEMBER_NAMES) + assert set(cast("tuple[type, ...]", get_args(BetaBrowserMemberInput))) == set(BROWSER_INPUT_TYPES.values()) + assert BROWSER_INPUT_TYPES["navigate"] is BetaBrowserNavigateInput + # The two property-less members have no generated model; the SDK's stand-ins fill the slots. + assert BROWSER_INPUT_TYPES["new_tab"] is BetaBrowserNewTabInput + assert BROWSER_INPUT_TYPES["list_tabs"] is BetaBrowserListTabsInput + + +def test_every_pure_action_has_confirmation_text_and_tab_members_render_state_only() -> None: + for name, member in BROWSER_MEMBERS.items(): + assert member.input is BROWSER_INPUT_TYPES[name] + if name in STATE_ONLY_MEMBERS: + # The API produces the text for these from the browser_state block; the SDK adds none. + assert member.text is None + assert member.result in ("tab", "tabs", "none") + elif member.result == "none": + assert member.text, f"{name} is a pure action with nothing for the model to read" + + +def test_derived_member_sets() -> None: + # The tab members render no content of their own. + assert STATE_ONLY_MEMBERS == {"new_tab", "list_tabs", "switch_tab", "close_tab"} + + +def test_member_enabled_fails_closed() -> None: + assert member_enabled("navigate", None) is True + assert member_enabled("javascript_exec", None) is False + assert member_enabled("javascript_exec", {"javascript_exec": {"enabled": True}}) is True + assert member_enabled("navigate", {"navigate": {"enabled": False}}) is False + + # Anything but a real boolean turns the member off rather than on. + assert member_enabled("javascript_exec", {"javascript_exec": {"enabled": "yes"}}) is False + assert member_enabled("navigate", {"navigate": {"enabled": 1}}) is False + assert member_enabled("navigate", {"navigate": {"defer_loading": True}}) is True + + # An entry that is neither a mapping nor None reads as off too, so `{"navigate": False}` cannot leave navigate on. + assert member_enabled("navigate", cast(Any, {"navigate": False})) is False + assert member_enabled("navigate", cast(Any, {"navigate": "off"})) is False + assert member_enabled("navigate", {"navigate": None}) is True # None is the wire type's "defaults" + + +def test_every_confirmation_placeholder_has_a_template_field() -> None: + # A `{name}` in a member's confirmation text that `_render` does not know would reach the model as written. + placeholders = { + name for member in BROWSER_MEMBERS.values() if member.text for name in re.findall(r"{(\w+)}", member.text) + } + assert placeholders == set(TEMPLATE_FIELDS) + + +def test_every_state_change_kind_is_either_tab_opened_or_a_download_kind() -> None: + """A new change kind with a `url` in the generated union must be added to DOWNLOAD_TYPES, or the SDK neither + bounds its `url` nor checks its `path` and `error` against the file policy.""" + kinds = {get_args(get_type_hints(variant)["type"])[0] for variant in get_args(BetaBrowserStateChangeParam)} + assert kinds == {"tab_opened", *DOWNLOAD_TYPES} diff --git a/tests/lib/tools/toolsets/test_runnable.py b/tests/lib/tools/toolsets/test_runnable.py new file mode 100644 index 000000000..41be5d7e1 --- /dev/null +++ b/tests/lib/tools/toolsets/test_runnable.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import logging +from typing import Any, cast +from typing_extensions import override + +import pytest + +from anthropic.lib import tools +from anthropic._compat import PYDANTIC_V1 +from anthropic.lib.tools import ToolError, BetaFunctionTool, BetaAsyncFunctionTool, beta_tool, beta_async_tool +from anthropic.types.beta import BetaToolUseBlock +from anthropic.lib.tools._toolsets import ToolsetContractError +from anthropic.lib.tools._tool_dispatch import index_runnables, tool_error_content +from anthropic.lib.tools._toolsets._runnable import ( + BetaToolsetContent, + BetaRunnableToolset, + BetaToolsetCallContext, + BetaAsyncRunnableToolset, +) +from anthropic.lib.tools._toolsets._sanitize import FIELD_MAX, error_text + +from ._stubs import EchoBrowserToolset, AsyncEchoBrowserToolset + + +def _member_use( + name: str, input: dict[str, Any] | None = None, *, toolset_name: str | None = "browser" +) -> BetaToolUseBlock: + return BetaToolUseBlock( + type="tool_use", id=f"toolu_{name}", name=name, input=input or {}, toolset_name=toolset_name + ) + + +pytestmark = pytest.mark.skipif(PYDANTIC_V1, reason="tool functions not supported with pydantic v1") + + +@pytest.fixture +def navigate() -> BetaFunctionTool[Any]: + # Built inside the fixture: `beta_tool` raises at decoration time under pydantic v1. + @beta_tool + def navigate(url: str) -> str: + """A custom tool that shares a browser member's name.""" + return f"custom:{url}" + + return navigate + + +@pytest.fixture +def async_navigate() -> BetaAsyncFunctionTool[Any]: + @beta_async_tool + async def async_navigate(url: str) -> str: + """Async custom tool that shares a browser member's name.""" + return f"custom:{url}" + + return async_navigate + + +def test_tools_are_indexed_by_name_and_toolsets_by_family(navigate: BetaFunctionTool[Any]) -> None: + toolset = EchoBrowserToolset() + by_name, by_family = index_runnables([toolset, navigate]) + assert list(by_name) == ["navigate"] and by_name["navigate"] is navigate + assert by_family == {"browser": toolset} + assert index_runnables([navigate]) == ({"navigate": navigate}, {}) + + +def test_tool_result_echoes_toolset_name_and_passes_the_tool_use_as_context() -> None: + toolset = EchoBrowserToolset() + use = _member_use("navigate", {"url": "https://example.com"}) + + result = toolset.tool_result(use) + assert result == { + "type": "tool_result", + "tool_use_id": "toolu_navigate", + "toolset_name": "browser", + "content": 'navigate:{"url": "https://example.com"}', + } + assert toolset.calls == [("navigate", {"url": "https://example.com"})] + + # The member learns which model call it is answering through the context, not instance state. + assert toolset.contexts[0].tool_use is use + + +def test_an_unexpected_exception_is_logged_with_folded_names_and_answered_with_bounded_text( + caplog: pytest.LogCaptureFixture, +) -> None: + # The member name is model output and the exception text may carry page content: the operator's log line gets + # the names with control characters folded, and the model gets at most FIELD_MAX characters. + class Failing(EchoBrowserToolset): + @override + def call(self, context: BetaToolsetCallContext, name: str, input: object) -> BetaToolsetContent: + raise RuntimeError("x" * 10_000) + + use = BetaToolUseBlock(type="tool_use", id="toolu_f", name="scr\neenshot\x1b[2J", input={}, toolset_name="browser") + + with caplog.at_level(logging.ERROR): + result = Failing().tool_result(use) + + assert result.get("is_error") is True + content = result.get("content") + text = content if isinstance(content, str) else cast(Any, content)[0]["text"] + assert len(text) == FIELD_MAX and text.startswith("RuntimeError('xxx") + (record,) = [r for r in caplog.records if "toolset member" in r.getMessage()] + assert "\n" not in record.getMessage() and "\x1b" not in record.getMessage() + + +def test_tool_result_maps_tool_error_to_is_error_text_content() -> None: + # The API rejects anything but text on an is_error result, so the image the member attached to + # its ToolError is dropped rather than ending the loop. + result = EchoBrowserToolset().tool_result(_member_use("boom")) + assert result == { + "type": "tool_result", + "tool_use_id": "toolu_boom", + "toolset_name": "browser", + "content": [{"type": "text", "text": "member refused"}], + "is_error": True, + } + + +def test_a_lone_surrogate_in_error_text_becomes_the_replacement_character() -> None: + # page text a member quotes in its refusal may carry one; it could not be sent + class RefusesWithText(EchoBrowserToolset): + @override + def call(self, context: BetaToolsetCallContext, name: str, input: object) -> BetaToolsetContent: + raise ToolError("bad\ud800") + + class RefusesWithBlocks(EchoBrowserToolset): + @override + def call(self, context: BetaToolsetCallContext, name: str, input: object) -> BetaToolsetContent: + raise ToolError([{"type": "text", "text": "bad\udc00"}]) + + assert RefusesWithText().tool_result(_member_use("x"))["content"] == "bad\ufffd" # pyright: ignore[reportTypedDictNotRequiredAccess] + assert RefusesWithBlocks().tool_result(_member_use("x"))["content"] == [{"type": "text", "text": "bad\ufffd"}] # pyright: ignore[reportTypedDictNotRequiredAccess] + + +def test_an_empty_text_block_is_dropped_from_error_content_so_the_placeholder_applies() -> None: + # an is_error result may carry neither non-text nor empty content (the API answers either with a 400) + result = EchoBrowserToolset().tool_result(_member_use("blank")) + assert result.get("is_error") is True + assert result.get("content") == "The tool call failed with an empty error message." + + +@pytest.mark.parametrize( + ("exc", "text"), + [ + (TypeError("boom"), "TypeError('boom')"), + (RuntimeError("two\nlines"), "RuntimeError('two\\nlines')"), + (ValueError(), "ValueError()"), # no message: the type name is still there + (KeyError("k"), "KeyError('k')"), + ], +) +def test_a_raised_exception_reads_the_same_on_the_function_tool_and_toolset_paths(exc: Exception, text: str) -> None: + assert tool_error_content(exc) == text + assert error_text(exc) == text + + +def test_the_toolset_path_stays_encodable_and_cuts_to_the_field_limit() -> None: + assert error_text(RuntimeError("a\ud800b")) == "RuntimeError('a\\ud800b')" # `repr` escapes a lone surrogate + assert error_text(RuntimeError("x" * 10_000)) == ("RuntimeError('" + "x" * 10_000)[:FIELD_MAX] + + +def test_tool_result_reports_any_other_exception_to_the_model() -> None: + # A driver's own failure is something the model can react to, not a reason to stop the run. + result = EchoBrowserToolset().tool_result(_member_use("crash")) + assert result.get("is_error") is True + assert result.get("content") == "RuntimeError('backend exploded')" + + +def test_tool_result_propagates_the_developer_error_class_only() -> None: + # ToolsetUsageError is the one thing that stops the run; a TypeError from a driver is not. + toolset = EchoBrowserToolset() + with pytest.raises(ToolsetContractError): + toolset.tool_result(_member_use("misuse")) + + class _TypeErrorToolset(EchoBrowserToolset): + @override + def call(self, context: object, name: str, input: object) -> Any: + raise TypeError("driver bug") + + result = _TypeErrorToolset().tool_result(_member_use("navigate")) + assert result.get("is_error") is True and result.get("content") == "TypeError('driver bug')" + + +@pytest.mark.parametrize("toolset_name", [None, "computer"]) +def test_tool_result_rejects_block_of_another_family(toolset_name: str | None) -> None: + toolset = EchoBrowserToolset() + with pytest.raises(ToolsetContractError, match="is not a member call of the 'browser' toolset"): + toolset.tool_result(_member_use("navigate", toolset_name=toolset_name)) + assert toolset.calls == [] + + +def test_context_manager_closes_the_toolset() -> None: + toolset = EchoBrowserToolset() + with toolset as entered: + assert entered is toolset + assert toolset.closed == 1 + + +async def test_async_tool_result_parity(async_navigate: BetaAsyncFunctionTool[Any]) -> None: + toolset = AsyncEchoBrowserToolset() + use = _member_use("navigate", {"url": "u"}) + + ok = await toolset.tool_result(use) + assert ok == { + "type": "tool_result", + "tool_use_id": "toolu_navigate", + "toolset_name": "browser", + "content": 'navigate:{"url": "u"}', + } + assert toolset.contexts[0].tool_use is use + + failed = await toolset.tool_result(_member_use("boom")) + assert failed.get("is_error") is True and failed.get("toolset_name") == "browser" + assert failed.get("content") == [{"type": "text", "text": "member refused"}] + + crashed = await toolset.tool_result(_member_use("crash")) + assert crashed.get("content") == "RuntimeError('backend exploded')" + + with pytest.raises(ToolsetContractError): + await toolset.tool_result(_member_use("misuse")) + with pytest.raises(ToolsetContractError): + await toolset.tool_result(_member_use("navigate", toolset_name=None)) + + assert index_runnables([toolset, async_navigate]) == ({"async_navigate": async_navigate}, {"browser": toolset}) + + async with toolset: + pass + assert toolset.closed == 1 + + +def test_the_runnable_toolset_bases_are_exported_next_to_the_runner() -> None: + assert ( + tools.BetaRunnableToolset is BetaRunnableToolset and tools.BetaAsyncRunnableToolset is BetaAsyncRunnableToolset + ) + assert {"BetaRunnableToolset", "BetaAsyncRunnableToolset", "BetaToolRunner"} <= set(tools.__all__) + + +async def test_an_async_unexpected_exception_is_logged_with_folded_names_and_answered_with_bounded_text( + caplog: pytest.LogCaptureFixture, +) -> None: + class Failing(AsyncEchoBrowserToolset): + @override + async def call(self, context: BetaToolsetCallContext, name: str, input: object) -> BetaToolsetContent: + raise RuntimeError("x" * 10_000) + + use = BetaToolUseBlock(type="tool_use", id="toolu_f", name="scr\neenshot\x1b[2J", input={}, toolset_name="browser") + + with caplog.at_level(logging.ERROR): + result = await Failing().tool_result(use) + + assert result.get("is_error") is True + content = result.get("content") + text = content if isinstance(content, str) else cast(Any, content)[0]["text"] + assert len(text) == FIELD_MAX and text.startswith("RuntimeError('xxx") + (record,) = [r for r in caplog.records if "toolset member" in r.getMessage()] + assert "\n" not in record.getMessage() and "\x1b" not in record.getMessage() + + +@pytest.mark.parametrize("toolset_name", [None, "computer"]) +async def test_async_tool_result_rejects_block_of_another_family(toolset_name: str | None) -> None: + toolset = AsyncEchoBrowserToolset() + with pytest.raises(ToolsetContractError, match="is not a member call of the 'browser' toolset"): + await toolset.tool_result(_member_use("navigate", toolset_name=toolset_name)) + assert toolset.calls == [] diff --git a/tests/lib/tools/toolsets/test_runner.py b/tests/lib/tools/toolsets/test_runner.py new file mode 100644 index 000000000..83f5cce37 --- /dev/null +++ b/tests/lib/tools/toolsets/test_runner.py @@ -0,0 +1,844 @@ +"""Toolset member routing through `client.beta.messages.tool_runner`: the +Messages responses are mocked so the runner sees `tool_use` blocks carrying +`toolset_name` exactly as the API stamps them.""" + +from __future__ import annotations + +import os +import json +from typing import Any, cast + +import httpx2 +import pytest +from respx import MockRouter + +from anthropic import Anthropic, AsyncAnthropic, beta_tool, beta_async_tool +from anthropic._compat import PYDANTIC_V1 +from anthropic.lib.tools import BetaFunctionTool, BetaAsyncFunctionTool +from anthropic.types.beta import BetaMessageParam, BetaToolUseBlock, BetaContentBlockParam +from anthropic.lib.tools._toolsets import ToolsetClosedError, ToolsetContractError +from anthropic.lib._stainless_helpers import tag_helper + +from ._stubs import BROWSER_ENTRY, EchoBrowserToolset, AsyncEchoBrowserToolset + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + +pytestmark = [ + pytest.mark.skipif(PYDANTIC_V1, reason="tool runner not supported with pydantic v1"), + pytest.mark.respx(base_url=base_url), +] + + +def _tool_use(name: str, id: str, input: dict[str, Any], *, toolset_name: str | None = None) -> dict[str, Any]: + block: dict[str, Any] = {"type": "tool_use", "id": id, "name": name, "input": input} + if toolset_name is not None: + block["toolset_name"] = toolset_name + return block + + +def _response(*blocks: dict[str, Any]) -> httpx2.Response: + return httpx2.Response( + 200, + json={ + "id": "msg_tool_use", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": list(blocks), + "stop_reason": "tool_use", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + +def _end_turn() -> httpx2.Response: + return httpx2.Response( + 200, + json={ + "id": "msg_end_turn", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [{"type": "text", "text": "Done."}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + +def _sse(message: dict[str, Any]) -> httpx2.Response: + """`message` as the server-sent event stream a streaming request receives (one event per content block).""" + events: list[str] = [] + + def event(name: str, data: dict[str, Any]) -> None: + events.append(f"event: {name}\ndata: {json.dumps(data)}\n\n") + + event("message_start", {"type": "message_start", "message": {**message, "content": [], "stop_reason": None}}) + for index, block in enumerate(message["content"]): + if block["type"] == "tool_use": + event( + "content_block_start", + {"type": "content_block_start", "index": index, "content_block": {**block, "input": {}}}, + ) + delta = {"type": "input_json_delta", "partial_json": json.dumps(block["input"])} + else: + event( + "content_block_start", + {"type": "content_block_start", "index": index, "content_block": {**block, "text": ""}}, + ) + delta = {"type": "text_delta", "text": block["text"]} + event("content_block_delta", {"type": "content_block_delta", "index": index, "delta": delta}) + event("content_block_stop", {"type": "content_block_stop", "index": index}) + usage = {"output_tokens": 1} + event("message_delta", {"type": "message_delta", "delta": {"stop_reason": message["stop_reason"]}, "usage": usage}) + event("message_stop", {"type": "message_stop"}) + return httpx2.Response(200, content="".join(events).encode(), headers={"content-type": "text/event-stream"}) + + +def _tool_removal(name: str) -> BetaContentBlockParam: + return {"type": "tool_removal", "tool": {"type": "tool_reference", "name": name}} + + +@pytest.fixture +def client(client: Anthropic) -> Anthropic: # the shared client (tests/conftest.py), without retries + return client.with_options(max_retries=0) + + +@pytest.fixture +def async_client(async_client: AsyncAnthropic) -> AsyncAnthropic: + return async_client.with_options(max_retries=0) + + +def _run_sync(client: Anthropic, *, tools: list[Any], messages: list[BetaMessageParam]) -> list[BetaMessageParam]: + runner = client.beta.messages.tool_runner(max_tokens=1024, model="claude-haiku-4-5", tools=tools, messages=messages) + responses: list[BetaMessageParam] = [] + for _ in runner: + response = runner.generate_tool_call_response() + if response is not None: + responses.append(response) + return responses + + +async def _run_async( + client: AsyncAnthropic, *, tools: list[Any], messages: list[BetaMessageParam] +) -> list[BetaMessageParam]: + runner = client.beta.messages.tool_runner(max_tokens=1024, model="claude-haiku-4-5", tools=tools, messages=messages) + responses: list[BetaMessageParam] = [] + async for _ in runner: + response = await runner.generate_tool_call_response() + if response is not None: + responses.append(response) + return responses + + +def _sent_tools(respx_mock: MockRouter) -> list[dict[str, Any]]: + """The `tools` array of the first request the runner sent.""" + call = cast(Any, respx_mock.calls[0]) + body = cast(dict[str, Any], json.loads(call.request.content)) + return cast(list[dict[str, Any]], body["tools"]) + + +USER_MESSAGE: BetaMessageParam = {"role": "user", "content": "Open example.com"} + + +@pytest.fixture +def navigate() -> BetaFunctionTool[Any]: + # Built inside the fixture: `beta_tool` raises at decoration time under pydantic v1. + @beta_tool + def navigate(url: str) -> str: + """Custom tool that shares a browser member's name.""" + return f"custom:{url}" + + return navigate + + +@pytest.fixture +def async_navigate() -> BetaAsyncFunctionTool[Any]: + async def _async_navigate(url: str) -> str: + """Custom tool that shares a browser member's name.""" + return f"custom:{url}" + + return beta_async_tool(_async_navigate, name="navigate") + + +def test_member_call_routes_by_family_not_name_sync( + client: Anthropic, respx_mock: MockRouter, navigate: BetaFunctionTool[Any] +) -> None: + # Two `navigate` calls in one turn: the member one (toolset_name set) goes + # to the toolset, the bare one to the same-named custom tool. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _response( + _tool_use("navigate", "toolu_member", {"url": "https://example.com"}, toolset_name="browser"), + _tool_use("navigate", "toolu_custom", {"url": "https://custom.test"}), + ), + _end_turn(), + ] + ) + toolset = EchoBrowserToolset() + + results = _run_sync(client, tools=[toolset, navigate], messages=[USER_MESSAGE]) + + assert toolset.calls == [("navigate", {"url": "https://example.com"})] + assert results == [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_member", + "toolset_name": "browser", + "content": 'navigate:{"url": "https://example.com"}', + }, + {"type": "tool_result", "tool_use_id": "toolu_custom", "content": "custom:https://custom.test"}, + ], + } + ] + + # Wire golden: the toolset goes out as its nameless entry, nothing SDK-side leaks. + sent_tools = _sent_tools(respx_mock) + assert sent_tools[0] == BROWSER_ENTRY + assert sent_tools[1]["name"] == "navigate" + assert len(sent_tools) == 2 + + +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_a_streamed_member_call_is_routed_to_the_toolset( + client: Anthropic, + async_client: AsyncAnthropic, + respx_mock: MockRouter, + navigate: BetaFunctionTool[Any], + async_navigate: BetaAsyncFunctionTool[Any], + flavour: str, +) -> None: + # The streaming runner accumulates the tool_use from events; `toolset_name` must survive that and route the + # block to the toolset, and a same-named plain tool in the same turn still goes to the function tool. + turn = json.loads( + _response( + _tool_use("navigate", "toolu_member", {"url": "u"}, toolset_name="browser"), + _tool_use("navigate", "toolu_custom", {"url": "v"}), + ).content + ) + + respx_mock.post("/v1/messages").mock(side_effect=[_sse(turn), _sse(json.loads(_end_turn().content))]) + + toolset: Any = EchoBrowserToolset() if flavour == "sync" else AsyncEchoBrowserToolset() + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + runner = make( + max_tokens=1024, + model="claude-haiku-4-5", + tools=[toolset, navigate if flavour == "sync" else async_navigate], + messages=[USER_MESSAGE], + stream=True, + ) + + results: list[Any] = [] + if flavour == "sync": + for stream in runner: + stream.until_done() + response = runner.generate_tool_call_response() + if response is not None: + results.extend(response["content"]) + else: + async for astream in runner: + await astream.until_done() + aresponse = await runner.generate_tool_call_response() + if aresponse is not None: + results.extend(aresponse["content"]) + + assert toolset.calls == [("navigate", {"url": "u"})] + assert [(r["tool_use_id"], r.get("toolset_name"), r["content"]) for r in results] == [ + ("toolu_member", "browser", 'navigate:{"url": "u"}'), + ("toolu_custom", None, "custom:v"), + ] + + +def test_unregistered_family_is_error_result_with_toolset_name_sync(client: Anthropic, respx_mock: MockRouter) -> None: + # A computer member call with only the browser toolset (and a custom tool + # that happens to be called `screenshot`) registered: nothing runs, and + # the error result still carries `toolset_name`, which the API requires on every member result. + respx_mock.post("/v1/messages").mock( + side_effect=[_response(_tool_use("screenshot", "toolu_cu", {}, toolset_name="computer")), _end_turn()] + ) + toolset = EchoBrowserToolset() + screenshot_called = False + + @beta_tool + def screenshot() -> str: + """Custom tool sharing a computer member's name.""" + nonlocal screenshot_called + screenshot_called = True + return "custom" + + with pytest.warns(UserWarning, match="Toolset 'computer' \\(member 'screenshot'\\) not found in tool runner"): + results = _run_sync(client, tools=[toolset, screenshot], messages=[USER_MESSAGE]) + + assert toolset.calls == [] + assert screenshot_called is False + assert results == [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_cu", + "toolset_name": "computer", + "content": "Error: Toolset 'computer' member 'screenshot' not found", + "is_error": True, + } + ], + } + ] + + +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_unregistered_family_names_are_folded_before_they_reach_a_log_or_the_result( + client: Anthropic, async_client: AsyncAnthropic, respx_mock: MockRouter, flavour: str +) -> None: + # Both names are model output: a line break, escape or bidi override in one must not forge the warning record or + # the error text, and neither may run to any length. + weird_family = "comp\nuter\x1b[2J" + "x" * 500 + + respx_mock.post("/v1/messages").mock( + side_effect=[_response(_tool_use("scr\u202eeen", "toolu_cu", {}, toolset_name=weird_family)), _end_turn()] + ) + + with pytest.warns(UserWarning) as caught: + if flavour == "sync": + results = _run_sync(client, tools=[EchoBrowserToolset()], messages=[USER_MESSAGE]) + else: + results = await _run_async(async_client, tools=[AsyncEchoBrowserToolset()], messages=[USER_MESSAGE]) + + warned = next(str(w.message) for w in caught if "not found in tool runner" in str(w.message)) + content = str(cast(Any, results[0])["content"][0]["content"]) + + for text in (warned.split(". Registered")[0], content): + assert "\n" not in text and "\x1b" not in text and "\u202e" not in text and len(text) < 500 + assert content.startswith("Error: Toolset 'comp uter [2J" + "x" * 20) + + +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_family_or_type_named_block_without_toolset_name_is_not_a_member_call( + client: Anthropic, async_client: AsyncAnthropic, respx_mock: MockRouter, flavour: str +) -> None: + # Only `toolset_name` marks a member call. None of these blocks has one, so none reaches the toolset and no + # result carries `toolset_name`. The block named after the family gets an error that says what is wrong with it. + # The block named after the type is an unknown tool, and so is the one named after a toolset not in the run. + # The sync and async runners each have their own dispatch loop, so both are exercised. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _response( + _tool_use("browser", "toolu_family", {}), + _tool_use("browser_toolset_20260801", "toolu_type", {}), + _tool_use("computer", "toolu_other", {}), + ), + _end_turn(), + ] + ) + + toolset: Any = EchoBrowserToolset() if flavour == "sync" else AsyncEchoBrowserToolset() + + with pytest.warns(UserWarning) as warned: + if flavour == "sync": + results = _run_sync(client, tools=[toolset], messages=[USER_MESSAGE]) + else: + results = await _run_async(async_client, tools=[toolset], messages=[USER_MESSAGE]) + + assert [str(w.message).split(".")[0] for w in warned] == [ + "Tool 'browser_toolset_20260801' not found in tool runner", + "Tool 'computer' not found in tool runner", + ] + assert toolset.calls == [] + assert results == [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_family", + "content": "Error: the 'browser' toolset could not run this call: the call has no 'actions' list; " + "nothing in the batch was executed", + "is_error": True, + }, + { + "type": "tool_result", + "tool_use_id": "toolu_type", + "content": "Error: Tool 'browser_toolset_20260801' not found", + "is_error": True, + }, + { + "type": "tool_result", + "tool_use_id": "toolu_other", + "content": "Error: Tool 'computer' not found", + "is_error": True, + }, + ], + } + ] + + +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_member_errors_carry_toolset_name( + client: Anthropic, async_client: AsyncAnthropic, respx_mock: MockRouter, flavour: str +) -> None: + respx_mock.post("/v1/messages").mock( + side_effect=[ + _response(_tool_use("boom", "toolu_boom", {}, toolset_name="browser")), + _response(_tool_use("crash", "toolu_crash", {}, toolset_name="browser")), + _end_turn(), + ] + ) + if flavour == "sync": + results = _run_sync(client, tools=[EchoBrowserToolset()], messages=[USER_MESSAGE]) + else: + results = await _run_async(async_client, tools=[AsyncEchoBrowserToolset()], messages=[USER_MESSAGE]) + + assert [message["content"] for message in results] == [ + [ + { + "type": "tool_result", + "tool_use_id": "toolu_boom", + "toolset_name": "browser", + "content": [{"type": "text", "text": "member refused"}], + "is_error": True, + } + ], + [ + { + "type": "tool_result", + "tool_use_id": "toolu_crash", + "toolset_name": "browser", + "content": "RuntimeError('backend exploded')", + "is_error": True, + } + ], + ] + + +def test_a_failed_member_stops_the_rest_of_the_turns_members_but_not_function_tools_sync( + client: Anthropic, respx_mock: MockRouter, navigate: BetaFunctionTool[Any] +) -> None: + # Three browser actions in one turn, the second fails: the third is not run and says so. A function tool in the + # same turn runs regardless, before or after the failure, and a failed function tool stops nothing. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _response( + _tool_use("navigate", "toolu_1", {"url": "a"}, toolset_name="browser"), + _tool_use("navigate", "toolu_fn1", {"url": "f1"}), + _tool_use("boom", "toolu_2", {}, toolset_name="browser"), + _tool_use("navigate", "toolu_3", {"url": "c"}, toolset_name="browser"), + _tool_use("navigate", "toolu_fn2", {"url": "f2"}), + ), + _response( + _tool_use("nope", "toolu_fn3", {}), # an unknown function tool fails... + _tool_use("navigate", "toolu_4", {"url": "d"}, toolset_name="browser"), # ...and stops nothing + ), + _end_turn(), + ] + ) + + toolset = EchoBrowserToolset() + + with pytest.warns(UserWarning, match="not found"): + results = _run_sync(client, tools=[toolset, navigate], messages=[USER_MESSAGE]) + + first = {r["tool_use_id"]: (r.get("is_error", False), r["content"]) for r in cast(Any, results[0]["content"])} + assert first["toolu_1"] == (False, 'navigate:{"url": "a"}') + assert first["toolu_2"][0] is True and first["toolu_2"][1] == [{"type": "text", "text": "member refused"}] + assert first["toolu_3"] == (True, "Not executed: an earlier action in this turn failed.") + assert first["toolu_fn1"] == (False, "custom:f1") and first["toolu_fn2"] == (False, "custom:f2") + assert [c[1] for c in toolset.calls] == [{"url": "a"}, {}, {"url": "d"}] # toolu_3 never reached the toolset + + second = {r["tool_use_id"]: r.get("is_error", False) for r in cast(Any, results[1]["content"])} + assert second == {"toolu_fn3": True, "toolu_4": False} + + +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_tool_removal_of_member_name_does_not_withdraw_toolset( + client: Anthropic, + async_client: AsyncAnthropic, + respx_mock: MockRouter, + navigate: BetaFunctionTool[Any], + async_navigate: BetaAsyncFunctionTool[Any], + flavour: str, +) -> None: + # `tool_reference` names plain tools; a member name is not reserved, so + # removing `navigate` withdraws the custom tool but not the toolset member. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _response( + _tool_use("navigate", "toolu_member", {"url": "u"}, toolset_name="browser"), + _tool_use("navigate", "toolu_custom", {"url": "u"}), + ), + _end_turn(), + ] + ) + + toolset: Any = EchoBrowserToolset() if flavour == "sync" else AsyncEchoBrowserToolset() + messages: list[BetaMessageParam] = [USER_MESSAGE, {"role": "system", "content": [_tool_removal("navigate")]}] + + with pytest.warns(UserWarning, match="Tool 'navigate' not found in tool runner"): + if flavour == "sync": + results = _run_sync(client, tools=[toolset, navigate], messages=messages) + else: + results = await _run_async(async_client, tools=[toolset, async_navigate], messages=messages) + + assert toolset.calls == [("navigate", {"url": "u"})] + assert results == [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_member", + "toolset_name": "browser", + "content": 'navigate:{"url": "u"}', + }, + { + "type": "tool_result", + "tool_use_id": "toolu_custom", + "content": "Error: Tool 'navigate' not found", + "is_error": True, + }, + ], + } + ] + + +async def test_member_call_roundtrip_async( + async_client: AsyncAnthropic, respx_mock: MockRouter, async_navigate: BetaAsyncFunctionTool[Any] +) -> None: + respx_mock.post("/v1/messages").mock( + side_effect=[ + _response( + _tool_use("navigate", "toolu_member", {"url": "https://example.com"}, toolset_name="browser"), + _tool_use("navigate", "toolu_custom", {"url": "https://custom.test"}), + _tool_use("boom", "toolu_boom", {}, toolset_name="browser"), + ), + _end_turn(), + ] + ) + + toolset = AsyncEchoBrowserToolset() + results = await _run_async(async_client, tools=[toolset, async_navigate], messages=[USER_MESSAGE]) + + assert toolset.calls == [("navigate", {"url": "https://example.com"}), ("boom", {})] + assert results == [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_member", + "toolset_name": "browser", + "content": 'navigate:{"url": "https://example.com"}', + }, + {"type": "tool_result", "tool_use_id": "toolu_custom", "content": "custom:https://custom.test"}, + { + "type": "tool_result", + "tool_use_id": "toolu_boom", + "toolset_name": "browser", + "content": [{"type": "text", "text": "member refused"}], + "is_error": True, + }, + ], + } + ] + assert _sent_tools(respx_mock)[0] == BROWSER_ENTRY + + +async def test_a_failed_member_stops_the_rest_of_the_turns_members_but_not_function_tools_async( + async_client: AsyncAnthropic, respx_mock: MockRouter, async_navigate: BetaAsyncFunctionTool[Any] +) -> None: + # Three browser actions in one turn, the second fails: the third is not run and says so. A function tool in the + # same turn runs regardless, before or after the failure, and a failed function tool stops nothing. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _response( + _tool_use("navigate", "toolu_1", {"url": "a"}, toolset_name="browser"), + _tool_use("navigate", "toolu_fn1", {"url": "f1"}), + _tool_use("boom", "toolu_2", {}, toolset_name="browser"), + _tool_use("navigate", "toolu_3", {"url": "c"}, toolset_name="browser"), + _tool_use("navigate", "toolu_fn2", {"url": "f2"}), + ), + _response( + _tool_use("nope", "toolu_fn3", {}), # an unknown function tool fails... + _tool_use("navigate", "toolu_4", {"url": "d"}, toolset_name="browser"), # ...and stops nothing + ), + _end_turn(), + ] + ) + + toolset = AsyncEchoBrowserToolset() + with pytest.warns(UserWarning, match="not found"): + results = await _run_async(async_client, tools=[toolset, async_navigate], messages=[USER_MESSAGE]) + + first = {r["tool_use_id"]: (r.get("is_error", False), r["content"]) for r in cast(Any, results[0]["content"])} + assert first["toolu_1"] == (False, 'navigate:{"url": "a"}') + assert first["toolu_2"][0] is True and first["toolu_2"][1] == [{"type": "text", "text": "member refused"}] + assert first["toolu_3"] == (True, "Not executed: an earlier action in this turn failed.") + assert first["toolu_fn1"] == (False, "custom:f1") and first["toolu_fn2"] == (False, "custom:f2") + assert [c[1] for c in toolset.calls] == [{"url": "a"}, {}, {"url": "d"}] # toolu_3 never reached the toolset + + second = {r["tool_use_id"]: r.get("is_error", False) for r in cast(Any, results[1]["content"])} + assert second == {"toolu_fn3": True, "toolu_4": False} + + +async def test_unregistered_family_is_error_result_with_toolset_name_async( + async_client: AsyncAnthropic, respx_mock: MockRouter +) -> None: + respx_mock.post("/v1/messages").mock( + side_effect=[_response(_tool_use("screenshot", "toolu_cu", {}, toolset_name="computer")), _end_turn()] + ) + with pytest.warns(UserWarning, match="Toolset 'computer' \\(member 'screenshot'\\) not found"): + results = await _run_async(async_client, tools=[AsyncEchoBrowserToolset()], messages=[USER_MESSAGE]) + + assert results == [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_cu", + "toolset_name": "computer", + "content": "Error: Toolset 'computer' member 'screenshot' not found", + "is_error": True, + } + ], + } + ] + + +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_every_call_to_an_unregistered_family_in_a_turn_says_not_found( + client: Anthropic, async_client: AsyncAnthropic, respx_mock: MockRouter, flavour: str +) -> None: + # The family is not registered, so no call ran, and each call reports "not found" on its own. + uses = [_tool_use(name, f"toolu_{name}", {}, toolset_name="computer") for name in ("screenshot", "left_click")] + respx_mock.post("/v1/messages").mock(side_effect=[_response(*uses), _end_turn()]) + + with pytest.warns(UserWarning, match="not found in tool runner"): + if flavour == "sync": + results = _run_sync(client, tools=[EchoBrowserToolset()], messages=[USER_MESSAGE]) + else: + results = await _run_async(async_client, tools=[AsyncEchoBrowserToolset()], messages=[USER_MESSAGE]) + + assert [block["content"] for block in cast(Any, results[0])["content"]] == [ + "Error: Toolset 'computer' member 'screenshot' not found", + "Error: Toolset 'computer' member 'left_click' not found", + ] + + +def test_runner_passes_the_tool_use_as_context_and_leaves_the_toolset_open_sync( + client: Anthropic, respx_mock: MockRouter +) -> None: + # The runner never closes a toolset: whoever created the browser closes it, and one instance can serve a second + # run. A usage error stops the run and still leaves the toolset open. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _response(_tool_use("navigate", "toolu_member", {"url": "u"}, toolset_name="browser")), + _end_turn(), + _response(_tool_use("misuse", "toolu_misuse", {}, toolset_name="browser")), + _response(_tool_use("navigate", "toolu_again", {"url": "v"}, toolset_name="browser")), + _end_turn(), + ] + ) + + toolset = EchoBrowserToolset() + _run_sync(client, tools=[toolset], messages=[USER_MESSAGE]) + assert toolset.contexts[0].tool_use is not None and toolset.contexts[0].tool_use.id == "toolu_member" + assert toolset.closed == 0 + + runner = client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=[toolset], messages=[USER_MESSAGE] + ) + with pytest.raises(ToolsetContractError): + runner.until_done() + assert toolset.closed == 0 + + _run_sync(client, tools=[toolset], messages=[USER_MESSAGE]) # the same instance serves another run + assert [c[1] for c in toolset.calls if c[0] == "navigate"] == [{"url": "u"}, {"url": "v"}] and toolset.closed == 0 + + +async def test_runner_passes_context_and_leaves_the_toolset_open_async( + async_client: AsyncAnthropic, respx_mock: MockRouter +) -> None: + # The runner never closes a toolset: whoever created the browser closes it, and one instance can serve a second + # run. A usage error stops the run and still leaves the toolset open. + respx_mock.post("/v1/messages").mock( + side_effect=[ + _response(_tool_use("navigate", "toolu_member", {"url": "u"}, toolset_name="browser")), + _end_turn(), + _response(_tool_use("misuse", "toolu_misuse", {}, toolset_name="browser")), + _response(_tool_use("navigate", "toolu_again", {"url": "v"}, toolset_name="browser")), + _end_turn(), + ] + ) + + toolset = AsyncEchoBrowserToolset() + await _run_async(async_client, tools=[toolset], messages=[USER_MESSAGE]) + assert toolset.contexts[0].tool_use is not None and toolset.contexts[0].tool_use.id == "toolu_member" + assert toolset.closed == 0 + + runner = async_client.beta.messages.tool_runner( + max_tokens=1024, model="claude-haiku-4-5", tools=[toolset], messages=[USER_MESSAGE] + ) + with pytest.raises(ToolsetContractError): + await runner.until_done() + assert toolset.contexts[1].tool_use is not None and toolset.contexts[1].tool_use.id == "toolu_misuse" + assert toolset.closed == 0 + + await _run_async(async_client, tools=[toolset], messages=[USER_MESSAGE]) # the same instance serves another run + assert [c[1] for c in toolset.calls if c[0] == "navigate"] == [{"url": "u"}, {"url": "v"}] and toolset.closed == 0 + + +_LATE_USE = BetaToolUseBlock( + type="tool_use", id="toolu_late", name="navigate", input={"url": "u"}, toolset_name="browser" +) + + +def test_close_and_with_mark_the_toolset_closed_sync() -> None: + # close() — directly or through `with` — marks the toolset closed once; a member call afterwards raises at + # your code instead of answering the model. + toolset = EchoBrowserToolset() + + with toolset: + toolset.tool_result(_LATE_USE) + assert toolset.closed == 1 + + with pytest.raises(ToolsetClosedError, match="is closed"): + toolset.tool_result(_LATE_USE) + with pytest.raises(ToolsetClosedError): + with toolset: + pass + + twice = EchoBrowserToolset() + with twice: + twice.close() + assert twice.closed == 1 # closed inside the block: the block does not close again + + +async def test_async_close_and_async_with_mark_the_toolset_closed_async() -> None: + toolset = AsyncEchoBrowserToolset() + async with toolset: + await toolset.tool_result(_LATE_USE) + assert toolset.closed == 1 + with pytest.raises(ToolsetClosedError): + await toolset.tool_result(_LATE_USE) + + with pytest.raises(ToolsetClosedError): + async with toolset: + pass + + twice = AsyncEchoBrowserToolset() + async with twice: + await twice.close() + assert twice.closed == 1 # closed inside the block: the block does not close again + + +def test_sync_tool_runner_rejects_an_async_toolset(client: Anthropic) -> None: + # Reported at the tool_runner() call rather than failing deep in JSON encoding. + with pytest.raises(ToolsetContractError, match="async"): + client.beta.messages.tool_runner( + max_tokens=1024, + model="claude-haiku-4-5", + messages=[USER_MESSAGE], + tools=[AsyncEchoBrowserToolset()], # pyright: ignore[reportArgumentType] + ) + + +async def test_async_tool_runner_rejects_a_sync_toolset(async_client: AsyncAnthropic) -> None: + with pytest.raises(ToolsetContractError, match="synchronous"): + async_client.beta.messages.tool_runner( + max_tokens=1024, + model="claude-haiku-4-5", + messages=[USER_MESSAGE], + tools=[EchoBrowserToolset()], # pyright: ignore[reportArgumentType] + ) + + +async def test_tool_runner_rejects_a_function_tool_of_the_other_flavour( + client: Anthropic, async_client: AsyncAnthropic +) -> None: + @beta_tool + def sync_tool(x: str) -> str: + """A sync tool.""" + return x + + async def _async_tool(x: str) -> str: + """An async tool.""" + return x + + async_tool = beta_async_tool(_async_tool) + with pytest.raises(TypeError, match="async tool"): + client.beta.messages.tool_runner( + max_tokens=1024, + model="claude-haiku-4-5", + messages=[USER_MESSAGE], + tools=[async_tool], # pyright: ignore[reportArgumentType] + ) + with pytest.raises(TypeError, match="synchronous tool"): + async_client.beta.messages.tool_runner( + max_tokens=1024, + model="claude-haiku-4-5", + messages=[USER_MESSAGE], + tools=[sync_tool], # pyright: ignore[reportArgumentType] + ) + + +@pytest.mark.parametrize("flavour", ["sync", "async"]) +async def test_tool_runner_accepts_a_generator_of_tools( + respx_mock: MockRouter, client: Anthropic, async_client: AsyncAnthropic, flavour: str +) -> None: + # A one-shot generator of tools behaves like a list: it is materialized before registration, so the runner sends + # the toolset's entry (not tools: []) and can still dispatch to it. + respx_mock.post("/v1/messages").mock(return_value=_end_turn()) + toolset: Any = EchoBrowserToolset() if flavour == "sync" else AsyncEchoBrowserToolset() + make: Any = (client if flavour == "sync" else async_client).beta.messages.tool_runner + runner = make(max_tokens=1024, model="claude-haiku-4-5", messages=[USER_MESSAGE], tools=(t for t in [toolset])) + + done = runner.until_done() + if flavour == "async": + await done + assert _sent_tools(respx_mock) == [BROWSER_ENTRY] + + +# --- helper header -------------------------------------------------------------------------------- + + +def test_the_helper_header_names_the_toolset_the_runner_was_created_with_sync( + client: Anthropic, respx_mock: MockRouter, navigate: BetaFunctionTool[Any] +) -> None: + def helper_tags(index: int) -> list[str]: + header = cast(Any, respx_mock.calls[index]).request.headers.get("x-stainless-helper", "") + return [tag for tag in header.split(", ") if tag] + + respx_mock.post("/v1/messages").mock(side_effect=[_end_turn(), _end_turn()]) + browser = EchoBrowserToolset() + tag_helper(browser, cast(Any, "browser-toolset")) # what the browser base class does for itself + + _run_sync(client, tools=[browser], messages=[USER_MESSAGE]) + _run_sync(client, tools=[navigate], messages=[USER_MESSAGE]) + + assert "browser-toolset" in helper_tags(0) and "BetaToolRunner" in helper_tags(0) + assert "browser-toolset" not in helper_tags(1) + + +async def test_the_helper_header_names_the_toolset_the_runner_was_created_with_async( + async_client: AsyncAnthropic, respx_mock: MockRouter, async_navigate: BetaAsyncFunctionTool[Any] +) -> None: + def helper_tags(index: int) -> list[str]: + header = cast(Any, respx_mock.calls[index]).request.headers.get("x-stainless-helper", "") + return [tag for tag in header.split(", ") if tag] + + respx_mock.post("/v1/messages").mock(side_effect=[_end_turn(), _end_turn()]) + browser = AsyncEchoBrowserToolset() + tag_helper(browser, cast(Any, "browser-toolset")) # what the browser base class does for itself + + await _run_async(async_client, tools=[browser], messages=[USER_MESSAGE]) + await _run_async(async_client, tools=[async_navigate], messages=[USER_MESSAGE]) + + assert "browser-toolset" in helper_tags(0) and "BetaToolRunner" in helper_tags(0) + assert "browser-toolset" not in helper_tags(1) diff --git a/tests/lib/tools/toolsets/test_security.py b/tests/lib/tools/toolsets/test_security.py new file mode 100644 index 000000000..1acc17f83 --- /dev/null +++ b/tests/lib/tools/toolsets/test_security.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import os +import re +from pathlib import Path + +import pytest + +from anthropic.tools import ( + ToolError, + ToolsetConfigError, +) +from anthropic.tools.browser import ( + BetaURLContext, + BetaLocalFilePolicy, + beta_check_upload_path, +) +from anthropic.lib.tools._toolsets import _security + +# The refusal texts the model reads for an upload path; pinned because they must never name a root. +UPLOAD_OUTSIDE_ROOTS = "file_upload path is outside the configured upload roots" +UPLOAD_NO_ROOTS = "file_upload has no configured upload roots" +UPLOAD_EMPTY_PATH = "file_upload path is empty or contains a NUL byte" +UPLOAD_DOTDOT = "file_upload path must not contain a '..' component" + + +def test_upload_path_table(tmp_path: Path) -> None: + root = tmp_path / "srv" / "upload" + root.mkdir(parents=True) + (root / "ok.txt").write_text("x") + (tmp_path / "srv" / "uploads-old").mkdir() + (tmp_path / "srv" / "uploads-old" / "x").write_text("x") + (tmp_path / "secret").write_text("s") + os.symlink(tmp_path / "secret", root / "link-out") + os.symlink(root / "link-out", root / "chain") + roots = [str(root)] + + assert beta_check_upload_path(str(root / "ok.txt"), roots) == str((root / "ok.txt").resolve()) + assert beta_check_upload_path(str(root), roots) == str(root.resolve()) + assert beta_check_upload_path(str(root / "."), roots) == str(root.resolve()) + assert beta_check_upload_path(str(root / "new" / "file.bin"), roots) == str(root.resolve() / "new" / "file.bin") + assert beta_check_upload_path(str(root / "ok.txt"), [str(tmp_path / "nope"), str(root)]) == str( + (root / "ok.txt").resolve() + ) + + for bad in [ + str(tmp_path / "srv" / "uploads-old" / "x"), + str(root / "link-out"), + str(root / "chain"), + "relative/path.txt", + ]: + with pytest.raises(ToolError) as info: + beta_check_upload_path(bad, roots) + assert info.value.content == UPLOAD_OUTSIDE_ROOTS, bad + for dots in [str(root / ".." / "uploads-old" / "x"), str(root / ".." / ".." / "secret")]: + with pytest.raises(ToolError) as info: + beta_check_upload_path(dots, roots) + assert info.value.content == UPLOAD_DOTDOT, dots + + with pytest.raises(ToolError) as info: + beta_check_upload_path(str(root / "ok.txt"), []) + assert info.value.content == UPLOAD_NO_ROOTS + for invalid in ["", "a\x00b"]: + with pytest.raises(ToolError) as info: + beta_check_upload_path(invalid, roots) + assert info.value.content == UPLOAD_EMPTY_PATH + + +def test_upload_sibling_prefix_is_not_a_root_match(tmp_path: Path) -> None: + (tmp_path / "upload").mkdir() + (tmp_path / "upload-evil").mkdir() + (tmp_path / "upload-evil" / "x").write_text("x") + with pytest.raises(ToolError): + beta_check_upload_path(str(tmp_path / "upload-evil" / "x"), [str(tmp_path / "upload")]) + + +def test_upload_dotdot_component_is_refused(tmp_path: Path) -> None: + """A `..` component is refused before resolution, so it cannot survive into the + not-yet-existing tail and let a lexical containment check pass a path that denotes a file + outside the root.""" + root = tmp_path / "srv" / "upload" + root.mkdir(parents=True) + (tmp_path / "secret").write_text("s") + for bad in [ + str(root / "ghost" / ".." / ".." / ".." / "secret"), + str(root / "ghost" / ".." / ".." / "secret"), + str(root / "a" / "b" / ".." / ".." / ".." / "secret"), + str(root / "ghost" / ".." / "file.bin"), + ]: + with pytest.raises(ToolError) as info: + beta_check_upload_path(bad, [str(root)]) + assert info.value.content == UPLOAD_DOTDOT, bad + + +def test_a_single_path_string_is_a_config_error() -> None: + """`Sequence[str]` is structurally satisfied by `str`, so this type-checks clean. + + Iterating one would walk characters: the `"/"` element is a parent of every + absolute path and approves everything — a fail-open in the default-deny direction. + """ + with pytest.raises(ToolsetConfigError): + beta_check_upload_path("/etc/passwd", "/srv/uploads") + + +def test_a_dangling_symlink_in_a_root_is_judged_by_its_target_and_a_cycle_resolves_as_written(tmp_path: Path) -> None: + """A symlink under a root that names a missing file elsewhere resolves to that target, so the root check + refuses it; a symlink cycle has no target and stays as written (opening it fails with ELOOP).""" + root = tmp_path / "upload" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + os.symlink(outside / "gone.txt", root / "dangling") + os.symlink(root / "b", root / "a") + os.symlink(root / "a", root / "b") + + with pytest.raises(ToolError) as info: + beta_check_upload_path(str(root / "dangling"), [str(root)]) + assert info.value.content == UPLOAD_OUTSIDE_ROOTS + + for loop in ["a", "b"]: + assert beta_check_upload_path(str(root / loop), [str(root)]) == str(root.resolve() / loop) + + # a genuinely absent child still resolves + assert beta_check_upload_path(str(root / "new.bin"), [str(root)]) == str(root.resolve() / "new.bin") + + +def test_an_upload_path_outside_every_root_is_refused_without_touching_the_filesystem( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A path that does not even claim to be under an upload root is refused on its text: the SDK never stats or + # resolves a path of the model's choosing (an existence probe, an automounted share). A path that does claim to + # be under a root is still resolved, so a symlink inside the root that points out of it is refused. + root = tmp_path / "uploads" + root.mkdir() + os.symlink(tmp_path, root / "escape") + resolved: list[str] = [] + real_realpath_lenient = _security.realpath_lenient + + def spy(path: str) -> Path: + resolved.append(path) + return real_realpath_lenient(path) + + monkeypatch.setattr(_security, "realpath_lenient", spy) + with pytest.raises(ToolError, match="outside the configured upload roots"): + beta_check_upload_path("/net/elsewhere.example/share/f", [str(root)]) + assert not [path for path in resolved if "elsewhere" in path] # only the deployer's root was looked at + + escape = str(root / "escape" / "secret") + with pytest.raises(ToolError, match="outside the configured upload roots"): + beta_check_upload_path(escape, [str(root)]) + assert escape in resolved # the spy sees a path that is resolved, so the empty result above means something + assert beta_check_upload_path(str(root / "f.txt"), [str(root)]) == str(root.resolve() / "f.txt") + + +def test_a_download_path_outside_the_download_dir_is_hidden_without_touching_the_filesystem( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # As for uploads: a path that does not claim to be under the download directory is judged on its text alone, so a + # page cannot make the SDK resolve a path of its choosing by getting it into a download event. + download_dir = tmp_path / "downloads" + download_dir.mkdir() + policy = BetaLocalFilePolicy(download_dir=str(download_dir), expose_download_paths=True) + resolved: list[str] = [] + real_realpath_lenient = _security.realpath_lenient + + def spy(path: str) -> Path: + resolved.append(path) + return real_realpath_lenient(path) + + monkeypatch.setattr(_security, "realpath_lenient", spy) + assert policy.is_path_visible("/net/elsewhere.example/share/f.bin") is False + # A ".." component is refused on its text alone, since resolving this path would touch the share it names. + assert policy.is_path_visible(f"/net/elsewhere.example/share/../../..{download_dir}/f.bin") is False + assert not [path for path in resolved if "elsewhere" in path] + + inside = str(download_dir / "f.bin") + assert policy.is_path_visible(inside) is True + assert inside in resolved # the spy sees a path that is resolved + + +@pytest.mark.parametrize( + "candidate", + [ + r"\\attacker.example\share\f.txt", + "//attacker.example/share/f.txt", + "/\\attacker.example\\share\\f.txt", # mixed separators are UNC to Windows too + "\\/?\\C:\\x", + r"\\?\C:\x", + "CON", + "com1.tar", + "NUL.txt", + r"sub\COM1.txt", # backslash-separated device: the check splits on '\' too + r"C:CON", # drive-relative spelling: split on ':' too + r"C:NUL.txt", + "COM1 ", # Win32 strips a trailing space before resolving the device name + r"f.txt:CON", # alternate-data-stream spelling + "CON .txt", # Win32 truncates the name at the extension dot after stripping the trailing space + "NUL .bin", + "COM1 .log", + "CONIN$", # console device handles + "CONOUT$", + "COM0", # may name a device on some Windows versions + "lpt0.txt", + ], +) +def test_windows_network_and_device_paths_are_refused_lexically_on_windows( + candidate: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Refused before the filesystem is touched: resolving a UNC path makes the Windows redirector + authenticate to the named host, and a DOS device name opens the device, not a file.""" + + def _resolved(_path: str) -> str: + raise AssertionError("the candidate must be refused before it is resolved") + + monkeypatch.setattr(_security, "ON_WINDOWS", True) + monkeypatch.setattr(_security, "realpath_lenient", _resolved) + with pytest.raises(ToolError) as info: + beta_check_upload_path( + str(tmp_path / candidate) if candidate[:1] not in ("\\", "/") else candidate, [str(tmp_path)] + ) + assert info.value.content == UPLOAD_OUTSIDE_ROOTS + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX path semantics") +def test_elsewhere_a_device_named_file_is_an_ordinary_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_security, "ON_WINDOWS", False) + root = os.path.realpath(tmp_path) + (tmp_path / "aux").mkdir() + + for name in ("CON", "con.pdf", "aux/notes.txt", "COM1 .log"): + assert beta_check_upload_path(os.path.join(root, name), [root]) == os.path.join(root, name) + + # two leading slashes name no network location here; such a path is judged by containment like any other + with pytest.raises(ToolError): + beta_check_upload_path("//attacker.example/share/f.txt", [root]) + + policy = BetaLocalFilePolicy(download_dir=root, expose_download_paths=True) + assert policy.is_path_visible(os.path.join(root, "nul.bin")) is True + + monkeypatch.setattr(_security, "ON_WINDOWS", True) + assert policy.is_path_visible(os.path.join(root, "nul.bin")) is False + + +def test_upload_root_that_does_not_exist_grants_nothing(tmp_path: Path) -> None: + # A root that does not resolve on disk must grant no containment (default-deny); otherwise a + # symlink planted in the root's parent chain escapes the gate. + missing = tmp_path / "nope" + with pytest.raises(ToolError): + beta_check_upload_path(str(missing / "file.bin"), [str(missing)]) + + # Same when the missing root sits behind a symlink whose target exists but the leaf does not. + link = tmp_path / "link" + link.symlink_to(tmp_path) # link -> an existing dir, but 'uploads' under it does not exist + with pytest.raises(ToolError): + beta_check_upload_path(str(link / "uploads" / "file.bin"), [str(link / "uploads")]) + + +def test_all_blank_upload_roots_report_no_configured_roots() -> None: + # A non-empty list whose entries are all blank configures no usable root, so report that rather + # than blaming the path. + for roots in ([""], [" "], ["", " "]): + with pytest.raises(ToolError, match="no configured upload roots"): + beta_check_upload_path("/x/f", roots) + + +def test_local_file_policy_resolves_uploads_under_its_roots(tmp_path: Path) -> None: + root = tmp_path / "uploads" + root.mkdir() + (root / "form.pdf").write_text("x") + + ctx = BetaURLContext(member="file_upload") + policy = BetaLocalFilePolicy(upload_roots=[str(root)]) + + assert policy.resolve_upload_paths(ctx, [str(root / "form.pdf")]) == [str(root / "form.pdf")] + + with pytest.raises(ToolError, match="outside the configured upload roots"): + policy.resolve_upload_paths(ctx, [str(tmp_path / "secret")]) + with pytest.raises(ToolError, match="must not contain a '..' component"): + policy.resolve_upload_paths(ctx, [str(root / ".." / "secret")]) + + # No roots configured: every path-bearing upload is refused. + with pytest.raises(ToolError, match="no configured upload roots"): + BetaLocalFilePolicy().resolve_upload_paths(ctx, [str(root / "form.pdf")]) + + +def test_local_file_policy_vets_document_ids_against_its_allowlist() -> None: + # Document ids (Files API files) are denied unless listed, like paths outside the upload roots; the refusal + # names no id, and a listed id passes through unchanged. + ctx = BetaURLContext(member="file_upload") + + with pytest.raises(ToolError, match="not in the upload allowlist"): + BetaLocalFilePolicy().resolve_upload_documents(ctx, ["file_abc"]) + + policy = BetaLocalFilePolicy(upload_document_ids=["file_abc", "file_def"]) + assert policy.resolve_upload_documents(ctx, ["file_def"]) == ["file_def"] + + with pytest.raises(ToolError) as caught: + policy.resolve_upload_documents(ctx, ["file_def", "file_xyz"]) + assert "file_xyz" not in str(caught.value) + assert policy.upload_document_ids == frozenset({"file_abc", "file_def"}) + + lazy = BetaLocalFilePolicy(upload_document_ids=(d for d in ["file_abc"])) # any iterable, read once + assert lazy.resolve_upload_documents(ctx, ["file_abc"]) == ["file_abc"] + + with pytest.raises(ToolsetConfigError): + BetaLocalFilePolicy(upload_document_ids="file_abc") + + +def test_local_file_policy_download_visibility(tmp_path: Path) -> None: + downloads = tmp_path / "downloads" + downloads.mkdir() + + assert BetaLocalFilePolicy(download_dir=str(downloads)).is_path_visible(str(downloads / "a.bin")) is False + + exposing = BetaLocalFilePolicy(download_dir=str(downloads), expose_download_paths=True) + assert exposing.is_path_visible(str(downloads / "a.bin")) is True + assert exposing.is_path_visible(str(downloads / "sub" / "not-yet.bin")) is True + + # A server-suggested name that climbs out, or a path elsewhere, stays hidden. + assert exposing.is_path_visible(str(downloads / ".." / "a.bin")) is False + assert exposing.is_path_visible("/etc/passwd") is False + + +def test_local_file_policy_construction_rules(tmp_path: Path) -> None: + # with no download_dir there is nothing to expose + assert BetaLocalFilePolicy(expose_download_paths=True).is_path_visible(str(tmp_path / "f.bin")) is False + + # a string from an environment variable ("false") is truthy: anything but True exposes nothing + lenient = BetaLocalFilePolicy(download_dir=str(tmp_path), expose_download_paths="true") # pyright: ignore[reportArgumentType] + assert lenient.is_path_visible(str(tmp_path / "f.bin")) is False + + with pytest.raises(ToolsetConfigError, match="not a single path"): + BetaLocalFilePolicy(upload_roots="/srv/uploads") + with pytest.raises(ToolsetConfigError, match="must not be empty"): + BetaLocalFilePolicy(upload_roots=[""]) + with pytest.raises(ToolsetConfigError, match="must not be empty"): + BetaLocalFilePolicy(download_dir=" ") + + # The download-then-upload loop: a download directory inside (or containing) an upload root. + with pytest.raises( + ToolsetConfigError, match=f"download_dir {re.escape(repr(str(tmp_path / 'dl')))} must be outside" + ): + BetaLocalFilePolicy(upload_roots=[str(tmp_path)], download_dir=str(tmp_path / "dl")) + with pytest.raises(ToolsetConfigError, match="outside every upload root"): + BetaLocalFilePolicy(upload_roots=[str(tmp_path / "up")], download_dir=str(tmp_path)) + # On a disk that ignores letter case (macOS by default) a case variant names the same directory, so it is refused + # everywhere. + with pytest.raises(ToolsetConfigError, match="outside every upload root"): + BetaLocalFilePolicy(upload_roots=[str(tmp_path / "Uploads")], download_dir=str(tmp_path / "uploads" / "dl")) + # The same disk ignores the Unicode form of a name too, so a decomposed spelling of a composed root is refused. + with pytest.raises(ToolsetConfigError, match="outside every upload root"): + BetaLocalFilePolicy( + upload_roots=[str(tmp_path / "t\u00e9l\u00e9")], download_dir=str(tmp_path / "te\u0301le\u0301") + ) + + # A download_dir that is a symlink into an upload root is the same loop by another spelling. + uploads = tmp_path / "uploads" + (uploads / "dl").mkdir(parents=True) + link = tmp_path / "downloads" + link.symlink_to(uploads / "dl") + with pytest.raises(ToolsetConfigError, match="outside every upload root"): + BetaLocalFilePolicy(upload_roots=[str(uploads)], download_dir=str(link)) + + # ... and so is an upload root that is a symlink into the download directory. + real_downloads = tmp_path / "real_downloads" + real_downloads.mkdir() + root_link = tmp_path / "root_link" + root_link.symlink_to(real_downloads) + with pytest.raises(ToolsetConfigError, match="outside every upload root"): + BetaLocalFilePolicy(upload_roots=[str(root_link)], download_dir=str(real_downloads / "sub")) + + policy = BetaLocalFilePolicy(upload_roots=[str(tmp_path / "up")], download_dir=str(tmp_path / "dl")) + assert policy.upload_roots == (str(tmp_path / "up"),) and policy.download_dir == str(tmp_path / "dl") diff --git a/tests/lib/tools/toolsets/test_surface.py b/tests/lib/tools/toolsets/test_surface.py new file mode 100644 index 000000000..c3ea41c77 --- /dev/null +++ b/tests/lib/tools/toolsets/test_surface.py @@ -0,0 +1,189 @@ +"""The browser toolset's public surface: every name the guide documents imports from `anthropic.tools.browser`, +and the abstract classes carry the constructor options and member methods a driver overrides.""" + +from __future__ import annotations + +import inspect +from typing import Any, cast +from typing_extensions import get_args, get_type_hints + +import pytest +import pydantic + +import anthropic.tools.browser as surface +from anthropic.tools import ( + ToolError, + ToolsetUsageError, + ConfirmFailedError, + ToolsetClosedError, + ToolsetConfigError, + UnknownMemberError, + DisabledMemberError, + ConfirmDeclinedError, + ToolsetContractError, + UnavailableMemberError, + InvalidMemberInputError, +) +from anthropic._compat import PYDANTIC_V1, field_outer_type, get_model_fields +from anthropic.lib.tools import BetaToolLike +from anthropic.types.beta import BetaBase64ImageSourceParam +from anthropic.tools.browser import ( + BetaURLContext, + TabMissingError, + URLRefusedError, + BetaBrowserState, + UploadRefusedError, + BetaDialogDismissed, + BetaScreenshotResult, + BetaNavigationRefused, + BetaAbstractBrowserToolset20260801, + BetaAsyncAbstractBrowserToolset20260801, +) +from anthropic.lib.tools._tool_params import SupportsToDict +from anthropic.lib.tools._toolsets._inputs import BROWSER_MEMBER_NAMES +from anthropic.lib.tools._toolsets._registry import BROWSER_DEFAULT_DISABLED_MEMBERS + + +def test_every_documented_name_is_exported() -> None: + for name in surface.__all__: + assert hasattr(surface, name), name + assert not hasattr(surface, "BROWSER_MEMBER_NAMES") + assert not hasattr(surface, "BROWSER_DEFAULT_DISABLED_MEMBERS") + + +def test_the_error_family_is_one_hierarchy() -> None: + for cls in (ToolsetClosedError, ToolsetConfigError, ToolsetContractError): + assert issubclass(cls, ToolsetUsageError) + + +REFUSALS = [ + "UnknownMemberError", + "DisabledMemberError", + "UnavailableMemberError", + "InvalidMemberInputError", + "URLRefusedError", + "TabMissingError", + "ConfirmDeclinedError", + "ConfirmFailedError", + "UploadRefusedError", +] + + +def test_the_refusals_are_exported_tool_errors() -> None: + classes = [ + UnknownMemberError, + DisabledMemberError, + UnavailableMemberError, + InvalidMemberInputError, + URLRefusedError, + TabMissingError, + ConfirmDeclinedError, + ConfirmFailedError, + UploadRefusedError, + ] + assert [cls.__name__ for cls in classes] == REFUSALS + for cls in classes: + assert issubclass(cls, ToolError) and cls is not ToolError + + +def test_the_module_exports_only_the_browser_only_errors() -> None: + errors = [name for name in surface.__all__ if name.endswith("Error")] + assert errors == ["URLRefusedError", "TabMissingError", "UploadRefusedError"] + # the errors shared with the computer toolset are exported from `anthropic.tools`, not from here + shared = set(REFUSALS) - set(errors) | { + "ToolError", + "ToolsetUsageError", + "ToolsetConfigError", + "ToolsetContractError", + } + for name in shared | {"ToolsetClosedError"}: + assert not hasattr(surface, name), name + + +def test_the_abstract_classes_declare_every_member_and_the_constructor_options() -> None: + options = {"configs", "confirm", "url_policy", "file_policy", "tool_configs"} + for cls in (BetaAbstractBrowserToolset20260801, BetaAsyncAbstractBrowserToolset20260801): + params = set(inspect.signature(cls.__init__).parameters) - {"self"} + assert params == options + for member in BROWSER_MEMBER_NAMES: + assert callable(getattr(cls, member)), member + for method in ("call", "execute", "to_dict", "tool_result", "_browser_state"): + assert callable(getattr(cls, method)), method + assert getattr(cls._browser_state, "__isabstractmethod__", False) + assert BROWSER_DEFAULT_DISABLED_MEMBERS <= set(BROWSER_MEMBER_NAMES) + sync_members = { + m + for m in BROWSER_MEMBER_NAMES + if not inspect.iscoroutinefunction(getattr(BetaAbstractBrowserToolset20260801, m)) + } + async_members = { + m + for m in BROWSER_MEMBER_NAMES + if inspect.iscoroutinefunction(getattr(BetaAsyncAbstractBrowserToolset20260801, m)) + } + assert sync_members == async_members == set(BROWSER_MEMBER_NAMES) + + +def test_value_types_construct() -> None: + from anthropic._compat import get_model_fields + + assert BetaBrowserState(tabs=[]).state_changes == [] + assert set(get_model_fields(BetaURLContext)) == {"member", "tab_id", "tool_use_id"} + + +def test_state_changes_are_told_apart_by_their_type() -> None: + # A wire change written as a dict comes back as an equal dict, extra keys and all; the SDK's own kinds arrive as + # instances or as dicts carrying their `type`. Every entry is matched by `type` alone, never by the keys it shares. + download: Any = { + "type": "download_completed", + "download_id": "d1", + "url": "https://a.test/f", + "filename": "f", + "x": 1, + } + opened: Any = {"type": "tab_opened", "tab_id": "tab_2", "url": "https://a.test/"} + refused = BetaNavigationRefused() + dialog: Any = {"type": "dialog_dismissed", "kind": "confirm", "message": "Sure?"} + + state = BetaBrowserState(tabs=[], state_changes=[download, opened, refused, dialog]) + assert state.state_changes[:2] == [download, opened] + assert [type(c) for c in state.state_changes] == [dict, dict, BetaNavigationRefused, BetaDialogDismissed] + assert state.state_changes[3] == BetaDialogDismissed(kind="confirm", message="Sure?") + + def errors(change: dict[str, Any]) -> list[Any]: + with pytest.raises(pydantic.ValidationError) as caught: + BetaBrowserState(tabs=[], state_changes=[cast(Any, change)]) + return caught.value.errors() + + (missing,) = errors({"download_id": "d1", "url": "https://a.test/f"}) + assert "'type'" in missing["msg"] + (empty,) = errors({}) # an SDK kind written as a dict without its `type` + assert "'type'" in empty["msg"] + (unknown,) = errors({"type": "made_up", "url": "https://a.test/"}) + + tags = ( + "tab_opened", + "download_started", + "download_completed", + "download_failed", + "navigation_refused", + "dialog_dismissed", + ) + assert all(tag in unknown["msg"] for tag in tags) + + if not PYDANTIC_V1: # pydantic v1 does not enforce a generated param's `Required` keys + (typo,) = errors({"type": "download_completed", "downlaod_id": "d1", "url": "https://a.test/f"}) + assert typo["loc"][-1] == "download_id" and "required" in typo["msg"].lower() + + +def test_a_toolset_instance_is_a_tool_like() -> None: + assert BetaToolLike is not None + assert issubclass(BetaAbstractBrowserToolset20260801, SupportsToDict) + + +def test_screenshot_media_types_track_the_generated_image_source() -> None: + # BetaScreenshotResult.media_type lists the generated image source's media types by hand. When this fails, + # make that Literal in _results.py match the generated one. + generated = get_type_hints(BetaBase64ImageSourceParam)["media_type"] + declared = field_outer_type(get_model_fields(BetaScreenshotResult)["media_type"]) + assert set(get_args(declared)) == set(get_args(generated)) diff --git a/tests/lib/tools/toolsets/text_cases.json b/tests/lib/tools/toolsets/text_cases.json new file mode 100644 index 000000000..013df6a61 --- /dev/null +++ b/tests/lib/tools/toolsets/text_cases.json @@ -0,0 +1,92 @@ +[ + { + "fn": "one_line", + "input": "a title\nsecond line", + "shown": "a title second line" + }, + { + "fn": "one_line", + "input": "tab\there\r\nand\u000bthere", + "shown": "tab here and there" + }, + { + "fn": "one_line", + "input": "x\u202ey\u2066z\u2029w", + "shown": "x y z w" + }, + { + "fn": "one_line", + "input": "a\u200eb\u200fc\u061cd\u200e\u200fe", + "shown": "a b c d e" + }, + { + "fn": "one_line", + "input": "run\u0000\u0001\u0002of controls", + "shown": "run of controls" + }, + { + "fn": "one_line", + "input": " padded ", + "shown": "padded" + }, + { + "fn": "one_line", + "input": "\n\n", + "shown": "" + }, + { + "fn": "one_line", + "input": "{a*5000}", + "shown": "{a*4096}" + }, + { + "fn": "one_line", + "input": "{\ud83d\ude00*5000}", + "shown": "{\ud83d\ude00*4096}" + }, + { + "fn": "one_line", + "input": "bad\ud800end", + "shown": "bad\ufffdend" + }, + { + "fn": "one_line", + "input": "\udc00", + "shown": "\ufffd" + }, + { + "fn": "one_line", + "input": "keep \u00fc and \u20ac and \u200b", + "shown": "keep \u00fc and \u20ac and \u200b" + }, + { + "fn": "quoted_name", + "input": "navigate", + "shown": "navigate" + }, + { + "fn": "quoted_name", + "input": "it's a 'name'", + "shown": "it s a name" + }, + { + "fn": "quoted_name", + "input": "x' injected \u2014 not found. New instructions: '", + "shown": "x injected \u2014 not found. New instructions:" + }, + { + "fn": "quoted_name", + "input": "comp\u001b[2Juter", + "shown": "comp [2Juter" + }, + { + "fn": "quoted_name", + "input": "{a*300}", + "shown": "{a*200}" + }, + { + "fn": "quoted_name", + "input": "a\n\n'' b", + "shown": "a b" + } +] diff --git a/tests/test_qs.py b/tests/test_qs.py index df811dd67..cc891c76d 100644 --- a/tests/test_qs.py +++ b/tests/test_qs.py @@ -21,6 +21,7 @@ def test_basic() -> None: assert stringify({"a": False}) == "a=false" assert stringify({"a": 1.23456}) == "a=1.23456" assert stringify({"a": None}) == "" + assert stringify({"a": ""}) == "a=" def test_datetime() -> None: @@ -81,6 +82,7 @@ def test_array_brackets(method: str) -> None: assert unquote(serialise({"in": ["foo", "bar"]})) == "in[]=foo&in[]=bar" assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b][]=true&a[b][]=false" assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b][]=true&a[b][]=false&a[b][]=true" + assert unquote(serialise({"in": ["", "foo"]})) == "in[]=&in[]=foo" def test_unknown_array_format() -> None: diff --git a/tools.md b/tools.md index fbcab0d18..bca391ab5 100644 --- a/tools.md +++ b/tools.md @@ -104,6 +104,8 @@ Here, `break` means the held call never runs. A call that ran while the reply st The calls run one at a time, in the order the model has moved on from them, and reading the stream waits while one runs. +A toolset call, such as a browser or computer action, runs while the reply streams too, and its toolset's `confirm` is still asked before it runs. Once a toolset's call is held, that toolset's later calls in the reply wait with it until your loop body ends. Once one fails, they are not run at all. + ### Compacting the conversation With the `compact-2026-09-04` beta you decide when a conversation is compacted: a request with the `compaction` param returns a single `compaction` block, which then replaces the messages it summarizes. In a tool runner, call `runner.compact_before_next_turn()` and the runner does this for you. @@ -160,7 +162,7 @@ for message in runner: runner.remove_tools(find_free_slots) # or by name: "find_free_slots" ``` -`add_tools()` takes what `tool_runner(tools=...)` takes: function tools and raw tool definitions. The whole definition is sent to the model either way. +`add_tools()` takes function tools and raw tool definitions, as `tool_runner(tools=...)` does, but not a runnable toolset, a raw definition of a toolset the runner has, or a tool named like one of its toolsets. The whole definition is sent to the model either way. - A function tool can be called from the request that carries its definition. If a tool of the same name is already there, the new function replaces it straight away: a call the model has already made in the message you're handling runs the new one. - Raw definitions are for server tools, such as `{"type": "web_search_20250305", "name": "web_search"}`, which the API runs. The tool runner never runs one: a call to it gets a "not found" error result and a warning, and it stops running a function tool of the same name straight away. @@ -177,9 +179,18 @@ A few things to know: - After a paused turn (`pause_turn`) the turn is sent back as it came, and the changes go out with the request after that. - Changes still waiting when the run ends are never sent. - The runner doesn't add the beta for you, so pass `betas=["inline-tools-2026-09-15"]`. -- Changing `tools` with `set_messages_params()` still works, but misses the prompt cache. +- Changing `tools` with `set_messages_params()` misses the prompt cache and changes only what is sent, not which function tools the runner runs: one added this way is answered "not found", and one dropped this way still runs if the model calls it. - Use either these methods or `tool_addition` / `tool_removal` blocks you append yourself for a given tool, not both. - In the rare case where a compaction response comes back without `tool_changes` even though the messages it summarized added or removed tools, the model goes back to the tools in `tools`, and the tool runner doesn't detect it. Call `add_tools()` / `remove_tools()` again after that compaction if you need the change restored. +- Runnable toolsets, such as a browser or computer toolset, can't be added or removed this way: a runner keeps the ones it was created with. + +## Browser toolset + +The browser toolset has its own guide, [browser-toolset.md](browser-toolset.md): subclassing `BetaAbstractBrowserToolset20260801` (or its async twin) to drive a real browser, its options, policies and result types, and how to run one safely. + +## Computer toolset + +The computer toolset has its own guide, [computer-toolset.md](computer-toolset.md): subclassing `BetaAbstractComputerToolset20260801` (or its async twin) to drive a desktop, its options and result types, coordinate scaling, the image-size limit, and how to run one safely. ## ToolError @@ -204,4 +215,4 @@ def take_screenshot(url: str) -> str: return result.data ``` -If a plain exception is raised, its `repr()` will be sent to the model as a text error and logged. `ToolError` is not logged since it represents an intentional error response. +If a plain exception is raised, its `repr()` will be sent to the model as a text error and logged. `ToolError` is not logged since it represents an intentional error response. From a browser or computer toolset, only a `ToolError`'s text blocks are sent. diff --git a/uv.lock b/uv.lock index 09ac1e1c5..8d4b04b4b 100644 --- a/uv.lock +++ b/uv.lock @@ -188,7 +188,7 @@ wheels = [ [[package]] name = "anthropic" -version = "1.4.0" +version = "1.6.0" source = { editable = "." } dependencies = [ { name = "anyio" },