diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 7ca1877..0000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - pull_request: - types: [opened, synchronize] - -permissions: - contents: read - pull-requests: write - issues: write - id-token: write - -jobs: - # Responds to @claude mentions in PRs and issues - claude-interactive: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # Automatic code review on every PR - claude-review: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - prompt: | - Review this PR. Focus on: - - Code quality and correctness - - Test coverage for new/changed functionality - - Documentation accuracy (CHANGELOG, README, USAGE) - - Breaking changes that need MIGRATION.md updates - - Security issues (hardcoded secrets, injection, etc.) - - This is a Speakeasy-generated Python SDK. Files under src/youdotcom/ - are mostly auto-generated — focus review on: - - tests/ (manually maintained) - - CHANGELOG.md, MIGRATION.md (manually maintained) - - overlays/python_overlay.yaml (manually maintained) - - pyproject.toml version/dependency changes - - Use inline comments for specific issues and post a summary comment. - claude_args: | - --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" diff --git a/.github/workflows/droid-review.yml b/.github/workflows/droid-review.yml new file mode 100644 index 0000000..0618932 --- /dev/null +++ b/.github/workflows/droid-review.yml @@ -0,0 +1,84 @@ +name: Droid Auto Review + +on: + pull_request: + types: [opened, ready_for_review, reopened, synchronize] + workflow_dispatch: + inputs: + pr_number: + description: "Pull request number to review (for external contributors)" + required: true + type: number + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pr_number }} + cancel-in-progress: true + +jobs: + droid-review: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + steps: + - name: Check if internal contributor + id: check_internal + if: github.event_name == 'pull_request' + run: | + RESPONSE=$(gh api repos/${{ github.repository }}/collaborators/${{ github.event.pull_request.user.login }}/permission 2>/dev/null || echo '{"permission":"none"}') + PERMISSION=$(echo "$RESPONSE" | jq -r '.permission') + echo "Author permission: $PERMISSION" + + if [[ "$PERMISSION" != "admin" && "$PERMISSION" != "maintain" && "$PERMISSION" != "write" ]]; then + echo "Skipping auto-review for external contributor (permission: $PERMISSION)" + echo "Use 'Actions > Droid Auto Review > Run workflow' to manually trigger review" + echo "is_internal=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Author is internal contributor, proceeding with review" + echo "is_internal=true" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ github.token }} + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Droid Auto Review (internal) + if: github.event_name == 'pull_request' && steps.check_internal.outputs.is_internal == 'true' + uses: Factory-AI/droid-action@v5 + with: + factory_api_key: ${{ secrets.FACTORY_API_KEY }} + automatic_review: true + automatic_security_review: true + allowed_bots: "claude[bot],github-advanced-security[bot]" + + - name: Checkout PR branch + if: github.event_name == 'workflow_dispatch' + run: gh pr checkout "$PR_NUMBER" + env: + PR_NUMBER: ${{ inputs.pr_number }} + GH_TOKEN: ${{ github.token }} + + - name: Install Droid CLI + if: github.event_name == 'workflow_dispatch' + run: | + # Official Factory CLI installer from app.factory.ai + # TODO: pin to a specific version with checksum verification + curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://app.factory.ai/cli | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Run Droid Review (external) + if: github.event_name == 'workflow_dispatch' + run: | + droid exec --auto high "/review $GITHUB_REPOSITORY/pull/$PR_NUMBER" + env: + PR_NUMBER: ${{ inputs.pr_number }} + GITHUB_REPOSITORY: ${{ github.repository }} + FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/generate-sdk.yml b/.github/workflows/generate-sdk.yml deleted file mode 100644 index a73d0cb..0000000 --- a/.github/workflows/generate-sdk.yml +++ /dev/null @@ -1,167 +0,0 @@ -name: Generate SDK Release - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to release (e.g. 2.4.0)' - required: true - type: string - issues: - types: [labeled] - -permissions: - contents: write - pull-requests: write - issues: write - id-token: write - -jobs: - generate-sdk: - if: | - github.event_name == 'workflow_dispatch' || - (github.event_name == 'issues' && github.event.label.name == 'sdk-release') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Parse inputs - id: parse - env: - EVENT_NAME: ${{ github.event_name }} - DISPATCH_VERSION: ${{ inputs.version }} - ISSUE_BODY: ${{ github.event.issue.body }} - run: | - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - echo "version=$DISPATCH_VERSION" >> "$GITHUB_OUTPUT" - echo "use_custom_specs=false" >> "$GITHUB_OUTPUT" - echo "custom_unified_agents=" >> "$GITHUB_OUTPUT" - echo "custom_search_v1=" >> "$GITHUB_OUTPUT" - echo "custom_contents=" >> "$GITHUB_OUTPUT" - echo "custom_base=" >> "$GITHUB_OUTPUT" - else - # Parse version from issue body (### Version section) - version=$(echo "$ISSUE_BODY" | sed -n '/### Version/{n;/^$/d;p;}' | head -1 | xargs) - echo "version=$version" >> "$GITHUB_OUTPUT" - - # Parse which specs to override from the dropdown - overrides=$(echo "$ISSUE_BODY" | sed -n '/### Which specs to override/,/### /{/### Which specs to override/d;/### /d;p;}' | grep -v '^$' | head -1) - - # Parse each custom spec textarea - has_custom=false - - unified=$(echo "$ISSUE_BODY" | sed -n '/### Custom spec: unified agents/,/### /{/### Custom spec: unified agents/d;/### /d;p;}' | sed '/^$/d') - if [ -n "$unified" ] && [ "$unified" != "_No response_" ]; then - has_custom=true - # Write to file for multiline support - echo "$unified" > /tmp/custom_unified_agents.yaml - echo "custom_unified_agents=/tmp/custom_unified_agents.yaml" >> "$GITHUB_OUTPUT" - else - echo "custom_unified_agents=" >> "$GITHUB_OUTPUT" - fi - - search=$(echo "$ISSUE_BODY" | sed -n '/### Custom spec: search v1/,/### /{/### Custom spec: search v1/d;/### /d;p;}' | sed '/^$/d') - if [ -n "$search" ] && [ "$search" != "_No response_" ]; then - has_custom=true - echo "$search" > /tmp/custom_search_v1.yaml - echo "custom_search_v1=/tmp/custom_search_v1.yaml" >> "$GITHUB_OUTPUT" - else - echo "custom_search_v1=" >> "$GITHUB_OUTPUT" - fi - - contents=$(echo "$ISSUE_BODY" | sed -n '/### Custom spec: contents/,/### /{/### Custom spec: contents/d;/### /d;p;}' | sed '/^$/d') - if [ -n "$contents" ] && [ "$contents" != "_No response_" ]; then - has_custom=true - echo "$contents" > /tmp/custom_contents.yaml - echo "custom_contents=/tmp/custom_contents.yaml" >> "$GITHUB_OUTPUT" - else - echo "custom_contents=" >> "$GITHUB_OUTPUT" - fi - - base=$(echo "$ISSUE_BODY" | sed -n '/### Custom spec: base/,/### \|$/{/### Custom spec: base/d;/### /d;p;}' | sed '/^$/d') - if [ -n "$base" ] && [ "$base" != "_No response_" ]; then - has_custom=true - echo "$base" > /tmp/custom_base.yaml - echo "custom_base=/tmp/custom_base.yaml" >> "$GITHUB_OUTPUT" - else - echo "custom_base=" >> "$GITHUB_OUTPUT" - fi - - echo "use_custom_specs=$has_custom" >> "$GITHUB_OUTPUT" - fi - - - uses: anthropics/claude-code-action@v1 - id: claude - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - allowed_tools: 'Bash,Read,Write,Edit,Glob,Grep' - prompt: | - You are running a non-interactive SDK release workflow. All inputs have been provided — do NOT use AskUserQuestion at any point. - - ## Inputs - - - **Version**: ${{ steps.parse.outputs.version }} - - **Use custom specs**: ${{ steps.parse.outputs.use_custom_specs }} - - **Custom spec file (unified agents)**: ${{ steps.parse.outputs.custom_unified_agents }} - - **Custom spec file (search v1)**: ${{ steps.parse.outputs.custom_search_v1 }} - - **Custom spec file (contents)**: ${{ steps.parse.outputs.custom_contents }} - - **Custom spec file (base)**: ${{ steps.parse.outputs.custom_base }} - - ## Instructions - - Follow the generate-sdk-and-open-pr skill in `.agents/skills/generate-sdk-and-open-pr/SKILL.md`, but skip all `AskUserQuestion` steps — the inputs above replace those interactions. Specifically: - - ### Step 1: Handle custom specs - - If "Use custom specs" is `true`, check which custom spec file paths are non-empty. For each non-empty path, read the file and update `.speakeasy/workflow.yaml` to point to that local file instead of the remote URL. Map: - - `custom_unified_agents` → replaces `https://you.com/specs/openapi_unified_agents.yaml` - - `custom_search_v1` → replaces `https://you.com/specs/openapi_search_v1.yaml` - - `custom_contents` → replaces `https://you.com/specs/openapi_contents.yaml` - - `custom_base` → replaces `https://you.com/specs/openapi_base.yaml` - - Leave specs with empty paths unchanged (they keep the remote URL). - - ### Step 2: Check current versions - - Run the version checks from the skill (git tags, gh release, PyPI, local files). Log the findings but do not ask for confirmation — proceed automatically. - - ### Step 3: Use the provided version - - The version is `${{ steps.parse.outputs.version }}`. Do not suggest alternatives — use this version directly. - - ### Step 4: Generate and release - - Follow steps 4b through 4j from the skill exactly: - - `speakeasy bump -v ${{ steps.parse.outputs.version }} -t you` - - `speakeasy run` - - Revert `.speakeasy/workflow.yaml` if custom specs were used - - Create branch `release/${{ steps.parse.outputs.version }}` - - Update versions in pyproject.toml and _version.py - - Update CHANGELOG.md, MIGRATION.md (if major), verify USAGE.md and README.md - - Update and run tests: `pytest tests/ --ignore=tests/test_live.py --ignore=tests/test_performance.py -v` - - Fix any test failures and re-run until clean - - Commit: `git add -A && git commit -m "feat: Python SDK ${{ steps.parse.outputs.version }}"` - - Push: `git push -u origin release/${{ steps.parse.outputs.version }}` - - Open PR: `gh pr create --title "Python SDK ${{ steps.parse.outputs.version }}" --base main` - - Include a structured PR body with Summary, Changes (from changelog), and Checklist. - - - name: Comment on issue and close - if: github.event_name == 'issues' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - VERSION: ${{ steps.parse.outputs.version }} - run: | - # Find the PR that was just created - pr_url=$(gh pr list --head "release/$VERSION" --json url --jq '.[0].url' 2>/dev/null || echo "") - - if [ -n "$pr_url" ]; then - gh issue comment "$ISSUE_NUMBER" --body "SDK release workflow completed. PR opened: $pr_url" - else - gh issue comment "$ISSUE_NUMBER" --body "SDK release workflow completed for version $VERSION. Check the Actions tab for details." - fi - - gh issue close "$ISSUE_NUMBER" diff --git a/.speakeasy/gen.lock b/.speakeasy/gen.lock deleted file mode 100644 index daa41f9..0000000 --- a/.speakeasy/gen.lock +++ /dev/null @@ -1,1312 +0,0 @@ -lockVersion: 2.0.0 -id: 77dbe0f5-bd71-4808-8102-230d4ffc4dc7 -management: - docChecksum: cd3c67b635e9f7d6c4e55617b4c76ec5 - docVersion: 1.0.0 - speakeasyVersion: 1.790.1 - generationVersion: 2.918.1 - releaseVersion: 2.4.0 - configChecksum: f04ae295df2090a834f287fc214a8067 - published: true -persistentEdits: - generation_id: 64917d79-e255-4e1d-b074-402dde71a2d6 - pristine_commit_hash: bf3b64524407653d41fcc0c93eefa75ef3d323e8 - pristine_tree_hash: 7956e2e2381c359635676fa8cb305c969d3eb7a4 -features: - python: - acceptHeaders: 3.0.0 - additionalDependencies: 1.1.0 - constsAndDefaults: 1.0.7 - core: 6.0.34 - defaultEnabledRetries: 0.2.0 - enumUnions: 0.1.1 - envVarSecurityUsage: 0.3.3 - flatRequests: 1.0.1 - flattening: 3.1.1 - globalSecurity: 3.0.7 - globalSecurityCallbacks: 1.0.0 - globalSecurityFlattening: 1.0.0 - globalServerURLs: 3.2.1 - methodArguments: 1.1.1 - methodServerURLs: 3.1.2 - nameOverrides: 3.0.3 - nullables: 1.0.2 - responseFormat: 1.1.0 - retries: 3.0.7 - sdkHooks: 1.3.0 - serverEvents: 1.1.1 - unions: 3.1.7 -trackedFiles: - .gitattributes: - id: 24139dae6567 - last_write_checksum: sha1:53134de3ada576f37c22276901e1b5b6d85cd2da - pristine_git_object: 4d75d59008e4d8609876d263419a9dc56c8d6f3a - .vscode/settings.json: - id: 89aa447020cd - last_write_checksum: sha1:f84632c81029fcdda8c3b0c768d02b836fc80526 - pristine_git_object: 8d79f0abb72526f1fb34a4c03e5bba612c6ba2ae - USAGE.md: - id: 3aed33ce6e6f - last_write_checksum: sha1:d882f687d690476f7ce13e0e6078cdb535e901b8 - pristine_git_object: 428c2a1b6f1b02f052d203cc674e8ab91f3b3e13 - docs/errors/agentruns400responseerror.md: - id: 4a255bc34dbc - last_write_checksum: sha1:52fd0795019408368d8d17c72c7dafe6267e7799 - pristine_git_object: e4e48bfdf33cf196d41064183b12e6d1e3edb231 - docs/errors/agentruns401responseerror.md: - id: 1f61b7b587ab - last_write_checksum: sha1:58285864fcc3859fee369a8858d7ee696e7c9492 - pristine_git_object: 7f02021e7673d81dab9a6dc59f620b71c6f8a5a3 - docs/errors/agentruns422responseerror.md: - id: 563424bde43f - last_write_checksum: sha1:d6fa9395601c34917e0c7bf80ec8378641d89ce9 - pristine_git_object: de23c318d560e32c2015128e590348d52d84dce5 - docs/errors/contentsforbiddenerror.md: - id: f29faf0667d2 - last_write_checksum: sha1:3a863233031f25949057e9e258e41a55965dfb6e - pristine_git_object: 81d690f0cd0f42cbe2db676a45ee59db84def43f - docs/errors/contentsinternalservererror.md: - id: 6f136fea0383 - last_write_checksum: sha1:89b8b80416518f036c8825ed773e4b092a7dde00 - pristine_git_object: 8dabd744caf24f9ce90fab0d8bb3f5ecdfeb7e44 - docs/errors/contentsunauthorizederror.md: - id: c673d1fd50f2 - last_write_checksum: sha1:c3aae77709d87098770406f004c19a27ef930e83 - pristine_git_object: 486b384d58421877ad73fa963a812c8cc83b5f1c - docs/errors/financeresearchforbiddenerror.md: - id: 3ec77ec3445d - last_write_checksum: sha1:34ebb641f33b71361bb5fcb83084f6c71a7ae80d - pristine_git_object: 9e6c3a96816aae16f8bc001ecfff328ca2dc84ac - docs/errors/financeresearchinternalservererror.md: - id: 67bb5aabbed5 - last_write_checksum: sha1:91621cc9156f585fce7c5542e453494652671c12 - pristine_git_object: 0556209616512b5d47ede4930d7d18aa5755059e - docs/errors/financeresearchunauthorizederror.md: - id: 44e3826db8d5 - last_write_checksum: sha1:5d6cf8136cfdcf5ada57360c432f59dc7ab0b96d - pristine_git_object: 2cf887047a9fa831d708594edfda92a01e6180f1 - docs/errors/financeresearchunprocessableentityerror.md: - id: dd6a93d0cd72 - last_write_checksum: sha1:4c7916bbde62fa9bf524a72ae0f0ff6895c5ff19 - pristine_git_object: 470b417ef83776af139e293573137a9fa02ebfa9 - docs/errors/forbiddenresponseerror.md: - id: 7decb227f487 - last_write_checksum: sha1:2704ca01ccd03ee1de3814613057dbac5d662203 - pristine_git_object: 5d49673dfa18a1a6fb21a5c4412d4daaf486e315 - docs/errors/internalservererrorresponse.md: - id: f9d2a251e010 - last_write_checksum: sha1:6699c675081cfa78d7b154a90d02271965b63548 - pristine_git_object: b99d7b0942e2efbe3e202f3ffc92a74f35cd580a - docs/errors/researchforbiddenerror.md: - id: 38294d725501 - last_write_checksum: sha1:6a2a0e75dc6ba751562e90bbb85fda2dda20ff60 - pristine_git_object: 9f18fd00d61656f4db977f5adf3ff2274700f20d - docs/errors/researchinternalservererror.md: - id: e140fbce184f - last_write_checksum: sha1:66f12319d49979b07d18489f5fdefad610ea42f6 - pristine_git_object: 5576cc81dab7ef5b597c8c71d79497b5c7cff741 - docs/errors/researchunauthorizederror.md: - id: 90348ea8ad14 - last_write_checksum: sha1:8948e2a9d19578d31dc9556b3a498208f6eaea1e - pristine_git_object: 9516b1732d6641bf0077ceee9c044ef662d456c8 - docs/errors/researchunprocessableentityerror.md: - id: 04be881a410c - last_write_checksum: sha1:5b8d4a7da29c0081ce83e2117b1964e369a68da0 - pristine_git_object: 603a7fdb346007af5fafb87bdfc8847bb0021a68 - docs/errors/unauthorizedresponseerror.md: - id: 500194829c27 - last_write_checksum: sha1:29d332afa85c2b7a4d2a8a955eb24010944433ca - pristine_git_object: fe026600dfac122697e638bb3613de731389f638 - docs/errors/unprocessableentityresponseerror.md: - id: cc0e37e6516e - last_write_checksum: sha1:783bebd4bf3453a0e67d63c16e6b9b1f0647631f - pristine_git_object: 5d4449977cb77c2f2265ca82a85f5b6013a22c56 - docs/models/advancedagentrunsrequest.md: - id: 1b4e175934b4 - last_write_checksum: sha1:73a48e950bb5313ee31f36c8bafff77c22078861 - pristine_git_object: 596bc50cef57e8402f45539d69327682a047f7e1 - docs/models/agentrunsbatchresponse.md: - id: 04584f262eed - last_write_checksum: sha1:b99416099fb3ddf30186ef638f2e8d39e2c562c6 - pristine_git_object: 3fe03a1507c1261432e8ec3479debddf57829833 - docs/models/agentrunsresponseoutput.md: - id: 628cb50c13b9 - last_write_checksum: sha1:b7fe4c0c6d2ea03dd892fc9621b49beb2462cc4d - pristine_git_object: b7f92254fcd6c1e10543ef9dba14a33c5310658b - docs/models/agentrunsresponsewebsearchresult.md: - id: 4a4bd85222f8 - last_write_checksum: sha1:204d00c488738a71f8cde4c91470bb64035928fd - pristine_git_object: 95bff3e274cd35e68da02fde008f62a0e7926fe2 - docs/models/agentrunsstreamingresponse.md: - id: 421588ccf00d - last_write_checksum: sha1:b435f704376b951318496e35b1e669ea565c5b88 - pristine_git_object: 897ae070ceece782a22f5d00b1672bb021cc43f2 - docs/models/agentsrunsrequest.md: - id: f6a0996a3515 - last_write_checksum: sha1:e4922f53b8691df22059216a1306473b6f721750 - pristine_git_object: 9140c34e22951697ee7c5c253d5122cb205b2d2c - docs/models/agentsrunsresponse.md: - id: 161c35469119 - last_write_checksum: sha1:517124f639e3d7baf45eaaa389c572dcd85e1fc8 - pristine_git_object: 22dfbc929b78486f693671db7d5ae6297f24e6cf - docs/models/computetool.md: - id: 57c817282ef7 - last_write_checksum: sha1:e0ebc2c82bc98687d9d3257dff8244e4d1023735 - pristine_git_object: 5c8b09feed16bdcfcae523839b5d4c602486e3a7 - docs/models/content.md: - id: bfd859c99f86 - last_write_checksum: sha1:54c87c754d78aff61c87cf09af3f428ba201afd1 - pristine_git_object: db56453ade1815af763b61c52527f1d8d7ef43ab - docs/models/contents.md: - id: da6e46f8e038 - last_write_checksum: sha1:954a223de149c3d7164949ca0783d8ff4616dcdc - pristine_git_object: a19143ec48165b836977bc9621ab61c11a52c337 - docs/models/contentsformats.md: - id: afdc3b11d819 - last_write_checksum: sha1:9276989fa981477f876146dc5b0c76482c1a41d4 - pristine_git_object: 8510c9af12a7d962caaf2decdd8cf313179ff018 - docs/models/contentsmetadata.md: - id: 9240f2df8fbf - last_write_checksum: sha1:2e8998401af96080a89aa177135011e8f79be878 - pristine_git_object: 6050bc0a757b2ea380ae2b1aaffc789588b44fa0 - docs/models/contentsrequest.md: - id: 9e0c7be98068 - last_write_checksum: sha1:59d78e99358f76e745850cd29b15fd3fcc3ac1ac - pristine_git_object: 847c157730d5d9723d7d4c1fb6b261cd2f73faee - docs/models/contentsresponse.md: - id: 3be584b2a8cf - last_write_checksum: sha1:357146cd86137bee392eed939594c1630937b377 - pristine_git_object: 7e81c7aa44ebb89cd11fc8ed4e75ad0802496589 - docs/models/contenttype.md: - id: 78e9266f4216 - last_write_checksum: sha1:682fb2676600386c8cfb21c1f547d332daa5bf6d - pristine_git_object: 02de4620daff095cebd82650b6c9a37858e3f9e0 - docs/models/country.md: - id: a9be7df1a5df - last_write_checksum: sha1:bf2348c1ad242cd575be6fac84df7d152b75e9af - pristine_git_object: 1bee618806380b9c459faf7df54bd13e832794e7 - docs/models/customagentrunsrequest.md: - id: 985d655f63bc - last_write_checksum: sha1:35757db39efe4a2d68133956ccc14f54a918c561 - pristine_git_object: d9b662531a8203e9eb0f945ac68a14826e3b292f - docs/models/data.md: - id: 9a31987caf78 - last_write_checksum: sha1:1eb699f2bd70920cba1b26690b8793660552286d - pristine_git_object: caff37e6f4704da440c4236b1e7ab1f1b303f91a - docs/models/detail.md: - id: bfd4e327e742 - last_write_checksum: sha1:32727a92e089117e460fad79af339fe8115a74f4 - pristine_git_object: c7ea282bc255e6185466b5769d0e4d83529090db - docs/models/expressagentrunsrequest.md: - id: 103f294a3f64 - last_write_checksum: sha1:bca1ea4c189cc504768a8eed46e36b2904880bdf - pristine_git_object: 271ad1a06da7cc2c5a02c2dcf14091aceaba5fe6 - docs/models/financeresearchcontenttype.md: - id: 251f6709d8fc - last_write_checksum: sha1:4d70e9c48d21c05af08f4ee62023ebe72082f163 - pristine_git_object: eba2d2dc19b8d28b8e5c2a2a703a3bb9cf505fc5 - docs/models/financeresearchdetail.md: - id: da1ea14e56eb - last_write_checksum: sha1:036693ffc8ad02df30ab9f8e63b9b7e2f723c329 - pristine_git_object: ca5a772467f315e72b89d028a80b2dbc5ff9f44b - docs/models/financeresearcheffort.md: - id: 3c7a91a84321 - last_write_checksum: sha1:7f81d8b3d8600921bf4af1a896c2033faef904c4 - pristine_git_object: 9402be19e1f6b23276cdff3ca87188b191d38e52 - docs/models/financeresearchinput.md: - id: e76c0ad8325b - last_write_checksum: sha1:4e72ffd4b153c3f7ff581fa0c9b3a0c0147b1680 - pristine_git_object: c7fc55919d0214e51805b03f3672e6d347f9a83f - docs/models/financeresearchinputunion.md: - id: bd158ae5ecac - last_write_checksum: sha1:453a944df9da5f074deed38023d93d76a0f3228a - pristine_git_object: f6a582f3ecddb8575ab97e27c7f67523b2adf5f3 - docs/models/financeresearchloc.md: - id: 64c17038a446 - last_write_checksum: sha1:e09bf82953f70c283f3d07f90322f8ad5c6f127b - pristine_git_object: df5440326162e805099dc71d6f4e1bec21361c98 - docs/models/financeresearchoutput.md: - id: eca77ec865c2 - last_write_checksum: sha1:f60c7aea6df195364fef1e36c0fc4ea4e5e72641 - pristine_git_object: b6e352829b15f4137713cc3dde8273caad295fcd - docs/models/financeresearchrequest.md: - id: 4fa7a1dfd720 - last_write_checksum: sha1:9600c4303006d36d246f578294e8b494bdaef6c2 - pristine_git_object: 7342a4ffeedc6c5fa7cdb36a0cc95bd7f21d1156 - docs/models/financeresearchresponse.md: - id: 4d259e737b06 - last_write_checksum: sha1:7c9245e636fd48185b4837af1e3d7a77d00c3d2d - pristine_git_object: 7db9645ec29a80418b2734ef7fde43701ea70681 - docs/models/financeresearchsource.md: - id: ba4fa6623cd8 - last_write_checksum: sha1:b1f2807cbadb88c549f1971800883ea4de888624 - pristine_git_object: 90a8e2a8ed3d62ff906b2e699ec8cc3cc55fcc60 - docs/models/freshness.md: - id: 88acdb1a4cde - last_write_checksum: sha1:f55d65e3cd1f3a40eb8f43e64390112bec8674dd - pristine_git_object: 38377ef33d46910d8f1cd3d2d759d5b833bc37cb - docs/models/freshnessvalue.md: - id: a0ab09970422 - last_write_checksum: sha1:ff17988d9c2ce0ca2217247d227b7f6d1d454c7c - pristine_git_object: 4c5c0585ee55b19f51feea0f726761b3303fc694 - docs/models/input.md: - id: 5cbc446a3956 - last_write_checksum: sha1:f7672dc07961b1c7816e32b5d13ff699d4ea55ac - pristine_git_object: 54bf616d4a0b7923835d7825e283e911322e2171 - docs/models/language.md: - id: 5bac2bb42c7c - last_write_checksum: sha1:ccb522cc80826dbb4aa2583df981c3a30ad2d6f1 - pristine_git_object: b8d19c1c9f8a36551caa305ba70eb257e97b20df - docs/models/livecrawl.md: - id: 8f56dc6b7dc1 - last_write_checksum: sha1:262a55ae586cbf797d208ab335f3d5505a4c0f89 - pristine_git_object: c62c4ee26a56f1183589c591d0dbcbdacd08161d - docs/models/livecrawlformats.md: - id: e1f2ecc26149 - last_write_checksum: sha1:e4da6638be2cab806a029d2fd186a5dd2b5a4c48 - pristine_git_object: c2d0a6b5c3c6ba084aaf0fd105f4618483bb4c25 - docs/models/loc.md: - id: b071d5a509cc - last_write_checksum: sha1:09a04749333ab50ae806c3ac6adcaa90d54df0f1 - pristine_git_object: d6094ac2c6e0326c039dad2f6b89158694ef6aa7 - docs/models/newsresult.md: - id: 6faa8e42dd7f - last_write_checksum: sha1:bd70dd78f300f80a0ef8f64623657685a630e14a - pristine_git_object: 0aad594860f49247b39b19cc210080776c39ca45 - docs/models/output.md: - id: 376633b966cd - last_write_checksum: sha1:53e9d19946a69a0cae5b67727f440d0947a5935c - pristine_git_object: 29628bb679315670ad8bec24a41721b9dfb22ae6 - docs/models/reportverbosity.md: - id: d06de274a3c5 - last_write_checksum: sha1:9b7dce8c7da103c20041cc87e52de081401d245f - pristine_git_object: 7c3d17fc8487c0ebe15ea35e754895b2a8531f00 - docs/models/researchdetail.md: - id: ac4ed946dccb - last_write_checksum: sha1:221678d588868630e1e79891ab988589bf83f9d2 - pristine_git_object: 8ee1a3bb595931e3dfd4329607b08dabee51d4f5 - docs/models/researcheffort.md: - id: 5b67676468b4 - last_write_checksum: sha1:b5b71faf30e50d38cc54892aa94485ca8b434c42 - pristine_git_object: 27ca90da768f977245c5b117a6c9f4029ba73998 - docs/models/researchinput.md: - id: a5099da2ac37 - last_write_checksum: sha1:a2212309beac7493be856f4dcff2c08af5fd3c9e - pristine_git_object: 440a4440591c38081f66c1d9f37395b1b4faf89d - docs/models/researchinputunion.md: - id: 3e1e0afabaad - last_write_checksum: sha1:d1da5ad31f9be4625ba4d2403ade8c21278221ce - pristine_git_object: 182ea147f83ed413d2ac71d251c04ffc3da08762 - docs/models/researchloc.md: - id: 3aeace91cdd2 - last_write_checksum: sha1:2666811f4486af8652e691adb62b0481f4fb2692 - pristine_git_object: 54741e7b741e96ee1b130afa66523e61215aa21e - docs/models/researchrequest.md: - id: bf0af2378820 - last_write_checksum: sha1:8efcc90ecf1c0039ac9e8698f0bd4481278d6ade - pristine_git_object: 6a08aa1a3b2a49b5bb4f0a1b61e7dda23fc12183 - docs/models/researchresponse.md: - id: e2ac4b94e0e5 - last_write_checksum: sha1:d0519db309441b605c339c6e3ec58c71b3857843 - pristine_git_object: e46094cc2c26f206fe0b884056ec8d1610c106dd - docs/models/researchtool.md: - id: 68f310f2701f - last_write_checksum: sha1:d5f85e978e3fb30fb80a8a362b0f8decf2a020fd - pristine_git_object: b4ede4276d7978b45bb6e6ecd23b71dd61c64600 - docs/models/responsecreated.md: - id: 367a02a3603b - last_write_checksum: sha1:7d4508ce78de9795b329564c89d498f02fe54c20 - pristine_git_object: 4e10ecca440ceb7935272761cd3872c934752a0c - docs/models/responsedone.md: - id: 021495f3139d - last_write_checksum: sha1:f2b0459fb238bbbb86a84c7d4df5a866d1892d99 - pristine_git_object: 6010f6bb2a11f427574dab6f3b29334b598cd524 - docs/models/responsedoneresponse.md: - id: 1bee90c808d7 - last_write_checksum: sha1:60799ed98b3c9562de26c4e388b777d84947cc27 - pristine_git_object: a702eabd81894b456c62031e26f33381f0235945 - docs/models/responseoutputcontentfull.md: - id: c2124663e312 - last_write_checksum: sha1:cf1e8342c2066b6f7042e2863651ea4d3f1181f1 - pristine_git_object: 03514433211cab18f56ecc0ea4030185ad8bb510 - docs/models/responseoutputcontentfullresponse.md: - id: b3f56988e304 - last_write_checksum: sha1:a4c2dbe9e01f3d0fc5c4a22270a9f633abc0b4f0 - pristine_git_object: 3391d4f4db84952825f79c29affacf596c1372a0 - docs/models/responseoutputitemadded.md: - id: 0b502df74939 - last_write_checksum: sha1:4fe3c83ee9b43b9be7ce0f726a200ffc24755005 - pristine_git_object: 7eae6248548a151463c7be361e18374ba57c797a - docs/models/responseoutputitemaddedresponse.md: - id: 8db1d25923bd - last_write_checksum: sha1:0119f378cdee24ef6ac1420c2affed55b79462f7 - pristine_git_object: 4acf2143d4eacf2b49539d2ffb6a9ffe031fd388 - docs/models/responseoutputitemdone.md: - id: a2303f075518 - last_write_checksum: sha1:578099c234e607610cf9129c3e19a9fd7ad549cc - pristine_git_object: 382af8750414faf6ca76f7a40e129137b0f8cf4c - docs/models/responseoutputitemdoneresponse.md: - id: 1decbf43cd71 - last_write_checksum: sha1:30e0e0b2991169f9278d60097bfb325af9ed8c70 - pristine_git_object: 9d2c570ad8ab051b42252bff8a8f621d16bdd796 - docs/models/responseoutputtextdelta.md: - id: 013ccf043599 - last_write_checksum: sha1:751de12f3118aef8a88261095838e49bb8cfe59e - pristine_git_object: b584c1a3f1f48a472e4bf1ef1767bff4f0846229 - docs/models/responseoutputtextdeltaresponse.md: - id: 15503bf62515 - last_write_checksum: sha1:2cdaacd50a5be662b803516687bde22dc39f855e - pristine_git_object: 431cd0a3829d36ff77db8fb957078e7ba5cabbc7 - docs/models/responsestarting.md: - id: e315ded134b4 - last_write_checksum: sha1:326a9f1e5a3413b94c6656df55c7fc580231c2b8 - pristine_git_object: 5d468de3e2eddb033379b1d482fd8dd094bdb491 - docs/models/results.md: - id: c3a733d7c7d3 - last_write_checksum: sha1:c66d85da84634b1a4cab5dcb60101f86a5ce90b9 - pristine_git_object: 342f0ea7f84a9f91517333400a3bd5b3a6df6593 - docs/models/role.md: - id: b694540a5b1e - last_write_checksum: sha1:d92cc3fcf9deaa2505304aa0b28a361993ba7df2 - pristine_git_object: 1f861cc27d1fd4b1b9a4020eb6dadcd0c53d2c57 - docs/models/safesearch.md: - id: 03e75d7f2e0d - last_write_checksum: sha1:2c1d5ff1fa333a417ca8c1224163b53eb44b9a28 - pristine_git_object: 9efddcb711df36aa570dc30cbf40492b2b8f747f - docs/models/searcheffort.md: - id: d518e1b065c1 - last_write_checksum: sha1:2f8259825f919cd9e5f4522d660f22547cd1c132 - pristine_git_object: 6f1c1ed8866231412ba7b38262ef17d92967ed09 - docs/models/searchmetadata.md: - id: 7fab78f275a5 - last_write_checksum: sha1:54940e0966c1feadafd666525700f988985b4e3c - pristine_git_object: 01105636a662e21e977fbaf3f04e189a0aafae5f - docs/models/searchrequest.md: - id: bc36c5e5aee1 - last_write_checksum: sha1:6c52337bf506fb50f2d130503a58a445860129ac - pristine_git_object: 3c07e4c98ceab5467d99acf5a1f5e05297d1f13a - docs/models/searchrequestbody.md: - id: d4e2dd80df2d - last_write_checksum: sha1:55c3281697e2230a70072aa02a55198f028642c7 - pristine_git_object: 82a042803a7a6e53f0c75573f97176d7edb67ce8 - docs/models/searchresponse.md: - id: d5606b4d403f - last_write_checksum: sha1:bf8f309cc2f4d9a1236e1cc8afbfc3900861d2a3 - pristine_git_object: 0a0dcb77858a45a169e986cb0cd2b870db63acd7 - docs/models/security.md: - id: 452e4d4eb67a - last_write_checksum: sha1:71fdd7bbeb4eccce70c9b87e9631d708571e0f17 - pristine_git_object: 0346db068b3d37366beee9c844a3643d6a332cee - docs/models/source.md: - id: 6541ef7b41e7 - last_write_checksum: sha1:483698db3a1c935665a55f062761e2250173f813 - pristine_git_object: 174794d3a4732ea91a88d0b9b8d058127fc62f27 - docs/models/sourcecontrol.md: - id: 1da814573b6a - last_write_checksum: sha1:9158bddc57eaa41434172f0e0b3fc8f4ffc2f056 - pristine_git_object: cc42a853efacaf1a5168830b37f688661b367c5a - docs/models/tool.md: - id: 8966139dbeed - last_write_checksum: sha1:75d2ca9e2e2b19ceaa89db32c055b290709edc5a - pristine_git_object: bb6d45112ef7ab37ea8cfab1f85eb32e9954679d - docs/models/type.md: - id: 98c32f09b2c8 - last_write_checksum: sha1:d65a0a63069409574549340807ed0bbc2fc0be91 - pristine_git_object: 31b2152c9a0fe6cc1acfb95300c42c693e004a0f - docs/models/utils/retryconfig.md: - id: 4343ac43161c - last_write_checksum: sha1:562c0f21e308ad10c27f85f75704c15592c6929d - pristine_git_object: 69dd549ec7f5f885101d08dd502e25748183aebf - docs/models/verbosity.md: - id: a29d18f70f3d - last_write_checksum: sha1:bf8d880a60a36b0230d73c4883f8c6c945a5b5a8 - pristine_git_object: 35bfe62e1f58576bb086eb4566cca60afad89ffc - docs/models/webresult.md: - id: 8a203dd46ef9 - last_write_checksum: sha1:04c21830cc109bbce380a3193577a73b966c8212 - pristine_git_object: efac9dfaa8c5a97905db8f136a7ea043e3db865a - docs/models/websearchtool.md: - id: fc4df52fb9b5 - last_write_checksum: sha1:3dd3b260252f363f9c6ae617fd38288bd007fad1 - pristine_git_object: 5cdccb407b16a36d44a50144f03b08add890d077 - docs/models/workflowconfig.md: - id: f001930acbb7 - last_write_checksum: sha1:63c40c6883be0a1af95095db6cf0e018e2fdca26 - pristine_git_object: b8a67d378796378511ff5969bc86f6c6406f3576 - docs/sdks/contentssdk/README.md: - id: 7ca17aeff32d - last_write_checksum: sha1:d9f338b11de3f55a458f91aeb72529ed44fbce79 - pristine_git_object: 3fc51bcb135deac43542ec0b6a01325768318776 - docs/sdks/runs/README.md: - id: 4598fd39b715 - last_write_checksum: sha1:42773cd1f5a922aad84df2549cdfd52a435460f4 - pristine_git_object: a653b912b254879a88ad52fcde0f68e9f077288e - docs/sdks/search/README.md: - id: 5c534716244c - last_write_checksum: sha1:4fa9985c4cddc602d3f2508a7a0a4c6c07b7c299 - pristine_git_object: 7b1acd043305e94aeb58c3f68abb1875a4c4cb31 - docs/sdks/you/README.md: - id: 1abb954b0afb - last_write_checksum: sha1:3464d19d44289858273c43645e08ae31f561213a - pristine_git_object: e7efc81630b4e74a30875a6ff0fa654083f0eaab - py.typed: - id: 258c3ed47ae4 - last_write_checksum: sha1:8efc425ffe830805ffcc0f3055871bdcdc542c60 - pristine_git_object: 3e38f1a929f7d6b1d6de74604aa87e3d8f010544 - pylintrc: - id: 7ce8b9f946e6 - last_write_checksum: sha1:b08ef8689f6474e400ebbe9c8baf7d7b9fae3a73 - pristine_git_object: f456032107a9387ba6c98afd1c981df2f4b3d636 - pyproject.toml: - id: 5d07e7d72637 - last_write_checksum: sha1:c231ed86f5f161447f9838a5491c7a1729a54f8f - pristine_git_object: 84a36dcf42ba5df5d829604647bdb3ca66008185 - scripts/publish.sh: - id: fe273b08f514 - last_write_checksum: sha1:adc9b741c12ad1591ab4870eabe20f0d0a86cd1a - pristine_git_object: ef28dc10c60d7d6a4bac0c6a1e9caba36b471861 - src/youdotcom/__init__.py: - id: de52fa8763cc - last_write_checksum: sha1:da077c0bdfcef64a4a5aea91a17292f72fa2b088 - pristine_git_object: 833c68cd526fe34aab2b7e7c45f974f7f4b9e120 - src/youdotcom/_hooks/__init__.py: - id: a370ae86d681 - last_write_checksum: sha1:e3111289afd28ad557c21d9e2f918caabfb7037d - pristine_git_object: 2ee66cdd592fe41731c24ddd407c8ca31c50aec1 - src/youdotcom/_hooks/sdkhooks.py: - id: ae69916cdcf0 - last_write_checksum: sha1:178db1da49608906b8160dfa958a508b303de3b0 - pristine_git_object: 48d0589d2a8c91e461bf078223e50797256c6125 - src/youdotcom/_hooks/types.py: - id: 0b21d2f9c319 - last_write_checksum: sha1:2b04fb1ed665dcd77bb36a68db682daa2723bf86 - pristine_git_object: 2b03ad3cd42aa4614839ded8cdb336383c9b8713 - src/youdotcom/_version.py: - id: 5224f82ecc7b - last_write_checksum: sha1:e0ec3974211a1e2ba2dd64460c710bb2aaff8f28 - pristine_git_object: 5233a7d1b49558616850f63701437453ebad23f7 - src/youdotcom/agents.py: - id: 0ec0f4c4e0d0 - last_write_checksum: sha1:4b58c15455f5410f050cfc8be831bfb6401d68ad - pristine_git_object: 9090364d995f43bed128ecf046863c697691fd83 - src/youdotcom/basesdk.py: - id: c1c9ef882178 - last_write_checksum: sha1:403b2306c57c2fb84c5ee7048eaf168d573bdae4 - pristine_git_object: 8665d7f872dae803866e57bda2cc9288fb8d5fe0 - src/youdotcom/contents_sdk.py: - id: 0684c08251f6 - last_write_checksum: sha1:6e1927f6fa39fa8a866ddb909ba64aec9cdde2c8 - pristine_git_object: e7ce6138c8d3f5c9fb1be8963388917ae2c7b31d - src/youdotcom/errors/__init__.py: - id: e7ee44aa2c0f - last_write_checksum: sha1:82f2a29f37730c2e493b37cdbfe15712907b7560 - pristine_git_object: fe2e187546aa01d1a29b9194d02641709ba3dfb1 - src/youdotcom/errors/agentruns400response_error.py: - id: 6e04ce5f87f1 - last_write_checksum: sha1:3d8694955e3799f0c762f605074d403b8b2203ab - pristine_git_object: bce533b275ec1159ac16e22627d156bbc8e1518e - src/youdotcom/errors/agentruns401response_error.py: - id: bcc5cc460d23 - last_write_checksum: sha1:2dfb81c09ef52427d9f5bb7216e84ab7f07d5d0e - pristine_git_object: 50df6789ace55831331ed2c5c1411c545ab4da48 - src/youdotcom/errors/agentruns422response_error.py: - id: 5a29847fc4cf - last_write_checksum: sha1:b0a78b35b2b86d8ce0d226e6dd89470d56b230f4 - pristine_git_object: c86c94af3ec33f7c7c7d167a25e0dd3a13775206 - src/youdotcom/errors/contentsop.py: - id: 92163cd72b73 - last_write_checksum: sha1:0589479e94a35d68deac9064f098ce0569b3e36d - pristine_git_object: 443fd304ff07e7623911e094281ab2f1661843f1 - src/youdotcom/errors/finance_researchop.py: - id: 83beaa919a2b - last_write_checksum: sha1:de34cbe163c5b9dad85b5bf88f2870e4d9956dd1 - pristine_git_object: a42f34dbe086f2af1e4fbfbd5a9184037646ca1f - src/youdotcom/errors/forbidden_response_error.py: - id: 777fa6546b44 - last_write_checksum: sha1:afe4203758b823712ebd8c8be02fdd7649e94c64 - pristine_git_object: 575dded2c86c4c8fd55107c2dda8a09a92de0908 - src/youdotcom/errors/internalservererror_response.py: - id: 7b3a4b21c280 - last_write_checksum: sha1:a82788befcb10990f3271d8b9b461956990cd78d - pristine_git_object: 4a8c1a8ff1c1b6906af38e19b492a98636ed46bb - src/youdotcom/errors/no_response_error.py: - id: 9f953dc697cf - last_write_checksum: sha1:7f326424a7d5ae1bcd5c89a0d6b3dbda9138942f - pristine_git_object: 1deab64bc43e1e65bf3c412d326a4032ce342366 - src/youdotcom/errors/researchop.py: - id: 8143ca635d3f - last_write_checksum: sha1:696a48d30e1e40cd8fc2ad162524107f0ab1dc2c - pristine_git_object: a64bebbb06286b54a281d95d5573ccf6c27dfb91 - src/youdotcom/errors/responsevalidationerror.py: - id: 0ad5034298b3 - last_write_checksum: sha1:f95060059297e22c183f9e387df44eb03aac1f5c - pristine_git_object: 8e3bb217198ec204c2c92aa0c3f1aa92ce1ec5c3 - src/youdotcom/errors/unauthorized_response_error.py: - id: 7f23fe11fee3 - last_write_checksum: sha1:8fffa1bbbae4188098c085f1fd8e2374abb47a78 - pristine_git_object: dbc2f9f28572fbbb2dbbaad3c9cd2c387bd969a4 - src/youdotcom/errors/unprocessableentity_response_error.py: - id: d4a6fd9c273f - last_write_checksum: sha1:5a25ee76ff251002f745a4fd23eb0735e11903dd - pristine_git_object: f9153650c749efc975429515a53eb321b2e42225 - src/youdotcom/errors/youdefaulterror.py: - id: 4a5d0619a409 - last_write_checksum: sha1:1b4ccd64f7c7845f589d1a43735da0a0ea186459 - pristine_git_object: 795cc568b9ae7252d38ad478db8e5845dd7a0646 - src/youdotcom/errors/youerror.py: - id: 69bdecea2a9e - last_write_checksum: sha1:6234f40bf5cc32147457ab27a9cae9dc4dbe39a0 - pristine_git_object: cedd8c0f87c42dd4d2577b7149aa5871938ac659 - src/youdotcom/httpclient.py: - id: b97c4e6cfb19 - last_write_checksum: sha1:5e55338d6ee9f01ab648cad4380201a8a3da7dd7 - pristine_git_object: 89560b566073785535643e694c112bedbd3db13d - src/youdotcom/models/__init__.py: - id: ad350e4fd8c1 - last_write_checksum: sha1:1b8f3c2193ca69271ba0267eab4b6fddd8e3c54c - pristine_git_object: 0aa0f8bf1db191ced6521819957e37c778ed77ae - src/youdotcom/models/advancedagentrunsrequest.py: - id: 6bb8d5dd67d4 - last_write_checksum: sha1:ac336ef380378a65f90a9b70dba67b2e2db26401 - pristine_git_object: 8511c0aebc0698e8aad0bee3b9766271eb65f447 - src/youdotcom/models/agentruns422response_error.py: - id: 6731cdd29afd - last_write_checksum: sha1:e9c70fe90257d4f3a93ea7d730e4d7f662dbd7bb - pristine_git_object: 41240562660e2a4dd1ee4823ae31e843ce51c056 - src/youdotcom/models/agentrunsbatchresponse.py: - id: 38fe9f202ccb - last_write_checksum: sha1:ade03547dd8f3c2ac9872fc325126fcc03cddbd9 - pristine_git_object: df8614a864c6960c97fecf7e40b3ad2e4c78729a - src/youdotcom/models/agentrunsresponseoutput.py: - id: 6ac5478f43e0 - last_write_checksum: sha1:bb2f18b91a61766c5c050214e98999e8cc0f111d - pristine_git_object: 1eaa631665b302a4aea097bf859bc07727e2c335 - src/youdotcom/models/agentrunsresponsewebsearchresult.py: - id: e85bdd982508 - last_write_checksum: sha1:dfe451543399d856efea36949e16d0cebdef9299 - pristine_git_object: 7f9d3c3ae17aecd76f7f5c4f408979ed8f5b2feb - src/youdotcom/models/agentrunsstreamingresponse.py: - id: 1423c3f03bf9 - last_write_checksum: sha1:0fff2d6e0785c7744a50455853c309f7ca609fd5 - pristine_git_object: 1a2c119e6ecee3f1116cc98968a9cc86cdde503e - src/youdotcom/models/agentsrunsop.py: - id: b0d55d74eb29 - last_write_checksum: sha1:7b2b7d6c14bb77b25efc2ddc29e9336d04aed473 - pristine_git_object: 52a57d5de789822fbeb65ad45b7273c6402ea1d2 - src/youdotcom/models/computetool.py: - id: 5353a2de6f97 - last_write_checksum: sha1:4c65a3868a85ac0ca4a08e8ab10f1dc34bf7f364 - pristine_git_object: 12538817aaf1efe50c87ee29995f423b1c89567c - src/youdotcom/models/contents.py: - id: c1d5af212a4c - last_write_checksum: sha1:b7a585d24a3635704779bafc6141f811268cd499 - pristine_git_object: 37d69b8fd439a008b0a9459819fc35135b03f572 - src/youdotcom/models/contentsformats.py: - id: 0d5f457da03c - last_write_checksum: sha1:c226d046ba051044bde0ba070143fcc2cabe5ed8 - pristine_git_object: 91f16e0e9d599e1a9c4b819ac25ac07a8246cb4c - src/youdotcom/models/contentsmetadata.py: - id: 4e49905aae0b - last_write_checksum: sha1:5dc844d2c2bf653caff438b7f4ee1eadf37e0ced - pristine_git_object: b6b8e50f233dedf42297e1f52cce6b2430ffb833 - src/youdotcom/models/contentsop.py: - id: ca42ef875c5f - last_write_checksum: sha1:28dee18e3e275608363bf81b38210df548a3a958 - pristine_git_object: 9e102924a02d1165ca1b3849f88657a9032065be - src/youdotcom/models/country.py: - id: 725d2a57cc07 - last_write_checksum: sha1:7827b3e65afd2ee86078e45ac373cedfe5d68766 - pristine_git_object: 720e60691d5f653cae80488c00e54063953596e8 - src/youdotcom/models/customagentrunsrequest.py: - id: 089bb3a5b607 - last_write_checksum: sha1:745881c56a61fc877f3a5f28b6a69faf5663a945 - pristine_git_object: 181e560c7a144fe28f84bbcc3d35d8ba67d5ce0e - src/youdotcom/models/expressagentrunsrequest.py: - id: 0f698f43a90d - last_write_checksum: sha1:44ac13388d585adceb28c1164d852ddb15550327 - pristine_git_object: 8211741b2403722bb0dff06a7b31b29918dd544d - src/youdotcom/models/finance_researchop.py: - id: 581b6926206d - last_write_checksum: sha1:add5e681c935ccee9eb22808c85d7dd9eb4e28af - pristine_git_object: 1d886fde93e4f319ebc62167440ca558bef75a18 - src/youdotcom/models/financeresearcheffort.py: - id: 493d242448a3 - last_write_checksum: sha1:a4e4ea1e984849bc3ea3eb52697387e1320aa2ac - pristine_git_object: 417d8052d4135f81767efc9e9349f71cd2b3c4d8 - src/youdotcom/models/freshness.py: - id: c7c960c20e5e - last_write_checksum: sha1:9ed62b1edeac55bd86aa69d3092a6864046487ff - pristine_git_object: 83281eb315fc1d2675302c34c15f81d408fd462f - src/youdotcom/models/freshnessvalue.py: - id: 53cb2e2fc925 - last_write_checksum: sha1:1408b2d4e83ed5cf93f15da95797b6c44ea00b2a - pristine_git_object: 66ff7788852bd6ff23f2d4941e2fffb436740b30 - src/youdotcom/models/language.py: - id: 4e51e1ee857d - last_write_checksum: sha1:6fb833b3b60ce6c233218377a550303b3daece8a - pristine_git_object: 83704f11b3829b3776d6551d059209d9c6c76858 - src/youdotcom/models/livecrawl.py: - id: e5dd4948ff3f - last_write_checksum: sha1:f2b60aa9d84b622f961f81f781574f958c247953 - pristine_git_object: c28464e9c8784e91c7f1e8e2ea417220040eaf58 - src/youdotcom/models/livecrawlformats.py: - id: 775d02437b81 - last_write_checksum: sha1:09894aa858c80f89c322d61c378ee033f3e9aef1 - pristine_git_object: ceca02dde497a61488a00d1312ece701f6f7ce37 - src/youdotcom/models/newsresult.py: - id: daffe8db4b1b - last_write_checksum: sha1:d8a48605207bf6cbd4a143c3d5acd5bdf492f89e - pristine_git_object: 9a892957291c73bfde00e33cdfd3c19e4428035f - src/youdotcom/models/reportverbosity.py: - id: 5a8683f42b91 - last_write_checksum: sha1:b7c084407a5584d770deb61970d4953825a3bd2c - pristine_git_object: 14a8b432a575bc4c291723e98f2586beae4f5939 - src/youdotcom/models/researcheffort.py: - id: 24a4c58a1aa5 - last_write_checksum: sha1:8bd93d9e73d30e730759c5a8fcecd16282563268 - pristine_git_object: 2384195f5785456082cbb4cf279bbcce118ff0e1 - src/youdotcom/models/researchop.py: - id: c1ae2c3f13d9 - last_write_checksum: sha1:7f0302e741abe214926447c89c3ccb4d268fe493 - pristine_git_object: 15231bac26d522e52ae43a3745ff1aff41475de4 - src/youdotcom/models/researchresponse.py: - id: 4d52580e1e41 - last_write_checksum: sha1:35aef8078002601b96600b4d8225a85c1ffdbb23 - pristine_git_object: 8e0ef6c2da0b28e960797ecd1a2a0b3153637cc9 - src/youdotcom/models/researchtool.py: - id: 4e0236b1b670 - last_write_checksum: sha1:e90b83ae9dbf18da27bbbf2db81fbd18a16cb4d1 - pristine_git_object: 445f60cce6ef58e3c0da474ac55da3e298ae114b - src/youdotcom/models/response_created.py: - id: ec9dee8aac4e - last_write_checksum: sha1:a4b321cd48f8409910efc27a1bd145f1de254dc7 - pristine_git_object: 509085d15b21cb96bebd088785f773555d029759 - src/youdotcom/models/response_done.py: - id: 436337f20c7e - last_write_checksum: sha1:93b52e55c42a41453c2047d62d6e783624fb831b - pristine_git_object: 599f8a7d5a7f8a4a917d0b3fa1dc2b18a1f9f199 - src/youdotcom/models/response_output_content_full.py: - id: 7f5d8f4dd4fb - last_write_checksum: sha1:b1cb42414e2f43af2491431da5e513db6c2d1702 - pristine_git_object: b2bd70cecbd90d17b2f90f83cb1e0e83a328f1b7 - src/youdotcom/models/response_output_item_added.py: - id: 190e4d2047f4 - last_write_checksum: sha1:b5e09d2685f2197d1f08717fddd1705e5a291a5c - pristine_git_object: 72949626cb9a3422ae999ea6dcee0cefdc2f205c - src/youdotcom/models/response_output_item_done.py: - id: 7a086b15ec74 - last_write_checksum: sha1:088dc34f175c26df4cda6b122148f1de3e3095a5 - pristine_git_object: 54f4cbcfdd897d72c82f926ad627f2200d3530ba - src/youdotcom/models/response_output_text_delta.py: - id: a77d5296fe54 - last_write_checksum: sha1:8010dd2b2a35d13f0174d80a06fb549d469645e6 - pristine_git_object: 36aa5a4f36e4d781a5113b0a339a474f4c4b1a09 - src/youdotcom/models/response_starting.py: - id: ba22f359a5db - last_write_checksum: sha1:282c112ecc2b6343c5b34fc9af6f3ede1b6b8489 - pristine_git_object: 438a1913bc2391b0d972e30461d0361a7134cb66 - src/youdotcom/models/safesearch.py: - id: ad014425b7f3 - last_write_checksum: sha1:bda9731af9142b81be895fca26e2499e30edbc1b - pristine_git_object: 5a64287d13adf6df6ef7e140239bf76fd413ff2c - src/youdotcom/models/searcheffort.py: - id: 8864bba8ca75 - last_write_checksum: sha1:8709eb40bec8635ce221ddfad0a3abc8ee3b400d - pristine_git_object: cec092e4f820d3dfd8d09f8b7bc224c418b8fd49 - src/youdotcom/models/searchmetadata.py: - id: de041a4286e9 - last_write_checksum: sha1:1adb9cdd44100896aa387cd1f995cb86994dc34f - pristine_git_object: 758b0c2297d21b7c483fd164f3778dc9506e5718 - src/youdotcom/models/searchop.py: - id: 525c0a4e8872 - last_write_checksum: sha1:c17146d3f6f1ff1a75a1750c9050ffc53e4f301a - pristine_git_object: 358a4fa5770c26927df2b1876822b10580a881e7 - src/youdotcom/models/searchpostop.py: - id: 57d9a686a4d2 - last_write_checksum: sha1:393291e98f851ad723e1b239a69c0a045c38fc67 - pristine_git_object: e0f8b5b537deccd0bd4213f783dd502cf340597d - src/youdotcom/models/searchrequestbody.py: - id: e36cf1876cdd - last_write_checksum: sha1:3a6820ed3a4fa6d14011f0def1043a0524ff5ae1 - pristine_git_object: df94c4c545951a34a0eb8b58a8139593395ee546 - src/youdotcom/models/searchresponse.py: - id: 777112b1d670 - last_write_checksum: sha1:8c597f06af6b2f1d47ae564d1a462b6dadf636ac - pristine_git_object: ee9313ea8ad7ca83082a1c7e2b726ef7fcef750f - src/youdotcom/models/security.py: - id: 3a94d17768c4 - last_write_checksum: sha1:f55a74714c74d2f19f22b23fd8c798be81828804 - pristine_git_object: 2313b5207ca434956401f8f32265c71790396370 - src/youdotcom/models/verbosity.py: - id: 68768141a514 - last_write_checksum: sha1:ba9d4ee35bed37f14d67ca27f38cb0efc44eb132 - pristine_git_object: 666d75aaa5f5ab22a259d8fe1aa08f599c79e104 - src/youdotcom/models/webresult.py: - id: 4f4f8e119dbe - last_write_checksum: sha1:160bda43c198787655edcc6b031824488929b183 - pristine_git_object: d8f6f6c49923e82a46eb6d077d309f20d2eb0c96 - src/youdotcom/models/websearchtool.py: - id: 8240ffdc3807 - last_write_checksum: sha1:44338571c786fbf9377a82ca0e0ff57a26472e66 - pristine_git_object: 7834ec3a54a74c67a575b3c2987299989f07aac7 - src/youdotcom/py.typed: - id: 90f76277734b - last_write_checksum: sha1:8efc425ffe830805ffcc0f3055871bdcdc542c60 - pristine_git_object: 3e38f1a929f7d6b1d6de74604aa87e3d8f010544 - src/youdotcom/runs.py: - id: 8011c1ffa5a1 - last_write_checksum: sha1:ceffbd7959577edb2d8f3b23d565567fbb008e97 - pristine_git_object: 0c63569f3630825cea41a82c0429fda5496b41ef - src/youdotcom/sdk.py: - id: 90954e74e7b7 - last_write_checksum: sha1:dcced052664fa8e4454f797a1d3a55678e8a4a83 - pristine_git_object: 5d95cb97a0c5b01aa1af3294612f81cc9b296aaa - src/youdotcom/sdkconfiguration.py: - id: eb56427350d9 - last_write_checksum: sha1:f62ffed1bbe732f18d96076c622fccf3eb8c2f6c - pristine_git_object: ea49a89ef4840777202f1440fbf116e2f63d7a56 - src/youdotcom/search.py: - id: 5e475f9db47f - last_write_checksum: sha1:9176b010d442d11586bc42b493974e297984509d - pristine_git_object: 9e69fa0416a9ed0e8583977fde7d1901dfdd13ba - src/youdotcom/types/__init__.py: - id: 5e0774b59bbc - last_write_checksum: sha1:f9ad14217f832e74f594285960125add50324be9 - pristine_git_object: faa268137bc01c9d08cfadc4797017db48747a96 - src/youdotcom/types/base64fileinput.py: - id: 7a5db9c7c0dd - last_write_checksum: sha1:1522687ae3398374c35710cad993a6e82b5ab99d - pristine_git_object: 862566fe2b1db830276b390e136e65090e5963d2 - src/youdotcom/types/basemodel.py: - id: cbf130c761e5 - last_write_checksum: sha1:10d84aedeb9d35edfdadf2c3020caa1d24d8b584 - pristine_git_object: a9a640a1a7048736383f96c67c6290c86bf536ee - src/youdotcom/utils/__init__.py: - id: bd4acfd8ce6a - last_write_checksum: sha1:527db7a6c93948ecdec6a33f56b6551ae44f0180 - pristine_git_object: c48a36ca3d6e47e384b133bc0bcb482c6d8589b9 - src/youdotcom/utils/annotations.py: - id: bc5c21b7250e - last_write_checksum: sha1:a4824ad65f730303e4e1e3ec1febf87b4eb46dbc - pristine_git_object: 12e0aa4f1151bb52474cc02e88397329b90703f6 - src/youdotcom/utils/datetimes.py: - id: 2b6c0dd73070 - last_write_checksum: sha1:fa47d54c549ff9b95496ce91930fadfb04a36be9 - pristine_git_object: adad24762cf452d137abe94fda0fd26224cef593 - src/youdotcom/utils/dynamic_imports.py: - id: 09b830ea97a1 - last_write_checksum: sha1:a1940c63feb8eddfd8026de53384baf5056d5dcc - pristine_git_object: 673edf82a97d0fea7295625d3e092ea369a36b79 - src/youdotcom/utils/enums.py: - id: 6e8be91b2dd2 - last_write_checksum: sha1:bc8c3c1285ae09ba8a094ee5c3d9c7f41fa1284d - pristine_git_object: 3324e1bc2668c54c4d5f5a1a845675319757a828 - src/youdotcom/utils/eventstreaming.py: - id: 5f31d9da5a93 - last_write_checksum: sha1:7d1dc68f8b48486ab646653aa05cc38752e1f912 - pristine_git_object: a8d4fe5cc88d3c7337339e1b36a61bbf7ca8c4eb - src/youdotcom/utils/forms.py: - id: 1bf9b877c054 - last_write_checksum: sha1:dcf527960992b8baef3b21937c7e6c4e652630c0 - pristine_git_object: 193f264960f3014c81d41543b9023c14c44c12e6 - src/youdotcom/utils/headers.py: - id: f1171bccb6d8 - last_write_checksum: sha1:7c6df233ee006332b566a8afa9ce9a245941d935 - pristine_git_object: 37864cbbbc40d1a47112bbfdd3ba79568fc8818a - src/youdotcom/utils/logger.py: - id: e03b4d05c523 - last_write_checksum: sha1:c63d1633efc3bd6b0b479ac038b13e1bc6150fe1 - pristine_git_object: 6ae3abd220a08af699d685aa61dd4168df6dbcfa - src/youdotcom/utils/metadata.py: - id: f6d2fb72eae3 - last_write_checksum: sha1:e703e5cbb5255144aacf86898d1420529afaaff8 - pristine_git_object: 5abddd588837ac297050ca3b543627faadb350a9 - src/youdotcom/utils/queryparams.py: - id: 1340b8e3e103 - last_write_checksum: sha1:b94c3f314fd3da0d1d215afc2731f48748e2aa59 - pristine_git_object: c04e0db82b68eca041f2cb2614d748fbac80fd41 - src/youdotcom/utils/requestbodies.py: - id: 1276b4c43669 - last_write_checksum: sha1:e1fef575283b7fe7fe2ad392dbbb3fb105309124 - pristine_git_object: 591415af8e64baa410627b507d2740afb5387d13 - src/youdotcom/utils/retries.py: - id: 384c61cdc8f6 - last_write_checksum: sha1:3585b891142f30a597fbf7a2f0340700babef8e4 - pristine_git_object: ca7b59efebbbd9545744d0207ef42725c4cc5143 - src/youdotcom/utils/security.py: - id: 41e3b3176b50 - last_write_checksum: sha1:802ca60459e7a12b60dba8c2060dcdedd52fd1ee - pristine_git_object: cd67559004f44eaeec27d09183a6b7ce2fced666 - src/youdotcom/utils/serializers.py: - id: 360f8e2583cc - last_write_checksum: sha1:7485f1425b0661fd84836186570df90207eec6af - pristine_git_object: 1031ed930bad5ece220cf7416a56c29f40f0588b - src/youdotcom/utils/unmarshal_json_response.py: - id: ae5a7f2d9dc3 - last_write_checksum: sha1:aae3840de6b5894dcf8f167fd83b6b77294041ef - pristine_git_object: 131b392d5cc7e7fb1bb462a3717a4a408f202c65 - src/youdotcom/utils/url.py: - id: 593048ceb18a - last_write_checksum: sha1:6479961baa90432ca25626f8e40a7bbc32e73b41 - pristine_git_object: c78ccbae426ce6d385709d97ce0b1c2813ea2418 - src/youdotcom/utils/values.py: - id: 13f600b5fe36 - last_write_checksum: sha1:acaa178a7c41ddd000f58cc691e4632d925b2553 - pristine_git_object: dae01a44384ac3bc13ae07453a053bf6c898ebe3 -examples: - AgentsRuns: - express_batch: - requestBody: - application/json: {"agent": "express", "input": "What is the capital of France?", "stream": false} - responses: - "200": - application/json: {"agent": "express", "mode": "express", "input": [{"role": "user", "content": "What is the capital of France?"}], "output": []} - "400": - application/json: {"detail": "Invalid or expired API key"} - "401": - application/json: {"detail": "Invalid or expired API key"} - "422": - application/json: {"detail": [{"type": "model_attributes_type", "loc": ["body", "tools", 0], "msg": "Input should be a valid dictionary or object to extract fields from", "input": "web_search"}]} - express_stream: - requestBody: - application/json: {"agent": "express", "input": "What are some great recipes I can make in under half an hour", "stream": true, "tools": [{"type": "web_search"}]} - responses: - "200": - application/json: {"agent": "express", "mode": "express", "input": [{"role": "user", "content": "What is the capital of France?"}], "output": []} - "400": - application/json: {"detail": "Invalid or expired API key"} - "401": - application/json: {"detail": "Invalid or expired API key"} - "422": - application/json: {"detail": [{"type": "model_attributes_type", "loc": ["body", "tools", 0], "msg": "Input should be a valid dictionary or object to extract fields from", "input": "web_search"}]} - advanced_batch: - requestBody: - application/json: {"agent": "advanced", "input": "You are a biologist studying the impacts of microplastics. Explain what microplastics are to a group of engineers, explain the impacts of microplastics on the body, and what the common sources and dosages of microplastics are. Highlight what a safe dosage might be and how to achieve it", "stream": false, "tools": [{"type": "research", "search_effort": "auto", "report_verbosity": "medium"}]} - responses: - "200": - application/json: {"agent": "express", "mode": "express", "input": [{"role": "user", "content": "What is the capital of France?"}], "output": []} - "400": - application/json: {"detail": "Invalid or expired API key"} - "401": - application/json: {"detail": "Invalid or expired API key"} - "422": - application/json: {"detail": [{"type": "model_attributes_type", "loc": ["body", "tools", 0], "msg": "Input should be a valid dictionary or object to extract fields from", "input": "web_search"}]} - advanced_stream: - requestBody: - application/json: {"agent": "express", "input": "Analyze the economic impact of renewable energy adoption", "stream": true, "tools": [{"type": "web_search"}]} - responses: - "200": - application/json: {"agent": "express", "mode": "express", "input": [{"role": "user", "content": "What is the capital of France?"}], "output": []} - "400": - application/json: {"detail": "Invalid or expired API key"} - "401": - application/json: {"detail": "Invalid or expired API key"} - "422": - application/json: {"detail": [{"type": "model_attributes_type", "loc": ["body", "tools", 0], "msg": "Input should be a valid dictionary or object to extract fields from", "input": "web_search"}]} - custom_batch: - requestBody: - application/json: {"agent": "63773261-b4de-4d8f-9dfd-cff206a5cb51", "input": "What is the capital of France?", "stream": false} - responses: - "200": - application/json: {"agent": "express", "mode": "express", "input": [{"role": "user", "content": "What is the capital of France?"}], "output": []} - "400": - application/json: {"detail": "Invalid or expired API key"} - "401": - application/json: {"detail": "Invalid or expired API key"} - "422": - application/json: {"detail": [{"type": "model_attributes_type", "loc": ["body", "tools", 0], "msg": "Input should be a valid dictionary or object to extract fields from", "input": "web_search"}]} - custom_stream: - requestBody: - application/json: {"agent": "63773261-b4de-4d8f-9dfd-cff206a5cb51", "input": "Tell me about the history of Paris", "stream": true} - responses: - "200": - application/json: {"agent": "express", "mode": "express", "input": [{"role": "user", "content": "What is the capital of France?"}], "output": []} - "400": - application/json: {"detail": "Invalid or expired API key"} - "401": - application/json: {"detail": "Invalid or expired API key"} - "422": - application/json: {"detail": [{"type": "model_attributes_type", "loc": ["body", "tools", 0], "msg": "Input should be a valid dictionary or object to extract fields from", "input": "web_search"}]} - speakeasy-default-agents-runs: - requestBody: - application/json: {"agent": "63773261-b4de-4d8f-9dfd-cff206a5cb51", "input": "What are some insights about my data?", "stream": false} - responses: - "200": - application/json: {"agent": "express", "mode": "express", "input": [{"role": "user", "content": "What is the capital of France?"}], "output": []} - "400": - application/json: {"detail": "Invalid or expired API key"} - "401": - application/json: {"detail": "Invalid or expired API key"} - "422": - application/json: {"detail": [{"type": "model_attributes_type", "loc": ["body", "tools", 0], "msg": "Input should be a valid dictionary or object to extract fields from", "input": "web_search"}]} - search: - speakeasy-default-search: - parameters: - query: - query: "Your query" - language: "EN" - count: 10 - include_domains: "nytimes.com,bbc.com" - exclude_domains: "spam-site.com,other-site.com" - boost_domains: "nytimes.com,wired.com" - crawl_timeout: 10 - responses: - "200": - application/json: {"results": {"web": [{"url": "https://you.com", "title": "The World's Greatest Search Engine!", "description": "Search on YDC", "snippets": ["I'm an AI assistant that helps you get more done. What can I help you with?"], "thumbnail_url": "https://www.somethumbnailsite.com/thumbnail.jpg", "page_age": "2025-06-25T11:41:00", "authors": ["John Doe"], "favicon_url": "https://someurl.com/favicon"}], "news": [{"title": "Exclusive | You.com becomes the backbone of the EU's AI strategy", "description": "As the EU's AI strategy is being debated, You.com becomes the backbone of the EU's AI strategy.", "page_age": "2025-06-25T11:41:00", "thumbnail_url": "https://www.somethumbnailsite.com/thumbnail.jpg", "url": "https://www.you.com/news/eu-ai-strategy-youcom"}]}, "metadata": {"search_uuid": "942ccbdd-7705-4d9c-9d37-4ef386658e90", "query": "Your query", "latency": 0.123}} - "401": - application/json: {} - "403": - application/json: {} - "500": - application/json: {} - "422": - application/json: {} - missingApiKey: - parameters: - query: - query: "Your query" - language: "EN" - count: 10 - include_domains: "nytimes.com,bbc.com" - exclude_domains: "spam-site.com,other-site.com" - boost_domains: "nytimes.com,wired.com" - crawl_timeout: 10 - responses: - "401": - application/json: {"detail": "API key is required"} - invalidOrExpired: - parameters: - query: - query: "Your query" - language: "EN" - count: 10 - include_domains: "nytimes.com,bbc.com" - exclude_domains: "spam-site.com,other-site.com" - boost_domains: "nytimes.com,wired.com" - crawl_timeout: 10 - responses: - "401": - application/json: {"detail": "Invalid or expired API key"} - otherAuthParsing: - parameters: - query: - query: "Your query" - language: "EN" - count: 10 - include_domains: "nytimes.com,bbc.com" - exclude_domains: "spam-site.com,other-site.com" - boost_domains: "nytimes.com,wired.com" - crawl_timeout: 10 - responses: - "401": - application/json: {"detail": ""} - missingScopes: - parameters: - query: - query: "Your query" - language: "EN" - count: 10 - include_domains: "nytimes.com,bbc.com" - exclude_domains: "spam-site.com,other-site.com" - boost_domains: "nytimes.com,wired.com" - crawl_timeout: 10 - responses: - "403": - application/json: {"detail": "Missing required scopes"} - authFailure: - parameters: - query: - query: "Your query" - language: "EN" - count: 10 - include_domains: "nytimes.com,bbc.com" - exclude_domains: "spam-site.com,other-site.com" - boost_domains: "nytimes.com,wired.com" - crawl_timeout: 10 - responses: - "500": - application/json: {"detail": "Internal authentication error"} - authorizationFailure: - parameters: - query: - query: "Your query" - language: "EN" - count: 10 - include_domains: "nytimes.com,bbc.com" - exclude_domains: "spam-site.com,other-site.com" - boost_domains: "nytimes.com,wired.com" - crawl_timeout: 10 - responses: - "500": - application/json: {"detail": "Internal authorization error"} - invalidParams: - parameters: - query: - query: "What are the latest geopolitical updates from India" - count: 10 - language: "EN" - include_domains: "nytimes.com,bbc.com" - exclude_domains: "spam-site.com,other-site.com" - boost_domains: "nytimes.com,wired.com" - crawl_timeout: 10 - responses: - "422": - application/json: {"error": "invalid request parameter(s)"} - contents: - missingApiKey: - requestBody: - application/json: {"urls": ["https://www.you.com"], "formats": ["html", "markdown"], "crawl_timeout": 10, "max_age": 86400} - responses: - "401": - application/json: {"detail": "API key is required"} - invalidOrExpired: - requestBody: - application/json: {"urls": ["https://www.you.com"], "formats": ["html", "markdown"], "crawl_timeout": 10, "max_age": 86400} - responses: - "401": - application/json: {"detail": "Invalid or expired API key"} - otherAuthParsing: - requestBody: - application/json: {"urls": ["https://www.you.com"], "formats": ["html", "markdown"], "crawl_timeout": 10, "max_age": 86400} - responses: - "401": - application/json: {"detail": ""} - missingScopes: - requestBody: - application/json: {"urls": ["https://www.you.com"], "formats": ["html", "markdown"], "crawl_timeout": 10, "max_age": 86400} - responses: - "403": - application/json: {"detail": "Missing required scopes"} - authFailure: - requestBody: - application/json: {"urls": ["https://www.you.com"], "formats": ["html", "markdown"], "crawl_timeout": 10, "max_age": 86400} - responses: - "500": - application/json: {"detail": "Internal authentication error"} - authorizationFailure: - requestBody: - application/json: {"urls": ["https://www.you.com"], "formats": ["html", "markdown"], "crawl_timeout": 10, "max_age": 86400} - responses: - "500": - application/json: {"detail": "Internal authorization error"} - speakeasy-default-contents: - requestBody: - application/json: {"urls": ["https://www.you.com"], "formats": ["html", "markdown"], "crawl_timeout": 10, "max_age": 86400} - responses: - "200": - application/json: [{"url": "https://www.you.com", "title": "The best website in the world", "metadata": {"site_name": "You.com", "favicon_url": "https://api.ydc-index.io/favicon?domain=you.com&size=128"}}] - "401": - application/json: {} - "403": - application/json: {} - "500": - application/json: {} - research: - speakeasy-default-research: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "200": - application/json: {"output": {"content": "", "content_type": "text", "sources": []}} - "401": - application/json: {} - "403": - application/json: {} - "422": - application/json: {"detail": [{"type": "missing", "loc": ["body", "input"], "msg": "Field required", "input": ""}]} - "500": - application/json: {} - missingApiKey: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "401": - application/json: {"detail": "API key is required"} - invalidOrExpired: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "401": - application/json: {"detail": "Invalid or expired API key"} - otherAuthParsing: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "401": - application/json: {"detail": ""} - missingScopes: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "403": - application/json: {"detail": "Missing required scopes"} - missingField: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "422": - application/json: {"detail": [{"type": "missing", "loc": ["body", "input"], "msg": "Field required", "input": {}}]} - invalidEnum: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "422": - application/json: {"detail": [{"type": "enum", "loc": ["body", "research_effort"], "msg": "Input should be 'lite', 'standard', 'deep' or 'exhaustive'", "input": "invalid_value", "ctx": {"expected": "'lite', 'standard', 'deep' or 'exhaustive'"}}]} - stringTooLong: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "422": - application/json: {"detail": [{"type": "string_too_long", "loc": ["body", "input"], "msg": "String should have at most 40000 characters", "input": "", "ctx": {"max_length": 40000}}]} - invalidJson: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "422": - application/json: {"detail": [{"type": "json_invalid", "loc": ["body", 115], "msg": "JSON decode error", "input": {}, "ctx": {"error": "Invalid control character at"}}]} - authFailure: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "500": - application/json: {"detail": "Internal authentication error"} - authorizationFailure: - requestBody: - application/json: {"input": "", "research_effort": "standard"} - responses: - "500": - application/json: {"detail": "Internal authorization error"} - searchPost: - missingApiKey: - requestBody: - application/json: {"query": "What are the latest geopolitical updates from India", "count": 10, "language": "EN", "include_domains": ["nytimes.com", "bbc.com"], "exclude_domains": ["spam-site.com", "other-site.com"], "boost_domains": ["nytimes.com", "wired.com"], "crawl_timeout": 10} - responses: - "401": - application/json: {"detail": "API key is required"} - invalidOrExpired: - requestBody: - application/json: {"query": "What are the latest geopolitical updates from India", "count": 10, "language": "EN", "include_domains": ["nytimes.com", "bbc.com"], "exclude_domains": ["spam-site.com", "other-site.com"], "boost_domains": ["nytimes.com", "wired.com"], "crawl_timeout": 10} - responses: - "401": - application/json: {"detail": "Invalid or expired API key"} - otherAuthParsing: - requestBody: - application/json: {"query": "What are the latest geopolitical updates from India", "count": 10, "language": "EN", "include_domains": ["nytimes.com", "bbc.com"], "exclude_domains": ["spam-site.com", "other-site.com"], "boost_domains": ["nytimes.com", "wired.com"], "crawl_timeout": 10} - responses: - "401": - application/json: {"detail": ""} - missingScopes: - requestBody: - application/json: {"query": "What are the latest geopolitical updates from India", "count": 10, "language": "EN", "include_domains": ["nytimes.com", "bbc.com"], "exclude_domains": ["spam-site.com", "other-site.com"], "boost_domains": ["nytimes.com", "wired.com"], "crawl_timeout": 10} - responses: - "403": - application/json: {"detail": "Missing required scopes"} - invalidParams: - requestBody: - application/json: {"query": "What are the latest geopolitical updates from India", "count": 10, "language": "EN", "include_domains": ["nytimes.com", "bbc.com"], "exclude_domains": ["spam-site.com", "other-site.com"], "boost_domains": ["nytimes.com", "wired.com"], "crawl_timeout": 10} - responses: - "422": - application/json: {"error": "invalid request parameter(s)"} - authFailure: - requestBody: - application/json: {"query": "What are the latest geopolitical updates from India", "count": 10, "language": "EN", "include_domains": ["nytimes.com", "bbc.com"], "exclude_domains": ["spam-site.com", "other-site.com"], "boost_domains": ["nytimes.com", "wired.com"], "crawl_timeout": 10} - responses: - "500": - application/json: {"detail": "Internal authentication error"} - authorizationFailure: - requestBody: - application/json: {"query": "What are the latest geopolitical updates from India", "count": 10, "language": "EN", "include_domains": ["nytimes.com", "bbc.com"], "exclude_domains": ["spam-site.com", "other-site.com"], "boost_domains": ["nytimes.com", "wired.com"], "crawl_timeout": 10} - responses: - "500": - application/json: {"detail": "Internal authorization error"} - speakeasy-default-search-post: - requestBody: - application/json: {"query": "What are the latest geopolitical updates from India", "count": 10, "language": "EN", "include_domains": ["nytimes.com", "bbc.com"], "exclude_domains": ["spam-site.com", "other-site.com"], "boost_domains": ["nytimes.com", "wired.com"], "crawl_timeout": 10} - responses: - "200": - application/json: {"results": {"web": [{"url": "https://you.com", "title": "The World's Greatest Search Engine!", "description": "Search on YDC", "snippets": ["I'm an AI assistant that helps you get more done. What can I help you with?"], "thumbnail_url": "https://www.somethumbnailsite.com/thumbnail.jpg", "page_age": "2025-06-25T11:41:00", "authors": ["John Doe"], "favicon_url": "https://someurl.com/favicon"}], "news": [{"title": "Exclusive | You.com becomes the backbone of the EU's AI strategy", "description": "As the EU's AI strategy is being debated, You.com becomes the backbone of the EU's AI strategy.", "page_age": "2025-06-25T11:41:00", "thumbnail_url": "https://www.somethumbnailsite.com/thumbnail.jpg", "url": "https://www.you.com/news/eu-ai-strategy-youcom"}]}, "metadata": {"search_uuid": "942ccbdd-7705-4d9c-9d37-4ef386658e90", "query": "What are the latest geopolitical updates from India", "latency": 0.123}} - "401": - application/json: {} - "403": - application/json: {} - "422": - application/json: {} - "500": - application/json: {} - finance_research: - speakeasy-default-finance-research: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "200": - application/json: {"output": {"content": "For fiscal year 2025, ended January 26, 2025, NVIDIA's revenue rose to **$130.5 billion, up 114% year over year**.[[1]]\nThe main drivers were Data Center demand (up 142%), Compute & Networking growth (up 145%), and smaller contributions from Gaming, Professional Visualization, and Automotive.", "content_type": "text", "sources": [{"url": "https://investor.apple.com/sec-filings/annual-reports/default.aspx", "title": "Apple Inc. Annual Report FY2024 (Form 10-K)"}]}} - "401": - application/json: {} - "403": - application/json: {} - "422": - application/json: {"detail": [{"type": "missing", "loc": ["body", "input"], "msg": "Field required", "input": ""}]} - "500": - application/json: {} - missingApiKey: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "401": - application/json: {"detail": "API key is required"} - invalidOrExpired: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "401": - application/json: {"detail": "Invalid or expired API key"} - otherAuthParsing: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "401": - application/json: {"detail": ""} - missingScopes: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "403": - application/json: {"detail": "Missing required scopes"} - missingField: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "422": - application/json: {"detail": [{"type": "missing", "loc": ["body", "input"], "msg": "Field required", "input": {}}]} - invalidEnum: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "422": - application/json: {"detail": [{"type": "enum", "loc": ["body", "research_effort"], "msg": "Input should be 'deep' or 'exhaustive'", "input": "invalid_value", "ctx": {"expected": "'deep' or 'exhaustive'"}}]} - stringTooLong: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "422": - application/json: {"detail": [{"type": "string_too_long", "loc": ["body", "input"], "msg": "String should have at most 40000 characters", "input": "", "ctx": {"max_length": 40000}}]} - invalidJson: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "422": - application/json: {"detail": [{"type": "json_invalid", "loc": ["body", 115], "msg": "JSON decode error", "input": {}, "ctx": {"error": "Invalid control character at"}}]} - authFailure: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "500": - application/json: {"detail": "Internal authentication error"} - authorizationFailure: - requestBody: - application/json: {"input": "What were the key drivers of NVIDIA's revenue growth in fiscal year 2025?", "research_effort": "deep"} - responses: - "500": - application/json: {"detail": "Internal authorization error"} -examplesVersion: 1.0.2 -generatedTests: {} diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml deleted file mode 100644 index 48c79d3..0000000 --- a/.speakeasy/gen.yaml +++ /dev/null @@ -1,102 +0,0 @@ -configVersion: 2.0.0 -generation: - sdkClassName: You - maintainOpenAPIOrder: true - usageSnippets: - optionalPropertyRendering: withExample - sdkInitStyle: constructor - useClassNamesForArrayFields: true - fixes: - nameResolutionDec2023: true - nameResolutionFeb2025: true - parameterOrderingFeb2024: true - requestResponseComponentNamesFeb2024: true - securityFeb2025: true - sharedErrorComponentsApr2025: true - sharedNestedComponentsJan2026: false - nameOverrideFeb2026: false - auth: - oAuth2ClientCredentialsEnabled: true - oAuth2PasswordEnabled: true - hoistGlobalSecurity: true - inferSSEOverload: true - sdkHooksConfigAccess: true - schemas: - allOfMergeStrategy: shallowMerge - requestBodyFieldName: body - versioningStrategy: automatic - persistentEdits: {} - tests: - generateTests: true - generateNewTests: false - skipResponseBodyAssertions: false - docs: - examples: - - usage.md -python: - version: 2.4.0 - additionalDependencies: - dev: {} - main: {} - allowedRedefinedBuiltins: - - id - - object - - input - asyncMode: both - authors: - - You.com - baseErrorName: YouError - bodyVariantOverloads: false - clientServerStatusCodesAsErrors: true - constFieldCasing: upper - defaultErrorName: YouDefaultError - description: The official You.com Python SDK. - durationFormat: false - enableCustomCodeRegions: false - enumFormat: enum - envVarPrefix: YOU - errorSchemaValidation: true - eventStreamClassNames: - async: EventStreamAsync - sync: EventStream - fixFlags: - asyncPaginationSep2025: true - conflictResistantModelImportsFeb2026: false - responseRequiredSep2024: true - flattenGlobalSecurity: true - flattenRequests: true - flatteningOrder: parameters-first - forwardCompatibleEnumsByDefault: false - forwardCompatibleUnionsByDefault: "false" - imports: - option: openapi - paths: - callbacks: "" - errors: errors - operations: "" - shared: "" - webhooks: "" - inferUnionDiscriminators: true - inputModelSuffix: input - inputTypedDictSuffix: TypedDict - license: Apache-2.0 - maxMethodParams: 999 - methodArguments: infer-optional-args - methodTimeoutArgument: timeout-ms - methodTimeoutUnits: milliseconds - moduleName: "" - multipartArrayFormat: standard - optionalDependencies: {} - outputModelSuffix: output - packageManager: uv - packageName: youdotcom - preApplyUnionDiscriminators: true - pytestFilterWarnings: [] - pytestTimeout: 0 - rawResponseHelpers: false - responseFormat: flat - responseSchemaValidation: true - sseFlatResponse: false - templateVersion: v2 - useAsyncHooks: false - uuidFormat: false diff --git a/.speakeasy/out.openapi.yaml b/.speakeasy/out.openapi.yaml deleted file mode 100644 index f7fcc16..0000000 --- a/.speakeasy/out.openapi.yaml +++ /dev/null @@ -1,1912 +0,0 @@ -openapi: 3.1.0 -info: - title: You.com API - description: |- - Unified API for Express, Advanced, and Custom Agents from You.com - Get the best search results from web and news sources - Returns the HTML or Markdown of a target webpage - Multi-step reasoning with comprehensive research capabilities - Finance-focused multi-step research with competitive accuracy at same price points and latencies as the Research API - Comprehensive API for You.com services: - - **Agents API**: Execute queries using Express, Advanced, and Custom AI agents - - **Research API**: In-depth, multi-step research with citations and sources - - **Finance Research API**: Finance-focused multi-step research with citations and sources - - **Search API**: Get search results from web and news sources - - **Contents API**: Retrieve and process web page content - version: 1.0.0 -servers: - - url: https://api.you.com - x-fern-server-name: Production -paths: - /v1/agents/runs: - post: - operationId: AgentsRuns - security: - - ApiKeyAuth: [] - summary: Run an Agent - description: | - Execute queries using You.com's AI agents. This endpoint supports three agent types: - - - **Express Agent**: Fast responses with optional web search (max 1 search) - - **Advanced Agent**: Complex queries with multi-turn reasoning, planning, and tool usage - - **Custom Agent**: User-configured assistants created in the You.com UI - - The response format depends on the `stream` parameter - either a complete JSON payload or Server-Sent Events (SSE). - requestBody: - description: "The parameters to ask the agent a question" - required: true - content: - application/json: - schema: - oneOf: - - $ref: "#/components/schemas/ExpressAgentRunsRequest" - - $ref: "#/components/schemas/AdvancedAgentRunsRequest" - - $ref: "#/components/schemas/CustomAgentRunsRequest" - examples: - express_batch: - summary: Express Agent - Batch Response - value: - agent: express - input: "What is the capital of France?" - stream: false - express_stream: - summary: Express Agent - Streaming Response - value: - agent: express - input: "What are some great recipes I can make in under half an hour" - stream: true - tools: - - type: web_search - advanced_batch: - summary: Advanced Agent - Batch Response - value: - agent: advanced - input: "You are a biologist studying the impacts of microplastics. Explain what microplastics are to a group of engineers, explain the impacts of microplastics on the body, and what the common sources and dosages of microplastics are. Highlight what a safe dosage might be and how to achieve it" - stream: false - tools: - - type: research - search_effort: auto - report_verbosity: medium - advanced_stream: - summary: Advanced Agent - Streaming Response - value: - agent: advanced - input: "Analyze the economic impact of renewable energy adoption" - stream: true - tools: - - type: compute - custom_batch: - summary: Custom Agent - Batch Response - value: - agent: "63773261-b4de-4d8f-9dfd-cff206a5cb51" - input: "What is the capital of France?" - stream: false - custom_stream: - summary: Custom Agent - Streaming Response - value: - agent: "63773261-b4de-4d8f-9dfd-cff206a5cb51" - input: "Tell me about the history of Paris" - stream: true - responses: - "200": - description: | - Successful agent response. The content type depends on the `stream` parameter: - - When `stream: false` - Returns complete JSON response (`application/json`) - - When `stream: true` - Returns Server-Sent Events stream (`text/event-stream`) - content: - application/json: - schema: - $ref: "#/components/schemas/AgentRunsBatchResponse" - text/event-stream: - schema: - $ref: "#/components/schemas/AgentRunsStreamingResponse" - "400": - description: The Authorization Bearer token was missing or invalid - content: - application/json: - schema: - $ref: "#/components/schemas/AgentRuns400Response" - "401": - description: Unauthorized - Invalid or expired API key - content: - application/json: - schema: - $ref: "#/components/schemas/AgentRuns401Response" - "422": - description: Unprocessable Entity - Invalid request data - content: - application/json: - schema: - $ref: "#/components/schemas/AgentRuns422Response" - servers: - - url: https://api.you.com - tags: - - agents.runs - x-speakeasy-name-override: create - /v1/search: - post: - operationId: searchPost - summary: Returns a list of unified search results from web and news sources - description: |- - This endpoint is designed to return LLM-ready web results based on a user's query. Based on a classification mechanism, it can return web results and news associated with your query. If you need to feed an LLM with the results of a query that sounds like `What are the latest geopolitical updates from India`, then this endpoint is the right one for you. - - `POST` is the recommended method when using complex parameters such as `include_domains` or `exclude_domains`. These fields accept JSON arrays in the request body, which is unambiguous and supports up to 500 domains per request—something that would exceed URL length limits with GET. Use GET for simple queries where HTTP cacheability matters. - servers: - - url: https://ydc-index.io - security: - - ApiKeyAuth: [] - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SearchRequestBody' - required: true - responses: - "200": - $ref: '#/components/responses/SearchSuccess' - "401": - $ref: '#/components/responses/Unauthorized' - "403": - $ref: '#/components/responses/Forbidden' - "422": - $ref: '#/components/responses/UnprocessableEntity' - "500": - $ref: '#/components/responses/InternalServerError' - get: - operationId: search - summary: Returns a list of unified search results from web and news sources - description: |- - This endpoint is designed to return LLM-ready web results based on a user's query. Based on a classification mechanism, it can return web results and news associated with your query. If you need to feed an LLM with the results of a query that sounds like `What are the latest geopolitical updates from India`, then this endpoint is the right one for you. - - `GET` is a good choice for simple queries where HTTP cacheability matters—GET responses can be cached at CDN and proxy layers, whereas POST responses are not cached by default per the HTTP spec. For requests with complex parameters such as `include_domains` or `exclude_domains`, use POST instead - domain lists are passed as comma-separated strings in GET and are limited by URL length. - servers: - - url: https://ydc-index.io - security: - - ApiKeyAuth: [] - parameters: - - $ref: '#/components/parameters/Query' - - $ref: '#/components/parameters/Count' - - $ref: '#/components/parameters/FreshnessParam' - - $ref: '#/components/parameters/Offset' - - $ref: '#/components/parameters/CountryParam' - - $ref: '#/components/parameters/LanguageParam' - - $ref: '#/components/parameters/SafeSearchParam' - - $ref: '#/components/parameters/LiveCrawlParam' - - $ref: '#/components/parameters/LiveCrawlFormatsParam' - - $ref: '#/components/parameters/IncludeDomainsParam' - - $ref: '#/components/parameters/ExcludeDomainsParam' - - $ref: '#/components/parameters/BoostDomainsParam' - - $ref: '#/components/parameters/CrawlTimeout' - responses: - "200": - $ref: '#/components/responses/SearchSuccess' - "401": - $ref: '#/components/responses/Unauthorized' - "403": - $ref: '#/components/responses/Forbidden' - "422": - $ref: '#/components/responses/UnprocessableEntity' - "500": - $ref: '#/components/responses/InternalServerError' - tags: - - search - x-speakeasy-name-override: unified - /v1/contents: - post: - operationId: contents - summary: Returns the content of the web pages - description: Returns the HTML or Markdown of a target webpage. - security: - - ApiKeyAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - urls: - type: array - items: - type: string - format: uri - example: "https://www.you.com" - description: Array of URLs to fetch the contents from. - formats: - $ref: '#/components/schemas/ContentsFormats' - crawl_timeout: - type: integer - maximum: 60 - minimum: 1 - description: Maximum time in seconds to wait for page content. Must be between 1 and 60 seconds. Default is 10 seconds. - default: 10 - example: 10 - max_age: - type: integer - minimum: 0 - description: 'Maximum allowed age of cached content in seconds. When set, cached content older than this threshold is ignored and the page is re-fetched. Must be 0 or greater. Default: null (no age limit, cached content is returned regardless of age).' - default: null - nullable: true - example: 86400 - required: true - responses: - "200": - description: An array of JSON objects containing the page content of each web page - content: - application/json: - schema: - type: array - items: - type: object - properties: - url: - type: string - format: uri - description: The webpage URL whose content has been fetched. - example: "https://www.you.com" - title: - type: string - description: The title of the web page. - example: "The best website in the world" - html: - type: string - description: The retrieved HTML content of the web page. - nullable: true - markdown: - type: string - description: The retrieved Markdown content of the web page. - nullable: true - metadata: - $ref: '#/components/schemas/ContentsMetadata' - "401": - description: Unauthorized. Problems with API key. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - description: Error detail message. - examples: - missingApiKey: - summary: Missing API key - value: - detail: "API key is required" - invalidOrExpired: - summary: Invalid/expired API key - value: - detail: "Invalid or expired API key" - otherAuthParsing: - summary: Other auth parsing errors - value: - detail: "" - "403": - description: Forbidden. API key lacks scope for this path. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - examples: - missingScopes: - summary: Missing required scopes - value: - detail: "Missing required scopes" - "500": - description: Internal Server Error during authentication/authorization middleware. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - examples: - authFailure: - summary: Authentication failure - value: - detail: "Internal authentication error" - authorizationFailure: - summary: Authorization failure - value: - detail: "Internal authorization error" - tags: - - contents - x-speakeasy-name-override: generate - servers: - - url: https://ydc-index.io - /v1/research: - post: - operationId: research - summary: Returns comprehensive research-grade answers with multi-step reasoning - description: Research goes beyond a single web search. In response to your question, it runs multiple searches, reads through the sources, and synthesizes everything into a thorough, well-cited answer. Use it when a question is too complex for a simple lookup, and when you need a response you can actually trust and verify. - security: - - ApiKeyAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - input: - type: string - maxLength: 40000 - description: |- - The research question or complex query requiring in-depth investigation and multi-step reasoning. - - Note: The maximum length of the input is 40,000 characters. - research_effort: - $ref: '#/components/schemas/ResearchEffort' - source_control: - type: object - properties: - include_domains: - type: array - items: - type: string - description: Only return results from these domains. Max 500 domains. Cannot be used with exclude_domains or boost_domains. - exclude_domains: - type: array - items: - type: string - description: Never return results from these domains. Max 500 domains. Also blocks the research agent from visiting pages on those domains during browsing. - boost_domains: - type: array - items: - type: string - description: Boost results from these domains without excluding other domains. Max 500 domains. Cannot be used with include_domains. - freshness: - type: string - description: Filter results by recency. Accepts `day`, `week`, `month`, `year`, or a custom date range in `YYYY-MM-DDtoYYYY-MM-DD` format. - country: - type: string - description: ISO 3166-1 alpha-2 country code, such as US, GB, or DE, to geographically focus web results. - description: |- - Beta. Controls which web sources the research agent searches and visits. Use this to allow specific domains, block specific domains, boost specific domains, filter by recency, or focus web results by country. - - `include_domains` and `exclude_domains` cannot be used together. Each domain list is capped at 500 entries. `exclude_domains` also blocks the research agent from visiting pages on those domains during browsing. `boost_domains` gives matching domains a relative ranking boost without filtering out other domains. It can be combined with `exclude_domains` but cannot be combined with `include_domains`. - output_schema: - type: object - description: |- - Beta. Requests structured JSON output in output.content using a supported JSON Schema subset. Supported only with research_effort values standard, deep, and exhaustive. Sending output_schema with research_effort: "lite" returns 422. - - Schema rules: Root must be a JSON object. Top-level anyOf is not allowed. Every object must define properties and set additionalProperties: false. Every property must be listed in required. Recursive schemas are not supported. - - Limits: Max nesting depth 5, max total properties 100, max total enum values 500, max total schema string budget 25,000. - additionalProperties: true - required: - - input - example: - input: Which global cities improved air quality the most over the past 10 years, and what measurable actions contributed? - research_effort: lite - required: true - responses: - "200": - description: A JSON object containing a comprehensive answer with citations and supporting search results. - content: - application/json: - schema: - $ref: '#/components/schemas/ResearchResponse' - example: - output: - content: |- - Over the past decade, some global cities have shown improvements in air quality due to specific actions. Beijing, for example, made significant strides in improving its air quality through coordinated control measures with surrounding areas, collaborative planning, unified standards, joint emergency responses, and information sharing [[1]]. These efforts, including a five-year action plan for air pollution prevention and control, have helped to substantially improve air quality in the Jing-Jin-Ji region [[2]]. - - Paris has also seen improvements, with a 50% reduction in Nitrogen dioxide pollution and a 55% decrease in particulate matter citywide since 2005. This was achieved through its climate strategy, which included adding more bike lanes and increasing cycling networks [[3]]. Wellington, New Zealand, improved air quality in one of its busiest districts by increasing the percentage of electric buses from 5% to over 50% between 2022 and 2023, leading to a 50% reduction in black carbon and a 29% drop in nitrogen dioxide levels [[3]]. Mexico City, once known as the world's most polluted city in the early 1990s, has vastly improved its air quality, with the daily concentration of SO2 declining significantly by 2018 [[2]]. - - While many cities globally have experienced persistently high or even rising levels of air pollution, especially concerning PM2.5 concentrations, NO2 exposures have shown an encouraging trend, with 211 more cities meeting the WHO guideline in 2019 compared to 2010 [[4]]. Local policies have been instrumental in these improvements [[4]]. - content_type: text - sources: - - url: https://sustainablemobility.iclei.org/air-pollution-beijing/ - title: "Clearing the skies: how Beijing tackled air pollution & what lies ..." - snippets: - - >- - However, Beijing has made remarkable strides in recent years to improve its air quality, setting an example for other cities grappling with similar challenges. The root causes Comparing the past 20 years of its development to the 20 before, Beijing's GDP, population, and vehicles sharply increased by 1078%, 74%, and 335% respectively (UNEP, 2019). - - >- - The city actively coordinated air pollution control measures with surrounding areas, such as the Beijing-Tianjin-Hebei region. Collaborative planning, unified standards, joint emergency responses, and information sharing significantly improved the air quality in this broader region. - - >- - While Beijing has made significant strides, challenges remain. The average PM2.5 level is still six times higher than the World Health Organization's (WHO) guideline, and the 2021-22 improvement may be partially attributed to measures taken for the Winter Olympics. - - >- - As China emerged as the world's largest automobile producer and consumer, it grappled with the detrimental impacts of increased oil consumption. Furthermore, the high level of coal consumption, especially during the winter heating season, contributed to the city's deteriorating air quality, reaching an average of 101.56 micrograms of PM2.5 particles per cubic meter in 2013 (Statista, 2023). - - url: https://blogs.worldbank.org/en/voices/tackling-poor-air-quality-lessons-three-cities - title: "Tackling poor air quality: Lessons from three cities" - snippets: - - >- - The latest World Bank report, Clearing the Air: A Tale of Three Cities, chose Beijing, New Delhi and Mexico City to assess how current and past efforts improved air quality. In the early 1990s, Mexico City was known as the world's most polluted city and while there are still challenges, air quality has vastly improved. Daily concentration of SO2 – a contributor to PM2.5 concentrations – declined from 300 µg/m3 in the 1990s to less than 100 µg/m3 in 2018. - - >- - In China, the ministries of Environmental Protection (now the Ministry of Ecology and Environment), Industry and Information Technology, Finance, Housing and Rural Development, along with the National Development and Reform Commission and National Energy Administration, worked together to issue a five-year action plan for air pollution prevention and control for the entire Jing-Jin-Ji region that surrounds Beijing and includes the municipality of Beijing, municipality of Tianjin, the province of Hebei, and small parts of Henan, Shanxi, inner Mongolia, and Shandong. What's encouraging about this new work is that it shows that with the right policies, incentives and information, air quality can be improved substantially, particularly as countries work to grow back cleaner after the pandemic. - - >- - Failure to provide such incentives in India in the late 1990s resulted in the government developing plans but not implementing them. This led to India's Supreme Court stepping in to force the government to implement policy measures. A recent government of India program to provide performance-based grants to cities to reward improvements in air quality is a step in the right direction. - - >- - The cost associated with health impacts of outdoor PM2.5 air pollution is estimated to be US$5.7 trillion, equivalent to 4.8 percent of global GDP, according to World Bank research. The COVID-19 pandemic further highlights why addressing air pollution is so important, with early research pointing to links between air pollution, illness and death due to the virus. On the flip side, the economic lockdowns caused by the pandemic, while devastating for communities, did result in some noticeable improvements in air quality but these improvements were inconsistent, particularly when it came to PM2.5. - - url: https://www.weforum.org/stories/2025/06/urban-mobility-improving-cities-air-quality/ - title: "Boosting clean air strategies in cities around the world | World ..." - snippets: - - >- - Comprehensive cycling networks improve air quality while also transforming urban mobility. Paris has added more bike lanes to its cityscape in recent years. Between 2022 and 2023 alone, bike path usage doubled during rush hour and cyclists now outnumber cars on many of the city's streets. The results of Paris' growing cycling network are promising. Alongside other elements of Paris's climate strategy, cycling has contributed to a 50% reduction in Nitrogen dioxide pollution and 55% decrease in particulate matter citywide since 2005. - - >- - In 2025, the alliance and members of the Global New Mobility Coalition will launch a new workstream on Transport and Urbanism that aims to speed up cross-sector collaboration on implementing proven mobility options to improve air quality and drive sustainable growth. - - >- - Air pollution has been estimated to cause 4.2 million premature deaths worldwide per year, according to the World Health Organization, and nearly half of urban airborne contamination comes from city transport. While vehicles are essential to the vitality of cities, without the right policies in place, transport will continue to be a major contributor to harmful air pollution. - - >- - In Wellington, New Zealand, the percentage of electric buses travelling across the city's heavily trafficked Golden Mile corridor rose from 5% to over 50% between 2022 and 2023. This shift led to a 50% reduction in black carbon and a 29% drop in nitrogen dioxide levels throughout the district. This has significantly improved air quality in one of the busiest parts of Wellington, as well as lowering noise pollution. - - url: https://www.stateofglobalair.org/resources/health-in-cities - title: "Air Pollution and Health in Cities | State of Global Air" - snippets: - - >- - Globally, NO2 exposures are heading in an encouraging direction as 211 more cities met the WHO guideline of 10 µg/m3 in 2019 compared to 2010. However, NO2 pollution is worsening in some other regions. Percentage of cities by population-weighted annual average pollutant concentration in 2010 and 2019. However, interventions targeting pollution at the local scale have successfully improved air quality in some cities. - - >- - Local policies have improved air quality in some cities, while pollution has worsened in others. Overall, many cities have seen persistently high — and even rising — levels of air pollution over the past decade. PM2.5 exposures remained stagnant in many cities from 2010 to 2019. - - >- - Cities are often hotspots for poor air quality. As rapid urbanization increases the number of people breathing dangerously polluted air, city-level data can help inform targeted efforts to curb urban air pollution and improve public health. - - >- - Explore air quality and health data for your city using our new interactive app here. Most cities have polluted air, but the type of pollution varies from place to place. Local policies have improved air quality in some cities, while pollution has worsened in others. - "422": - description: Unprocessable Entity. Request validation failed. - content: - application/json: - schema: - type: object - properties: - detail: - type: array - items: - type: object - properties: - type: - type: string - description: The validation error type. - example: missing - loc: - type: array - items: - oneOf: - - type: string - - type: integer - description: The location of the error as a path of segments (strings for field names, integers for byte offsets). - example: ["body", "input"] - msg: - type: string - description: A human-readable description of the error. - example: Field required - input: - oneOf: - - type: string - - type: object - description: The input value that caused the error. - ctx: - type: object - additionalProperties: true - description: Additional context about the error. - required: - - type - - loc - - msg - - input - examples: - missingField: - summary: Required field missing - value: - detail: - - type: missing - loc: ["body", "input"] - msg: Field required - input: {} - invalidEnum: - summary: Invalid enum value for research_effort - value: - detail: - - type: enum - loc: ["body", "research_effort"] - msg: "Input should be 'lite', 'standard', 'deep' or 'exhaustive'" - input: invalid_value - ctx: - expected: "'lite', 'standard', 'deep' or 'exhaustive'" - stringTooLong: - summary: Input exceeds maximum length - value: - detail: - - type: string_too_long - loc: ["body", "input"] - msg: String should have at most 40000 characters - input: - ctx: - max_length: 40000 - invalidJson: - summary: Invalid JSON body - value: - detail: - - type: json_invalid - loc: ["body", 115] - msg: JSON decode error - input: {} - ctx: - error: Invalid control character at - "401": - description: Unauthorized. Problems with API key. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - description: Error detail message. - examples: - missingApiKey: - summary: Missing API key - value: - detail: API key is required - invalidOrExpired: - summary: Invalid/expired API key - value: - detail: Invalid or expired API key - otherAuthParsing: - summary: Other auth parsing errors - value: - detail: - "403": - description: Forbidden. API key lacks scope for this path. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - examples: - missingScopes: - summary: Missing required scopes - value: - detail: Missing required scopes - "500": - description: Internal Server Error during authentication/authorization middleware. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - examples: - authFailure: - summary: Authentication failure - value: - detail: Internal authentication error - authorizationFailure: - summary: Authorization failure - value: - detail: Internal authorization error - /v1/finance_research: - post: - operationId: finance_research - summary: Returns comprehensive finance-grade research answers with multi-step reasoning - description: |- - The Finance Research API is purpose-built for financial questions. Like the Research API, it runs multiple searches, reads through sources, and synthesizes everything into a thorough, well-cited answer — but its retrieval index is optimized for financial data: earnings reports, SEC filings, analyst coverage, market data, and financial news. - Use it when you need credible, sourced answers to financial questions: company fundamentals, market trends, competitive analysis, earnings summaries, or macroeconomic research. - security: - - ApiKeyAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - input: - type: string - maxLength: 40000 - description: |- - The financial research question or complex query requiring in-depth investigation and multi-step reasoning. - - Note: The maximum length of the input is 40,000 characters. - example: What were the key drivers of NVIDIA's revenue growth in fiscal year 2025? - research_effort: - $ref: '#/components/schemas/FinanceResearchEffort' - required: - - input - example: - input: What were the key drivers of NVIDIA's revenue growth in fiscal year 2025? - research_effort: deep - required: true - responses: - "200": - description: A JSON object containing a comprehensive finance-grade answer with citations and supporting search results - content: - application/json: - schema: - type: object - properties: - output: - type: object - properties: - content: - type: string - description: The comprehensive finance-grade response with inline citations. Content is a Markdown string with numbered citations that reference the items in the sources array. - example: |- - For fiscal year 2025, ended January 26, 2025, NVIDIA's revenue rose to **$130.5 billion, up 114% year over year**.[[1]] - The main drivers were Data Center demand (up 142%), Compute & Networking growth (up 145%), and smaller contributions from Gaming, Professional Visualization, and Automotive. - content_type: - type: string - enum: - - text - description: The format of the content field. - example: text - sources: - type: array - items: - type: object - properties: - url: - type: string - description: The URL of the source webpage. - example: https://investor.apple.com/sec-filings/annual-reports/default.aspx - title: - type: string - description: The title of the source webpage. - example: "Apple Inc. Annual Report FY2024 (Form 10-K)" - required: - - url - description: A list of web sources used to generate the answer. - required: - - content - - content_type - - sources - description: The research output containing the answer and sources. - required: - - output - example: - output: - content: |- - For fiscal year 2025, ended January 26, 2025, NVIDIA's revenue rose to **$130.5 billion, up 114% year over year**.[[1]] The main drivers were: - - - **Data Center demand**, especially accelerated computing and AI platforms: Data Center revenue was **up 142%**, driven by demand for NVIDIA's **Hopper architecture** for large language models, recommendation engines, and generative AI applications; NVIDIA also began shipping **Blackwell** production systems in Q4 FY2025.[[1]] - - **Compute & Networking segment growth**: revenue increased **145%**, with Data Center compute up **162%** on Hopper demand and Data Center networking up **51%**, driven by Ethernet for AI, including Spectrum-X.[[1]] - - Smaller but positive contributions from other markets: **Gaming revenue rose 9%** on GeForce RTX 40 Series GPUs, **Professional Visualization rose 21%** on Ada RTX workstation adoption, and **Automotive rose 55%** from self-driving platform sales.[[1]] - - In short, NVIDIA's FY2025 growth was overwhelmingly driven by **AI-related Data Center compute and networking demand**, with additional support from gaming, professional visualization, and automotive. - content_type: text - sources: - - url: https://investor.nvidia.com/financial-info/financial-reports/default.aspx - title: NVIDIA Corporation - Financial Reports - "422": - description: Unprocessable Entity. Request validation failed. - content: - application/json: - schema: - type: object - properties: - detail: - type: array - items: - type: object - properties: - type: - type: string - description: The validation error type. - example: missing - loc: - type: array - items: - oneOf: - - type: string - - type: integer - description: The location of the error as a path of segments (strings for field names, integers for byte offsets). - example: ["body", "input"] - msg: - type: string - description: A human-readable description of the error. - example: Field required - input: - oneOf: - - type: string - - type: object - description: The input value that caused the error. - ctx: - type: object - additionalProperties: true - description: Additional context about the error. - required: - - type - - loc - - msg - - input - examples: - missingField: - summary: Required field missing - value: - detail: - - type: missing - loc: ["body", "input"] - msg: Field required - input: {} - invalidEnum: - summary: Invalid enum value for research_effort - value: - detail: - - type: enum - loc: ["body", "research_effort"] - msg: "Input should be 'deep' or 'exhaustive'" - input: invalid_value - ctx: - expected: "'deep' or 'exhaustive'" - stringTooLong: - summary: Input exceeds maximum length - value: - detail: - - type: string_too_long - loc: ["body", "input"] - msg: String should have at most 40000 characters - input: - ctx: - max_length: 40000 - invalidJson: - summary: Invalid JSON body - value: - detail: - - type: json_invalid - loc: ["body", 115] - msg: JSON decode error - input: {} - ctx: - error: Invalid control character at - "401": - description: Unauthorized. Problems with API key. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - description: Error detail message. - examples: - missingApiKey: - summary: Missing API key - value: - detail: API key is required - invalidOrExpired: - summary: Invalid/expired API key - value: - detail: Invalid or expired API key - otherAuthParsing: - summary: Other auth parsing errors - value: - detail: - "403": - description: Forbidden. API key lacks scope for this path. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - examples: - missingScopes: - summary: Missing required scopes - value: - detail: Missing required scopes - "500": - description: Internal Server Error during authentication/authorization middleware. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - examples: - authFailure: - summary: Authentication failure - value: - detail: Internal authentication error - authorizationFailure: - summary: Authorization failure - value: - detail: Internal authorization error -components: - securitySchemes: - ApiKeyAuth: - type: apiKey - in: header - name: X-API-Key - description: "A unique API Key is required to authorize API access. [Get your API Key with free credits](https://you.com/platform)." - schemas: - # REQUEST SCHEMAS - ExpressAgentRunsRequest: - type: object - required: - - agent - - input - properties: - agent: - type: string - const: express - description: >- - Setting this value to "express" is mandatory to use the express agent. - example: express - input: - type: string - description: "The question you'd like to ask the agent" - example: "What are some great recipes I can make in under half an hour" - stream: - type: boolean - default: false - description: >- - Must be set to `true` when you want to stream the express agent response as it's being generated, and `false` when you want the response to return after the agent has finished. - tools: - type: array - description: You can optionally ground the express agent response using results fetched from the web (max 1 web search) - items: - $ref: "#/components/schemas/WebSearchTool" - AdvancedAgentRunsRequest: - type: object - required: - - agent - - input - properties: - agent: - type: string - const: advanced - description: >- - Setting this value to "advanced" is mandatory to use the advanced agent. - example: advanced - input: - type: string - description: "The question you'd like to ask the agent" - example: "Analyze the economic impact of renewable energy adoption" - stream: - type: boolean - default: false - description: >- - Must be set to `true` when you want to stream the agent response as it's being generated, and `false` when you want the response to return after the agent has finished. - tools: - type: array - description: >- - The advanced agent accepts either `compute` or `research` tools Compute allows your agent to use a Python code interpreter for tasks such as data analysis, mathematical calculations, and plot generation.

Research iteratively searches the web, analyzes the results, and stops when finished. It then provides a comprehensive report to your agent with current, cited information.
- items: - oneOf: - - $ref: "#/components/schemas/ComputeTool" - - $ref: "#/components/schemas/ResearchTool" - discriminator: - propertyName: type - mapping: - compute: "#/components/schemas/ComputeTool" - research: "#/components/schemas/ResearchTool" - verbosity: - $ref: "#/components/schemas/Verbosity" - workflow_config: - type: object - required: - - max_workflow_steps - description: Defines the maximum number of steps the agent uses in its workflow plan to answer your query. Higher values allow for more tool calls, but it takes longer for the agent to provide the response. For instance, setting max_workflow_steps=5 could allow the agent to call the research tool 3 times and the compute tool 2 times. - properties: - max_workflow_steps: - type: integer - example: 10 - default: 10 - minimum: 1 - maximum: 20 - CustomAgentRunsRequest: - type: object - required: - - agent - - input - properties: - agent: - type: string - description: >- - Set the value to a Custom Agent's ID. Learn how to obtain an agent ID here [Create Custom Agents](https://docs.you.com/agents/custom/create-agents). - example: 63773261-b4de-4d8f-9dfd-cff206a5cb51 - input: - type: string - description: "The question you'd like to ask the agent" - example: "What are some insights about my data?" - stream: - type: boolean - default: false - description: >- - Must be set to `true` when you want to stream the agent response as it's being generated, and `false` when you want the response to return after the agent has finished. - # TOOL SCHEMAS - WebSearchTool: - type: object - required: - - type - properties: - type: - type: string - const: web_search - description: Setting this value to "web_search" is mandatory to use the web_search tool. - ComputeTool: - type: object - required: - - type - properties: - type: - type: string - const: compute - description: Setting this value to "compute" is mandatory to use the compute agent. - ResearchTool: - type: object - required: - - type - - search_effort - - report_verbosity - properties: - type: - type: string - const: research - description: Setting this value to "research" is mandatory to use the research agent. - search_effort: - $ref: "#/components/schemas/SearchEffort" - report_verbosity: - $ref: "#/components/schemas/ReportVerbosity" - SearchEffort: - type: string - enum: - - auto - - low - - medium - - high - description: >- - This parameter maps to different configurations regarding the depth of research the tool can perform. Its values range from `low`, `medium` to `high`. - - - Alternatively, use `auto` mode for a more dynamic search approach, allowing the tool the freedom to adjust its subparameters. - ReportVerbosity: - type: string - enum: - - medium - - high - description: Select whether to receive a medium or high length model response. - Verbosity: - type: string - enum: - - medium - - high - example: medium - description: >- - Controls the level of detail provided by the agent's response. Choosing high maps to a long-form report while medium maps to a medium verbosity report that captures most details but is less comprehensive. - # RESPONSE SCHEMAS - AgentRunsBatchResponse: - type: object - required: - - agent - - input - - output - properties: - agent: - type: string - description: The id of the agent populated in the request. - example: express - mode: - type: string - description: The mode of the agent - example: express - input: - type: array - description: The users access role and question you asked the agent - items: - type: object - required: - - role - - content - properties: - role: - type: string - description: The access based role of the user - enum: - - user - example: user - content: - type: string - description: The question populated in the request payload - example: What is the capital of France? - output: - type: array - description: Array of response outputs from the agent - items: - $ref: "#/components/schemas/AgentRunsResponseOutput" - AgentRunsResponseOutput: - type: object - description: The response populated by the agent. - required: - - type - properties: - text: - type: string - description: >- - The text response of the agent. This field returns when `type == message.answer`. The response returns as markdown formatted text. - - - For an overview of Markdown syntax, see the [Basic Syntax Markdown Guide](https://www.markdownguide.org/basic-syntax/) - example: "#### Capital of France\n\nThe capital of France is **Paris**. It is not only the capital but also the most populous city in the country. Paris is situated on the Seine River in the northern part of France, within the Île-de-France region. It serves as the main cultural, economic, and political center of France [[1]](https://www.coe.int/en/web/interculturalcities/paris)[[2]](https://en.wikipedia.org/wiki/Paris)." - type: - type: string - description: >- - The type of output. This can either be: - - * `message.answer` for text responses - - * `web_search.results` for output that contains web links. `web_search.results` only appear when you use the `research` tool or express agent with web_search - example: "web_search.results" - enum: - - message.answer - - web_search.results - content: - type: array - description: >- - The text response of the agent. - - This field returns when `type == web_search.results` - items: - $ref: "#/components/schemas/AgentRunsResponseWebSearchResult" - AgentRunsResponseWebSearchResult: - type: object - description: The text response of the agent. This field only returns when the type is `web_search.results` - required: - - source_type - - citation_uri - - title - - snippet - - url - properties: - source_type: - type: string - description: The type of content the agent can return outside a text response - example: web_search - const: web_search - citation_uri: - type: string - description: The web search result the agent returned along in its response - example: "https://www.foodnetwork.com/recipes/photos/30-minute-dinner-recipes" - provider: - type: string - description: "This is currently unused" - example: null - title: - type: string - description: "The title of the web site returned under url" - example: "103 Easy 30-Minute Dinner Recipes That Will Save Your Weeknights" - snippet: - type: string - description: "A textual portion of the web site returned under url" - example: "Apr 11, 2025 ... These quick dinner ideas will help you get a meal on the table in half an hour or less. ... It's a simple recipe ready in under half an hour with ..." - thumbnail_url: - type: string - description: The thumbnail image of the url - example: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQJTNucjGK8ZqfurwAmyuyhQ-7n7AVZHoJwUqfsqRYuCqlIpMwepNDEE_M&s" - url: - type: string - description: "The web search result the agent returned along in its response" - example: "https://www.foodnetwork.com/recipes/photos/30-minute-dinner-recipes" - # STREAMING RESPONSE SCHEMAS - AgentRunsStreamingResponse: - type: object - description: A server-sent event containing stock market update content - required: [id, event, data] - properties: - id: - type: string - description: "Sequence number of the SSE event, starts from 0" - event: - type: string - description: "The type of the SSE event" - data: - oneOf: - - $ref: "#/components/schemas/response.created" - - $ref: "#/components/schemas/response.starting" - - $ref: "#/components/schemas/response.output_item.added" - - $ref: "#/components/schemas/response.output_content.full" - - $ref: "#/components/schemas/response.output_item.done" - - $ref: "#/components/schemas/response.output_text.delta" - - $ref: "#/components/schemas/response.done" - response.created: - type: object - description: SSE event signifying the response stream has been created - required: - - seq_id - - type - properties: - seq_id: - type: integer - example: 0 - type: - type: string - const: response.created - example: response.created - response.starting: - type: object - description: SSE event signifying the response is starting - required: - - seq_id - - type - properties: - seq_id: - type: integer - example: 1 - type: - type: string - const: response.starting - example: response.starting - response.output_item.added: - type: object - description: SSE event signifying an output item has been added - required: - - seq_id - - type - - response - properties: - seq_id: - type: integer - example: 2 - type: - type: string - const: response.output_item.added - example: response.output_item.added - response: - type: object - required: - - output_index - properties: - output_index: - type: integer - description: The index of the output item in the response - example: 0 - response.output_content.full: - type: object - required: - - seq_id - - type - - response - properties: - seq_id: - type: integer - example: 3 - type: - type: string - const: response.output_content.full - example: response.output_content.full - response: - type: object - required: - - output_index - - type - - full - properties: - output_index: - type: integer - example: 0 - type: - type: string - const: web_search.results - example: web_search.results - full: - type: array - description: Complete web search results - items: - $ref: "#/components/schemas/AgentRunsResponseWebSearchResult" - response.output_item.done: - type: object - required: - - seq_id - - type - - response - properties: - seq_id: - type: integer - example: 4 - type: - type: string - const: response.output_item.done - example: response.output_item.done - response: - type: object - required: - - output_index - properties: - output_index: - type: integer - example: 0 - response.output_text.delta: - type: object - required: - - seq_id - - type - - response - properties: - seq_id: - type: integer - example: 6 - type: - type: string - const: response.output_text.delta - example: response.output_text.delta - response: - type: object - required: - - output_index - - type - - delta - properties: - output_index: - type: integer - example: 1 - type: - type: string - const: message.answer - example: message.answer - delta: - type: string - description: Incremental text content - example: " Test" - response.done: - type: object - required: - - seq_id - - type - - response - properties: - seq_id: - type: integer - example: 249 - type: - type: string - const: response.done - example: response.done - response: - type: object - required: - - run_time_ms - - finished - properties: - run_time_ms: - type: string - description: Total runtime in milliseconds - example: "8.029" - finished: - type: boolean - description: Whether the response is complete - example: true - # ERROR RESPONSE SCHEMAS - AgentRuns400Response: - type: object - properties: - detail: - type: string - example: "Invalid or expired API key" - description: "The message returned by the error" - AgentRuns401Response: - type: object - properties: - detail: - type: string - example: "Invalid or expired API key" - description: "The message returned by the error" - AgentRuns422Response: - type: object - properties: - detail: - type: array - items: - type: object - properties: - type: - type: string - example: model_attributes_type - loc: - type: array - items: - oneOf: - - type: string - - type: integer - example: ["body", "tools", 0] - msg: - type: string - example: Input should be a valid dictionary or object to extract fields from - input: - type: string - example: web_search - required: - - type - - loc - - msg - - input - SearchQuery: - type: string - description: The search query used to retrieve relevant results from the web. You can also include [search operators](https://docs.you.com/search/search-operators) to refine your search. - example: What are the latest geopolitical updates from India - Count: - type: integer - maximum: 100 - minimum: 1 - description: Specifies the maximum number of search results to return per section (the sections are `web` and `news`. See the JSON response to visualize them). - default: 10 - Freshness: - type: string - enum: - - day - - week - - month - - year - FreshnessValue: - oneOf: - - $ref: '#/components/schemas/Freshness' - - type: string - description: |- - Specifies the freshness of the results to return. Provide either one of `day`, `week`, `month`, `year`, or a date range string in the format `YYYY-MM-DDtoYYYY-MM-DD`. - - When your search query includes a temporal keyword and you also set a freshness parameter, the search will use the broader (i.e., less restrictive) of the two timeframes. For example, if you use `query=news+this+week&freshness=month`, the results will use a freshness of month. - Offset: - type: integer - maximum: 9 - minimum: 0 - description: Indicates the `offset` for pagination. The `offset` is calculated in multiples of `count`. For example, if `count = 5` and `offset = 1`, results 5–10 will be returned. Range `0 ≤ offset ≤ 9`. - Country: - type: string - enum: - - AR - - AU - - AT - - BE - - BR - - CA - - CL - - DK - - FI - - FR - - DE - - HK - - IN - - ID - - IT - - JP - - KR - - MY - - MX - - NL - - NZ - - NO - - CN - - PL - - PT - - PH - - RU - - SA - - ZA - - ES - - SE - - CH - - TW - - TR - - GB - - US - description: The country code that determines the geographical focus of the web results. - Language: - type: string - enum: - - AR - - EU - - BN - - BG - - CA - - ZH-HANS - - ZH-HANT - - HR - - CS - - DA - - NL - - EN - - EN-GB - - ET - - FI - - FR - - GL - - DE - - EL - - GU - - HE - - HI - - HU - - IS - - IT - - JA - - KN - - KO - - LV - - LT - - MS - - ML - - MR - - NB - - PL - - PT-BR - - PT-PT - - PA - - RO - - RU - - SR - - SK - - SL - - ES - - SV - - TA - - TE - - TH - - TR - - UK - - VI - description: The language of the web results that will be returned (BCP 47 format). - default: EN - SafeSearch: - type: string - enum: - - off - - moderate - - strict - description: Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not. - LiveCrawl: - type: string - enum: - - web - - news - - all - description: Indicates which section(s) of search results to livecrawl and return full page content. - LiveCrawlFormats: - type: array - items: - type: string - enum: - - html - - markdown - description: 'Indicates the format(s) of the livecrawled content. Pass one or both values (`html`, `markdown`). In a GET request, repeat the parameter: `?livecrawl_formats=html&livecrawl_formats=markdown`. In a POST body, provide a JSON array: `["html", "markdown"]`.' - IncludeDomains: - type: array - items: - type: string - description: |- - A list of domains to restrict search results to. Only results from these domains will be returned. Supports up to 500 domains. This is a strict allowlist, not a boost — results are limited exclusively to the specified domains. - - Cannot be combined with `exclude_domains`; passing both will return a `422` error. - example: - - nytimes.com - - bbc.com - ExcludeDomains: - type: array - items: - type: string - description: |- - A list of domains to exclude from search results. Results from these domains will be filtered out. Supports up to 500 domains. - - Cannot be combined with `include_domains`; passing both will return a `422` error. - example: - - spam-site.com - - other-site.com - BoostDomains: - type: array - items: - type: string - description: A list of domains to boost in search ranking. Matching results from these domains receive a relative ranking boost, but results are not limited to these domains. Supports up to 500 domains. Can be combined with `exclude_domains`, but cannot be combined with `include_domains` (returns `422`). - example: - - nytimes.com - - wired.com - CrawlTimeout: - type: integer - maximum: 60 - minimum: 1 - description: Maximum time in seconds to wait for page content when `livecrawl` is enabled. Must be between 1 and 60 seconds. Default is 10 seconds. - default: 10 - example: 10 - SearchRequestBody: - type: object - properties: - query: - $ref: '#/components/schemas/SearchQuery' - count: - $ref: '#/components/schemas/Count' - freshness: - $ref: '#/components/schemas/FreshnessValue' - offset: - $ref: '#/components/schemas/Offset' - country: - $ref: '#/components/schemas/Country' - language: - $ref: '#/components/schemas/Language' - safesearch: - $ref: '#/components/schemas/SafeSearch' - livecrawl: - $ref: '#/components/schemas/LiveCrawl' - livecrawl_formats: - $ref: '#/components/schemas/LiveCrawlFormats' - include_domains: - $ref: '#/components/schemas/IncludeDomains' - exclude_domains: - $ref: '#/components/schemas/ExcludeDomains' - boost_domains: - $ref: '#/components/schemas/BoostDomains' - crawl_timeout: - $ref: '#/components/schemas/CrawlTimeout' - required: - - query - SearchResponse: - type: object - properties: - results: - type: object - properties: - web: - type: array - items: - $ref: '#/components/schemas/WebResult' - news: - type: array - items: - $ref: '#/components/schemas/NewsResult' - metadata: - $ref: '#/components/schemas/SearchMetadata' - WebResult: - type: object - properties: - url: - type: string - description: The URL of the specific search result. - example: https://you.com - title: - type: string - description: The title or name of the search result. - example: The World's Greatest Search Engine! - description: - type: string - description: A brief description of the content of the search result. - example: Search on YDC - snippets: - type: array - items: - type: string - example: >- - I'm an AI assistant that helps you get more done. What can I help you with? - description: An array of text snippets from the search result, providing a preview of the content. - thumbnail_url: - type: string - description: URL of the thumbnail. - example: https://www.somethumbnailsite.com/thumbnail.jpg - page_age: - type: string - format: date-time - description: The age of the search result. - example: "2025-06-25T11:41:00" - contents: - $ref: '#/components/schemas/Contents' - authors: - type: array - items: - type: string - example: John Doe - description: An array of authors of the search result. - favicon_url: - type: string - description: The URL of the favicon of the search result's domain. - example: https://someurl.com/favicon - NewsResult: - type: object - properties: - title: - type: string - description: The title of the news result. - example: >- - Exclusive | You.com becomes the backbone of the EU's AI strategy - description: - type: string - description: A brief description of the content of the news result. - example: >- - As the EU's AI strategy is being debated, You.com becomes the backbone of the EU's AI strategy. - page_age: - type: string - format: date-time - description: UTC timestamp of the article's publication date. - example: "2025-06-25T11:41:00" - thumbnail_url: - type: string - description: URL of the thumbnail. - example: https://www.somethumbnailsite.com/thumbnail.jpg - url: - type: string - description: The URL of the news result. - example: https://www.you.com/news/eu-ai-strategy-youcom - contents: - $ref: '#/components/schemas/Contents' - SearchMetadata: - type: object - properties: - search_uuid: - type: string - format: uuid - example: 942ccbdd-7705-4d9c-9d37-4ef386658e90 - query: - type: string - description: Returns the search query used to retrieve the results. - example: What are the latest geopolitical updates from India - latency: - type: number - example: 0.123 - Contents: - type: object - properties: - html: - type: string - description: The HTML content of the page. - markdown: - type: string - description: The Markdown content of the page. - description: Contents of the page if livecrawl was enabled. - ContentsFormats: - type: array - items: - type: string - enum: - - html - - markdown - - metadata - description: Array of content formats to return. All included formats are returned in the response. Include "metadata" to get JSON-LD and OpenGraph information, if available. - example: ["html", "markdown"] - ContentsMetadata: - type: object - properties: - site_name: - type: string - description: The OpenGraph site name of the web page. - nullable: true - example: "You.com" - favicon_url: - type: string - description: The URL of the favicon of the web page's domain. - example: "https://api.ydc-index.io/favicon?domain=you.com&size=128" - description: Metadata about the web page. Only returned when 'metadata' is included in the formats array. - nullable: true - ResearchEffort: - type: string - enum: - - lite - - standard - - deep - - exhaustive - description: |- - Controls how much time and effort the Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time. - - Available levels: - - `lite`: Returns answers quickly. Good for straightforward questions that just need a fast, reliable answer. - - `standard`: The default. Balances speed and depth, a good fit for most questions. - - `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed. - - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result. - default: standard - ResearchResponse: - type: object - properties: - output: - type: object - properties: - content: - oneOf: - - type: string - - type: object - additionalProperties: true - description: The comprehensive response with inline citations. When content_type is "text", this is a Markdown string with numbered citations that reference the items in the sources array. When content_type is "object", this is a structured JSON object matching the requested output_schema. - content_type: - type: string - enum: - - text - - object - description: The format of the content field. - sources: - type: array - items: - type: object - properties: - url: - type: string - description: The URL of the source webpage. - title: - type: string - description: The title of the source webpage. - snippets: - type: array - items: - type: string - description: Relevant excerpts from the source page that were used in generating the answer. - required: - - url - description: A list of web sources used to generate the answer. - required: - - content - - content_type - - sources - description: The research output containing the answer and sources. - required: - - output - FinanceResearchEffort: - type: string - enum: - - deep - - exhaustive - description: |- - Controls how much time and effort the Finance Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time. - - Available levels: - - `deep`: The default. Spends more time researching and cross-referencing sources. Good for most financial questions, including multi-company comparisons, earnings analysis, and regulatory research. - - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex financial research tasks where you want the highest quality result. - default: deep - example: deep - responses: - SearchSuccess: - description: A JSON object containing unified search results from web and news sources - content: - application/json: - schema: - $ref: '#/components/schemas/SearchResponse' - Unauthorized: - description: Unauthorized. Problems with API key. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - description: Error detail message. - examples: - missingApiKey: - summary: Missing API key - value: - detail: API key is required - invalidOrExpired: - summary: Invalid/expired API key - value: - detail: Invalid or expired API key - otherAuthParsing: - summary: Other auth parsing errors - value: - detail: - Forbidden: - description: Forbidden. API key lacks scope for this path. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - examples: - missingScopes: - summary: Missing required scopes - value: - detail: Missing required scopes - UnprocessableEntity: - description: Unprocessable Entity. Invalid request parameter combination. - content: - application/json: - schema: - type: object - properties: - error: - type: string - examples: - invalidParams: - summary: Invalid request parameters - value: - error: "invalid request parameter(s)" - InternalServerError: - description: Internal Server Error during authentication/authorization middleware. - content: - application/json: - schema: - type: object - properties: - detail: - type: string - examples: - authFailure: - summary: Authentication failure - value: - detail: Internal authentication error - authorizationFailure: - summary: Authorization failure - value: - detail: Internal authorization error - parameters: - Query: - name: query - in: query - required: true - schema: - $ref: '#/components/schemas/SearchQuery' - Count: - name: count - in: query - required: false - schema: - $ref: '#/components/schemas/Count' - FreshnessParam: - name: freshness - in: query - required: false - schema: - $ref: '#/components/schemas/FreshnessValue' - Offset: - name: offset - in: query - required: false - schema: - $ref: '#/components/schemas/Offset' - CountryParam: - name: country - in: query - required: false - schema: - $ref: '#/components/schemas/Country' - LanguageParam: - name: language - in: query - required: false - schema: - $ref: '#/components/schemas/Language' - SafeSearchParam: - name: safesearch - in: query - required: false - schema: - $ref: '#/components/schemas/SafeSearch' - LiveCrawlParam: - name: livecrawl - in: query - required: false - schema: - $ref: '#/components/schemas/LiveCrawl' - LiveCrawlFormatsParam: - name: livecrawl_formats - in: query - required: false - style: form - explode: true - schema: - $ref: '#/components/schemas/LiveCrawlFormats' - IncludeDomainsParam: - name: include_domains - in: query - description: |- - A list of domains to restrict search results to. Only results from these domains will be returned. For large domain lists (up to 500), use POST with a JSON array instead. This is a strict allowlist — cannot be combined with `exclude_domains` (returns `422`). - - **Important:** Use a single comma-separated value (e.g. `include_domains=nytimes.com,bbc.com`). Repeated parameters (`include_domains=a.com&include_domains=b.com`) are not supported. - required: false - schema: - type: string - example: nytimes.com,bbc.com - ExcludeDomainsParam: - name: exclude_domains - in: query - description: |- - A list of domains to exclude from search results. Results from these domains will be filtered out. For large domain lists (up to 500), use POST with a JSON array instead. Cannot be combined with `include_domains` (returns `422`). - - **Important:** You must use a single comma-separated value (e.g. `exclude_domains=spam-site.com,other-site.com`). Repeated parameters are not supported. - required: false - schema: - type: string - example: spam-site.com,other-site.com - BoostDomainsParam: - name: boost_domains - in: query - description: |- - A list of domains to boost in search ranking. Matching results from these domains receive a relative ranking boost, but results are not limited to these domains. Supports up to 500 domains. Can be combined with `exclude_domains`, but cannot be combined with `include_domains` (returns `422`). - - **Important:** You must use a single comma-separated value (e.g. `boost_domains=nytimes.com,wired.com`). Repeated parameters are not supported. - required: false - schema: - type: string - example: nytimes.com,wired.com - CrawlTimeout: - name: crawl_timeout - in: query - required: false - schema: - $ref: '#/components/schemas/CrawlTimeout' diff --git a/.speakeasy/testfiles/example.file b/.speakeasy/testfiles/example.file deleted file mode 100644 index 3b18e51..0000000 --- a/.speakeasy/testfiles/example.file +++ /dev/null @@ -1 +0,0 @@ -hello world diff --git a/.speakeasy/workflow.lock b/.speakeasy/workflow.lock deleted file mode 100644 index 43e7ff6..0000000 --- a/.speakeasy/workflow.lock +++ /dev/null @@ -1,49 +0,0 @@ -speakeasyVersion: 1.790.1 -sources: - You.com API: - sourceNamespace: you-com-search-api - sourceRevisionDigest: sha256:3d877bc7e79a67634914d19f5fb8da8b3f26b84d7f2f426be6b42e6901d07372 - sourceBlobDigest: sha256:3928c45357af349beb1276c811f239ad9de98d0f6bd0e5af9a09f4d4857143f8 - tags: - - latest - - 1.0.0 -targets: - you: - source: You.com API - sourceNamespace: you-com-search-api - sourceRevisionDigest: sha256:3d877bc7e79a67634914d19f5fb8da8b3f26b84d7f2f426be6b42e6901d07372 - sourceBlobDigest: sha256:3928c45357af349beb1276c811f239ad9de98d0f6bd0e5af9a09f4d4857143f8 - codeSamplesNamespace: you-com-search-api-code-samples - codeSamplesRevisionDigest: sha256:88c246f8d3a1852f39352e2da711eefe502b3c49b215503078124d8b6e0a6ce6 -workflow: - workflowVersion: 1.0.0 - speakeasyVersion: latest - sources: - You.com API: - inputs: - - location: /Users/tyler/Workspace/youdotcom-frontend/public/specs/openapi_unified_agents.yaml - - location: /Users/tyler/Workspace/youdotcom-frontend/public/specs/openapi_search_v1.yaml - - location: /Users/tyler/Workspace/youdotcom-frontend/public/specs/openapi_contents.yaml - - location: /Users/tyler/Workspace/youdotcom-frontend/public/specs/openapi_research.yaml - - location: /Users/tyler/Workspace/youdotcom-frontend/public/specs/openapi_finance_research.yaml - - location: /Users/tyler/Workspace/youdotcom-frontend/public/specs/openapi_base.yaml - overlays: - - location: ./overlays/python_overlay.yaml - output: .speakeasy/out.openapi.yaml - registry: - location: registry.speakeasyapi.dev/youcom/python-sdk/you-com-search-api - targets: - you: - target: python - source: You.com API - publish: - pypi: - token: $pypi_token - codeSamples: - registry: - location: registry.speakeasyapi.dev/youcom/python-sdk/you-com-search-api-code-samples - labelOverride: - fixedValue: Python (SDK) - blocking: false - testing: - enabled: false diff --git a/.speakeasy/workflow.yaml b/.speakeasy/workflow.yaml deleted file mode 100644 index fe2955e..0000000 --- a/.speakeasy/workflow.yaml +++ /dev/null @@ -1,31 +0,0 @@ -workflowVersion: 1.0.0 -speakeasyVersion: latest -sources: - You.com API: - inputs: - - location: https://you.com/specs/openapi_unified_agents.yaml - - location: https://you.com/specs/openapi_search_v1.yaml - - location: https://you.com/specs/openapi_contents.yaml - - location: https://you.com/specs/openapi_research.yaml - - location: https://you.com/specs/openapi_finance_research.yaml - - location: https://you.com/specs/openapi_base.yaml - overlays: - - location: ./overlays/python_overlay.yaml - output: .speakeasy/out.openapi.yaml - registry: - location: registry.speakeasyapi.dev/youcom/python-sdk/you-com-search-api -targets: - you: - target: python - source: You.com API - publish: - pypi: - token: $pypi_token - codeSamples: - registry: - location: registry.speakeasyapi.dev/youcom/python-sdk/you-com-search-api-code-samples - labelOverride: - fixedValue: Python (SDK) - blocking: false - testing: - enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index a9764e6..75363e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ All notable changes to the You.com Python SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.5.0] - 2026-07-20 + +### Added + +- **`frontier` research effort tier**: New `ResearchEffort.FRONTIER` enum value for the highest-quality, longest-running research tasks. Frontier runs over longer durations with improved quality and accuracy. It only works with the task-based API (`background=true`); sending `frontier` without `background=true` returns a `422`. Use it with the background-mode helpers: + +```python +from youdotcom import You +from youdotcom.models import ResearchEffort +from youdotcom.research_helpers import research_and_wait + +you = You() +detail = research_and_wait( + you, + input="Evaluate the measurable global-health impact of the Gates Foundation", + research_effort=ResearchEffort.FRONTIER, + # timeout_s auto-adjusts to 14400 (4 hours) for frontier when omitted +) +print(detail.result.model_dump()["output"]["content"]) +``` + +- **`lite` finance research effort tier**: New `FinanceResearchEffort.LITE` enum value for the Finance Research API. Returns answers quickly for straightforward financial questions. The default remains `deep`. No migration required; existing `DEEP` and `EXHAUSTIVE` values are unchanged. + +- **Research Background Mode**: The `you.research()` method now accepts an optional `background=True` parameter. When enabled, the API queues the research task and returns a `TaskResponse` (with `task_id`, `type`, `status`, `stream_url`, `created_at`) immediately instead of waiting for the inline `ResearchResponse`. Use the new methods to poll or stream the task to completion. + +- **`you.get_research_task(task_id=...)`**: Poll the status of a background research task via `GET /v1/research/{task_id}`. Returns a `TaskDetail` with `status`, `result` (populated when completed), `error` (populated when failed), and timing fields. + +- **`you.stream_research_task(task_id=..., from_id=0)`**: Stream real-time updates for a background research task via `GET /v1/research/{task_id}/stream` (Server-Sent Events). Returns an `EventStream` of `ResearchTaskStreamEvent` objects. The connection closes automatically when the task reaches a terminal state. Terminal event names: `response.done`, `complete`, `completed` (success); `error`, `failed`, `cancelled` (failure). + +- **Convenience helpers** in `youdotcom.research_helpers` (hand-maintained, regen-safe): + - `research_background(you, ...)` / `research_background_async(you, ...)` — submit and return `TaskResponse` directly (no Union narrowing needed). + - `poll_research_task(you, task_id, ...)` / `poll_research_task_async(...)` — poll until terminal status (`completed`, `failed`, `cancelled`). Defaults: `interval_s=2.0`, `timeout_s=600.0` (10 minutes). For `frontier` tasks, pass `timeout_s=14400` (4 hours) explicitly since `poll_research_task` receives a `task_id` and cannot auto-detect the effort tier. + - `research_and_wait(you, ...)` / `research_and_wait_async(...)` — submit with `background=True`, then stream SSE events until a terminal event arrives, and fetch the final `TaskDetail`. If the stream times out or closes without a terminal event, a final `get_research_task` call resolves the status (returns the detail if completed, raises `RuntimeError` for terminal non-completed, or `TimeoutError` if still running). For polling instead of streaming, use `poll_research_task` directly. **`timeout_s` auto-adjusts** based on `research_effort` when omitted: 600s (10 min) for standard/deep/exhaustive, 14400s (4 hours) for `frontier`. + - `stream_research(you, task_id, ...)` / `stream_research_async(...)` — tolerant SSE iterator that surfaces undocumented event types as raw dicts instead of crashing on validation. **Recommended over `you.stream_research_task()` for real research tasks**, since the server emits intermediate workflow events not in the strict `Event` enum. + +- **New models**: `TaskResponse`, `TaskResponseStatus`, `TaskDetail`, `TaskDetailStatus`, `TaskDetailInput`, `Result`, `GetResearchTaskRequest`, `StreamResearchTaskRequest`, `ResearchTaskStreamEvent`, `ResearchTaskStreamEventData`, `Event`, `ResearchResult` (Union alias). + +- **New error classes**: `GetResearchTaskUnauthorizedError`, `GetResearchTaskForbiddenError`, `GetResearchTaskNotFoundError`, `GetResearchTaskInternalServerError`, `StreamResearchTaskUnauthorizedError`, `StreamResearchTaskForbiddenError`, `StreamResearchTaskNotFoundError`, `StreamResearchTaskInternalServerError` (and their `*Data` variants). + +### Changed + +- **`you.research()` return type** is now `Union[ResearchResponse, TaskResponse]` (exposed as the `ResearchResult` alias). When `background=False` (the default), the return is `ResearchResponse` as before. When `background=True`, the return is `TaskResponse`. Use `isinstance(res, TaskResponse)` to narrow, or use the `research_helpers` convenience functions. + +### Notes + +- All 2.4.0 features (finance_research, source_control, output_schema, boost_domains, max_age, env-var precedence, user_agent hook) are unchanged. + ## [2.4.0] - 2026-07-14 ### Added diff --git a/MIGRATION.md b/MIGRATION.md index 1909d95..1a52ebd 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,188 @@ # Migration Guide -## 2.3.0 → 2.4.0 (Latest) +## 2.4.0 → 2.5.0 (Latest) + +### New `frontier` Research Effort Tier + +A new `ResearchEffort.FRONTIER` enum value has been added for the highest-quality, longest-running research tasks. Frontier runs over longer durations (up to 4 hours) with improved quality and accuracy. + +**Key constraint:** `frontier` only works with the task-based API (`background=true`). Sending `frontier` without `background=true` returns a `422`. + +```python +from youdotcom import You +from youdotcom.models import ResearchEffort +from youdotcom.research_helpers import research_and_wait + +you = You() + +# Submit a frontier task with background mode and wait for completion +detail = research_and_wait( + you, + input="Evaluate the measurable global-health impact of the Gates Foundation", + research_effort=ResearchEffort.FRONTIER, + timeout_s=14400, # frontier tasks can run up to 4 hours +) +print(detail.result.model_dump()["output"]["content"]) +``` + +You can also use the lower-level helpers for manual polling or streaming: + +```python +from youdotcom import You +from youdotcom.models import ResearchEffort +from youdotcom.research_helpers import research_background, poll_research_task + +you = You() + +task = research_background( + you, + input="Evaluate the measurable global-health impact of the Gates Foundation", + research_effort=ResearchEffort.FRONTIER, +) +detail = poll_research_task(you, task_id=task.task_id, timeout_s=14400) +``` + +No migration is required for existing code. The `frontier` tier is purely additive; existing `LITE`, `STANDARD`, `DEEP`, and `EXHAUSTIVE` values are unchanged. + +### New `lite` Finance Research Effort Tier + +A new `FinanceResearchEffort.LITE` enum value has been added for quick, straightforward financial questions. The default remains `deep`. + +```python +from youdotcom import You +from youdotcom.models import FinanceResearchEffort + +you = You() +res = you.finance_research( + input="What was Apple's revenue in FY2024?", + research_effort=FinanceResearchEffort.LITE, +) +``` + +No migration is required. Existing `DEEP` and `EXHAUSTIVE` values are unchanged. + +### New Background Mode for Research + +The `you.research()` method now accepts `background=True` to queue long-running research tasks asynchronously. The return type changes from `ResearchResponse` to `Union[ResearchResponse, TaskResponse]` (exposed as the `ResearchResult` alias). + +**Synchronous mode (default, unchanged):** + +```python +from youdotcom import You +from youdotcom.models import ResearchEffort, ResearchResponse + +you = You() +res = you.research(input="...", research_effort=ResearchEffort.DEEP) +# res is ResearchResponse — same as 2.4.0 +assert isinstance(res, ResearchResponse) +``` + +**Background mode (new):** + +```python +from youdotcom import You +from youdotcom.models import ResearchEffort, TaskResponse, TaskDetail + +you = You() + +# Submit and get a task handle +res = you.research(input="...", research_effort=ResearchEffort.DEEP, background=True) +assert isinstance(res, TaskResponse) +print(res.task_id, res.stream_url) + +# Poll for completion +detail = you.get_research_task(task_id=res.task_id) +if detail.status.value == "completed": + payload = detail.result.model_dump() # extra="allow" preserves the full ResearchResponse + print(payload["output"]["content"]) + +# Or stream events with the tolerant helper (recommended) +from youdotcom.research_helpers import stream_research +for event in stream_research(you, task_id=res.task_id): + print(event.event, event.data) + if event.event in ("response.done", "complete", "completed", "error", "failed", "cancelled"): + break +``` + +**Or use the convenience helpers:** + +```python +from youdotcom import You +from youdotcom.models import ResearchEffort +from youdotcom.research_helpers import ( + research_background, poll_research_task, research_and_wait, + stream_research, +) + +you = You() + +# Option 1: Submit and wait in one call (simplest) +# Streams SSE events until the task completes, then fetches the result. +# If the stream times out or closes without a terminal event, a final +# get_research_task call resolves the status (returns the detail if +# completed, raises RuntimeError for terminal non-completed, or +# TimeoutError if still running). +detail = research_and_wait( + you, input="...", research_effort=ResearchEffort.DEEP, +) + +# Option 2: Submit, then poll manually +task = research_background(you, input="...", research_effort=ResearchEffort.DEEP) +detail = poll_research_task(you, task_id=task.task_id) + +# Option 3: Stream SSE events with a tolerant decoder +# (recommended over you.stream_research_task for real tasks, since +# the server emits intermediate event types not in the strict enum) +for event in stream_research(you, task_id=task.task_id): + print(event.event, event.data) + if event.event in ("response.done", "complete", "completed"): + break +``` + +> **Note on streaming:** The generated `you.stream_research_task()` method uses a strict pydantic decoder that validates event names against a fixed `Event` enum. The server emits intermediate workflow events (e.g. `response.created`, `response.starting`, `response.output_item.added`) that are not in this enum, which causes `ResponseValidationError` on the first intermediate event. The `stream_research()` helper uses a tolerant decoder that surfaces unknown event names as raw dicts instead of crashing. For real research tasks, prefer `stream_research()`. + +### Polling and Timeout Guidance + +The `poll_research_task` helper polls `GET /v1/research/{task_id}` at a configurable interval until the task reaches a terminal state. The `research_and_wait` helper streams SSE events and enforces a total wall-clock timeout. Both default sensibly for most effort tiers, but `frontier` tasks can run much longer. + +**`research_and_wait` auto-adjusts `timeout_s` based on `research_effort`** when you don't pass an explicit value: + +| `research_effort` | Auto `timeout_s` | Typical latency | +|-------------------|-------------------|-----------------| +| `lite` | 600s (10 min) | seconds | +| `standard` | 600s (10 min) | 30-120s | +| `deep` | 600s (10 min) | 120-300s | +| `exhaustive` | 600s (10 min) | 300-600s | +| `frontier` | 14400s (4 hours) | 300s - 4 hours | + +**`poll_research_task` does not auto-adjust** (it receives a `task_id`, not the effort level). You must set `timeout_s` explicitly for frontier: + +```python +from youdotcom import You +from youdotcom.models import ResearchEffort +from youdotcom.research_helpers import research_background, poll_research_task + +you = You() + +task = research_background( + you, input="...", research_effort=ResearchEffort.FRONTIER, +) +# poll_research_task defaults: interval_s=2.0, timeout_s=600.0 +# Override for frontier: +detail = poll_research_task( + you, task_id=task.task_id, + interval_s=5.0, # less aggressive for long-running tasks + timeout_s=14400, # 4 hours +) +``` + +### No Breaking Changes for Existing Code + +If you do not use `background=True`, your existing `you.research()` calls are unchanged at runtime. The return is still `ResearchResponse` when `background` is omitted or `False`. + +> **Type-level note for statically-typed callers:** The return type of `you.research()` widened from `ResearchResponse` to `Union[ResearchResponse, TaskResponse]`. If your code accesses `res.output` directly (or passes the result where a `ResearchResponse` is expected), `mypy`/`pyright` will flag it because `TaskResponse` has no `output` field. Add an `isinstance(res, ResearchResponse)` narrow or use `cast(ResearchResponse, res)` to satisfy type checkers. This only affects type checking, not runtime behavior. + +## 2.3.0 → 2.4.0 This guide covers breaking changes introduced in 2.4.0. If you are upgrading from 1.x or 2.0, also read the [1.x → 2.0](#1x-to-20) section below. diff --git a/README.md b/README.md index 7d7f1ef..d9d0461 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,8 @@ with You( * [search_post](docs/sdks/you/README.md#search_post) - Returns a list of unified search results from web and news sources * [research](docs/sdks/you/README.md#research) - Returns comprehensive research-grade answers with multi-step reasoning +* [get_research_task](docs/sdks/you/README.md#get_research_task) - Get the status of a background research task +* [stream_research_task](docs/sdks/you/README.md#stream_research_task) - Stream updates for a background research task * [finance_research](docs/sdks/you/README.md#finance_research) - Returns comprehensive finance-grade research answers with multi-step reasoning ### [Agents.Runs](docs/sdks/runs/README.md) @@ -445,7 +447,7 @@ with You( **Primary error:** * [`YouError`](./src/youdotcom/errors/youerror.py): The base class for HTTP error responses. -
Less common errors (23) +
Less common errors (31)
@@ -456,24 +458,32 @@ with You( **Inherit from [`YouError`](./src/youdotcom/errors/youerror.py)**: -* [`UnauthorizedResponseError`](./src/youdotcom/errors/unauthorizedresponseerror.py): Unauthorized. Problems with API key. Status code `401`. Applicable to 2 of 6 methods.* -* [`ForbiddenResponseError`](./src/youdotcom/errors/forbiddenresponseerror.py): Forbidden. API key lacks scope for this path. Status code `403`. Applicable to 2 of 6 methods.* -* [`UnprocessableEntityResponseError`](./src/youdotcom/errors/unprocessableentityresponseerror.py): Unprocessable Entity. Invalid request parameter combination. Status code `422`. Applicable to 2 of 6 methods.* -* [`InternalServerErrorResponse`](./src/youdotcom/errors/internalservererrorresponse.py): Internal Server Error during authentication/authorization middleware. Status code `500`. Applicable to 2 of 6 methods.* -* [`AgentRuns400ResponseError`](./src/youdotcom/errors/agentruns400responseerror.py): The message returned by the error. Status code `400`. Applicable to 1 of 6 methods.* -* [`ResearchUnauthorizedError`](./src/youdotcom/errors/researchunauthorizederror.py): Unauthorized. Problems with API key. Status code `401`. Applicable to 1 of 6 methods.* -* [`FinanceResearchUnauthorizedError`](./src/youdotcom/errors/financeresearchunauthorizederror.py): Unauthorized. Problems with API key. Status code `401`. Applicable to 1 of 6 methods.* -* [`ContentsUnauthorizedError`](./src/youdotcom/errors/contentsunauthorizederror.py): Unauthorized. Problems with API key. Status code `401`. Applicable to 1 of 6 methods.* -* [`AgentRuns401ResponseError`](./src/youdotcom/errors/agentruns401responseerror.py): The message returned by the error. Status code `401`. Applicable to 1 of 6 methods.* -* [`ResearchForbiddenError`](./src/youdotcom/errors/researchforbiddenerror.py): Forbidden. API key lacks scope for this path. Status code `403`. Applicable to 1 of 6 methods.* -* [`FinanceResearchForbiddenError`](./src/youdotcom/errors/financeresearchforbiddenerror.py): Forbidden. API key lacks scope for this path. Status code `403`. Applicable to 1 of 6 methods.* -* [`ContentsForbiddenError`](./src/youdotcom/errors/contentsforbiddenerror.py): Forbidden. API key lacks scope for this path. Status code `403`. Applicable to 1 of 6 methods.* -* [`ResearchUnprocessableEntityError`](./src/youdotcom/errors/researchunprocessableentityerror.py): Unprocessable Entity. Request validation failed. Status code `422`. Applicable to 1 of 6 methods.* -* [`FinanceResearchUnprocessableEntityError`](./src/youdotcom/errors/financeresearchunprocessableentityerror.py): Unprocessable Entity. Request validation failed. Status code `422`. Applicable to 1 of 6 methods.* -* [`AgentRuns422ResponseError`](./src/youdotcom/errors/agentruns422responseerror.py): Unprocessable Entity - Invalid request data. Status code `422`. Applicable to 1 of 6 methods.* -* [`ResearchInternalServerError`](./src/youdotcom/errors/researchinternalservererror.py): Internal Server Error during authentication/authorization middleware. Status code `500`. Applicable to 1 of 6 methods.* -* [`FinanceResearchInternalServerError`](./src/youdotcom/errors/financeresearchinternalservererror.py): Internal Server Error during authentication/authorization middleware. Status code `500`. Applicable to 1 of 6 methods.* -* [`ContentsInternalServerError`](./src/youdotcom/errors/contentsinternalservererror.py): Internal Server Error during authentication/authorization middleware. Status code `500`. Applicable to 1 of 6 methods.* +* [`UnauthorizedResponseError`](./src/youdotcom/errors/unauthorizedresponseerror.py): Unauthorized. Problems with API key. Status code `401`. Applicable to 2 of 8 methods.* +* [`ForbiddenResponseError`](./src/youdotcom/errors/forbiddenresponseerror.py): Forbidden. API key lacks scope for this path. Status code `403`. Applicable to 2 of 8 methods.* +* [`UnprocessableEntityResponseError`](./src/youdotcom/errors/unprocessableentityresponseerror.py): Unprocessable Entity. Invalid request parameter combination. Status code `422`. Applicable to 2 of 8 methods.* +* [`InternalServerErrorResponse`](./src/youdotcom/errors/internalservererrorresponse.py): Internal Server Error during authentication/authorization middleware. Status code `500`. Applicable to 2 of 8 methods.* +* [`AgentRuns400ResponseError`](./src/youdotcom/errors/agentruns400responseerror.py): The message returned by the error. Status code `400`. Applicable to 1 of 8 methods.* +* [`ResearchUnauthorizedError`](./src/youdotcom/errors/researchunauthorizederror.py): Unauthorized. Problems with API key. Status code `401`. Applicable to 1 of 8 methods.* +* [`FinanceResearchUnauthorizedError`](./src/youdotcom/errors/financeresearchunauthorizederror.py): Unauthorized. Problems with API key. Status code `401`. Applicable to 1 of 8 methods.* +* [`ContentsUnauthorizedError`](./src/youdotcom/errors/contentsunauthorizederror.py): Unauthorized. Problems with API key. Status code `401`. Applicable to 1 of 8 methods.* +* [`AgentRuns401ResponseError`](./src/youdotcom/errors/agentruns401responseerror.py): The message returned by the error. Status code `401`. Applicable to 1 of 8 methods.* +* [`ResearchForbiddenError`](./src/youdotcom/errors/researchforbiddenerror.py): Forbidden. API key lacks scope for this path. Status code `403`. Applicable to 1 of 8 methods.* +* [`FinanceResearchForbiddenError`](./src/youdotcom/errors/financeresearchforbiddenerror.py): Forbidden. API key lacks scope for this path. Status code `403`. Applicable to 1 of 8 methods.* +* [`ContentsForbiddenError`](./src/youdotcom/errors/contentsforbiddenerror.py): Forbidden. API key lacks scope for this path. Status code `403`. Applicable to 1 of 8 methods.* +* [`ResearchUnprocessableEntityError`](./src/youdotcom/errors/researchunprocessableentityerror.py): Unprocessable Entity. Request validation failed. Status code `422`. Applicable to 1 of 8 methods.* +* [`FinanceResearchUnprocessableEntityError`](./src/youdotcom/errors/financeresearchunprocessableentityerror.py): Unprocessable Entity. Request validation failed. Status code `422`. Applicable to 1 of 8 methods.* +* [`AgentRuns422ResponseError`](./src/youdotcom/errors/agentruns422responseerror.py): Unprocessable Entity - Invalid request data. Status code `422`. Applicable to 1 of 8 methods.* +* [`ResearchInternalServerError`](./src/youdotcom/errors/researchinternalservererror.py): Internal Server Error during authentication/authorization middleware. Status code `500`. Applicable to 1 of 8 methods.* +* [`FinanceResearchInternalServerError`](./src/youdotcom/errors/financeresearchinternalservererror.py): Internal Server Error during authentication/authorization middleware. Status code `500`. Applicable to 1 of 8 methods.* +* [`ContentsInternalServerError`](./src/youdotcom/errors/contentsinternalservererror.py): Internal Server Error during authentication/authorization middleware. Status code `500`. Applicable to 1 of 8 methods.* +* [`GetResearchTaskUnauthorizedError`](./src/youdotcom/errors/getresearchtaskop.py): Unauthorized. Problems with API key. Status code `401`. Applicable to 1 of 8 methods.* +* [`GetResearchTaskForbiddenError`](./src/youdotcom/errors/getresearchtaskop.py): Forbidden. API key lacks scope for this path. Status code `403`. Applicable to 1 of 8 methods.* +* [`GetResearchTaskNotFoundError`](./src/youdotcom/errors/getresearchtaskop.py): Not Found. Task does not exist or does not belong to the caller. Status code `404`. Applicable to 1 of 8 methods.* +* [`GetResearchTaskInternalServerError`](./src/youdotcom/errors/getresearchtaskop.py): Internal Server Error during authentication/authorization middleware. Status code `500`. Applicable to 1 of 8 methods.* +* [`StreamResearchTaskUnauthorizedError`](./src/youdotcom/errors/streamresearchtaskop.py): Unauthorized. Problems with API key. Status code `401`. Applicable to 1 of 8 methods.* +* [`StreamResearchTaskForbiddenError`](./src/youdotcom/errors/streamresearchtaskop.py): Forbidden. API key lacks scope for this path. Status code `403`. Applicable to 1 of 8 methods.* +* [`StreamResearchTaskNotFoundError`](./src/youdotcom/errors/streamresearchtaskop.py): Not Found. Task does not exist or does not belong to the caller. Status code `404`. Applicable to 1 of 8 methods.* +* [`StreamResearchTaskInternalServerError`](./src/youdotcom/errors/streamresearchtaskop.py): Internal Server Error during authentication/authorization middleware. Status code `500`. Applicable to 1 of 8 methods.* * [`ResponseValidationError`](./src/youdotcom/errors/responsevalidationerror.py): Type mismatch between the response data and the expected Pydantic model. Provides access to the Pydantic validation error via the `cause` attribute.
diff --git a/docs/errors/getresearchtaskforbiddenerror.md b/docs/errors/getresearchtaskforbiddenerror.md new file mode 100644 index 0000000..cc633bb --- /dev/null +++ b/docs/errors/getresearchtaskforbiddenerror.md @@ -0,0 +1,10 @@ +# GetResearchTaskForbiddenError + +Forbidden. API key lacks scope for this path. + + +## Fields + +| Field | Type | Required | Description | +| ------------------ | ------------------ | ------------------ | ------------------ | +| `detail` | *Optional[str]* | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/docs/errors/getresearchtaskinternalservererror.md b/docs/errors/getresearchtaskinternalservererror.md new file mode 100644 index 0000000..ebed213 --- /dev/null +++ b/docs/errors/getresearchtaskinternalservererror.md @@ -0,0 +1,10 @@ +# GetResearchTaskInternalServerError + +Internal Server Error. + + +## Fields + +| Field | Type | Required | Description | +| ------------------ | ------------------ | ------------------ | ------------------ | +| `detail` | *Optional[str]* | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/docs/errors/getresearchtasknotfounderror.md b/docs/errors/getresearchtasknotfounderror.md new file mode 100644 index 0000000..cbb274c --- /dev/null +++ b/docs/errors/getresearchtasknotfounderror.md @@ -0,0 +1,10 @@ +# GetResearchTaskNotFoundError + +Task not found or not authorized. + + +## Fields + +| Field | Type | Required | Description | Example | +| ------------------ | ------------------ | ------------------ | ------------------ | ------------------ | +| `detail` | *Optional[str]* | :heavy_minus_sign: | N/A | Task not found | \ No newline at end of file diff --git a/docs/errors/getresearchtaskunauthorizederror.md b/docs/errors/getresearchtaskunauthorizederror.md new file mode 100644 index 0000000..5ed32fa --- /dev/null +++ b/docs/errors/getresearchtaskunauthorizederror.md @@ -0,0 +1,10 @@ +# GetResearchTaskUnauthorizedError + +Unauthorized. Problems with API key. + + +## Fields + +| Field | Type | Required | Description | +| ------------------ | ------------------ | ------------------ | ------------------ | +| `detail` | *Optional[str]* | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/docs/errors/streamresearchtaskforbiddenerror.md b/docs/errors/streamresearchtaskforbiddenerror.md new file mode 100644 index 0000000..f13d63a --- /dev/null +++ b/docs/errors/streamresearchtaskforbiddenerror.md @@ -0,0 +1,10 @@ +# StreamResearchTaskForbiddenError + +Forbidden. API key lacks scope for this path. + + +## Fields + +| Field | Type | Required | Description | +| ------------------ | ------------------ | ------------------ | ------------------ | +| `detail` | *Optional[str]* | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/docs/errors/streamresearchtaskinternalservererror.md b/docs/errors/streamresearchtaskinternalservererror.md new file mode 100644 index 0000000..e7d0083 --- /dev/null +++ b/docs/errors/streamresearchtaskinternalservererror.md @@ -0,0 +1,10 @@ +# StreamResearchTaskInternalServerError + +Internal Server Error. + + +## Fields + +| Field | Type | Required | Description | +| ------------------ | ------------------ | ------------------ | ------------------ | +| `detail` | *Optional[str]* | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/docs/errors/streamresearchtasknotfounderror.md b/docs/errors/streamresearchtasknotfounderror.md new file mode 100644 index 0000000..8d8ff6b --- /dev/null +++ b/docs/errors/streamresearchtasknotfounderror.md @@ -0,0 +1,10 @@ +# StreamResearchTaskNotFoundError + +Task not found or not authorized. + + +## Fields + +| Field | Type | Required | Description | Example | +| ------------------ | ------------------ | ------------------ | ------------------ | ------------------ | +| `detail` | *Optional[str]* | :heavy_minus_sign: | N/A | Task not found | \ No newline at end of file diff --git a/docs/errors/streamresearchtaskunauthorizederror.md b/docs/errors/streamresearchtaskunauthorizederror.md new file mode 100644 index 0000000..86e9ae7 --- /dev/null +++ b/docs/errors/streamresearchtaskunauthorizederror.md @@ -0,0 +1,10 @@ +# StreamResearchTaskUnauthorizedError + +Unauthorized. Problems with API key. + + +## Fields + +| Field | Type | Required | Description | +| ------------------ | ------------------ | ------------------ | ------------------ | +| `detail` | *Optional[str]* | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/docs/models/event.md b/docs/models/event.md new file mode 100644 index 0000000..67efe4c --- /dev/null +++ b/docs/models/event.md @@ -0,0 +1,24 @@ +# Event + +The type of the SSE event. Terminal events that close the stream are: `response.done`, `complete`, `error`, and `cancelled`. The stream may also emit `completed`, `failed`, or `cancelled` as event names corresponding to the task's terminal status. + +## Example Usage + +```python +from youdotcom.models import Event + +value = Event.CONNECTED +``` + + +## Values + +| Name | Value | +| --------------- | --------------- | +| `CONNECTED` | connected | +| `RESPONSE_DONE` | response.done | +| `COMPLETE` | complete | +| `COMPLETED` | completed | +| `ERROR` | error | +| `FAILED` | failed | +| `CANCELLED` | cancelled | \ No newline at end of file diff --git a/docs/models/financeresearcheffort.md b/docs/models/financeresearcheffort.md index 9402be1..6c7ebde 100644 --- a/docs/models/financeresearcheffort.md +++ b/docs/models/financeresearcheffort.md @@ -3,6 +3,7 @@ Controls how much time and effort the Finance Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time. Available levels: +- `lite`: Returns answers quickly. Good for straightforward financial questions that just need a fast, reliable answer. - `deep`: The default. Spends more time researching and cross-referencing sources. Good for most financial questions, including multi-company comparisons, earnings analysis, and regulatory research. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex financial research tasks where you want the highest quality result. @@ -19,5 +20,6 @@ value = FinanceResearchEffort.DEEP | Name | Value | | ------------ | ------------ | +| `LITE` | lite | | `DEEP` | deep | | `EXHAUSTIVE` | exhaustive | \ No newline at end of file diff --git a/docs/models/getresearchtaskrequest.md b/docs/models/getresearchtaskrequest.md new file mode 100644 index 0000000..ba0a7b2 --- /dev/null +++ b/docs/models/getresearchtaskrequest.md @@ -0,0 +1,8 @@ +# GetResearchTaskRequest + + +## Fields + +| Field | Type | Required | Description | +| ------------------------------ | ------------------------------ | ------------------------------ | ------------------------------ | +| `task_id` | *str* | :heavy_check_mark: | The UUID of the research task. | \ No newline at end of file diff --git a/docs/models/researcheffort.md b/docs/models/researcheffort.md index 27ca90d..9203639 100644 --- a/docs/models/researcheffort.md +++ b/docs/models/researcheffort.md @@ -7,6 +7,7 @@ Available levels: - `standard`: The default. Balances speed and depth, a good fit for most questions. - `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result. +- `frontier`: The highest-quality tier. Runs over longer durations with improved quality and accuracy. Only works with the task-based API (`background=true`); sending `frontier` without `background=true` returns a 422. ## Example Usage @@ -24,4 +25,5 @@ value = ResearchEffort.LITE | `LITE` | lite | | `STANDARD` | standard | | `DEEP` | deep | -| `EXHAUSTIVE` | exhaustive | \ No newline at end of file +| `EXHAUSTIVE` | exhaustive | +| `FRONTIER` | frontier | \ No newline at end of file diff --git a/docs/models/researchrequest.md b/docs/models/researchrequest.md index 6a08aa1..cd83057 100644 --- a/docs/models/researchrequest.md +++ b/docs/models/researchrequest.md @@ -6,6 +6,7 @@ | Field | Type | Required | Description | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `input` | *str* | :heavy_check_mark: | The research question or complex query requiring in-depth investigation and multi-step reasoning.

Note: The maximum length of the input is 40,000 characters. | -| `research_effort` | [Optional[models.ResearchEffort]](../models/researcheffort.md) | :heavy_minus_sign: | Controls how much time and effort the Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time.

Available levels:
- `lite`: Returns answers quickly. Good for straightforward questions that just need a fast, reliable answer.
- `standard`: The default. Balances speed and depth, a good fit for most questions.
- `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed.
- `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result. | +| `research_effort` | [Optional[models.ResearchEffort]](../models/researcheffort.md) | :heavy_minus_sign: | Controls how much time and effort the Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time.

Available levels:
- `lite`: Returns answers quickly. Good for straightforward questions that just need a fast, reliable answer.
- `standard`: The default. Balances speed and depth, a good fit for most questions.
- `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed.
- `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result.
- `frontier`: The highest-quality, longest-running tier (up to 4 hours). Requires `background=true`; returns 422 otherwise. | +| `background` | *Optional[bool]* | :heavy_minus_sign: | When true, queue a research task and return a task handle immediately instead of waiting for the result inline. Defaults to synchronous. When enabled, the response is a TaskResponse object with a task_id and stream_url for polling progress via GET /v1/research/{task_id} or streaming via GET /v1/research/{task_id}/stream. | | `source_control` | [Optional[models.SourceControl]](../models/sourcecontrol.md) | :heavy_minus_sign: | Beta. Controls which web sources the research agent searches and visits. Use this to allow specific domains, block specific domains, boost specific domains, filter by recency, or focus web results by country.

`include_domains` and `exclude_domains` cannot be used together. Each domain list is capped at 500 entries. `exclude_domains` also blocks the research agent from visiting pages on those domains during browsing. `boost_domains` gives matching domains a relative ranking boost without filtering out other domains. It can be combined with `exclude_domains` but cannot be combined with `include_domains`. | | `output_schema` | Dict[str, *Any*] | :heavy_minus_sign: | Beta. Requests structured JSON output in output.content using a supported JSON Schema subset. Supported only with research_effort values standard, deep, and exhaustive. Sending output_schema with research_effort: "lite" returns 422.

Schema rules: Root must be a JSON object. Top-level anyOf is not allowed. Every object must define properties and set additionalProperties: false. Every property must be listed in required. Recursive schemas are not supported.

Limits: Max nesting depth 5, max total properties 100, max total enum values 500, max total schema string budget 25,000. | \ No newline at end of file diff --git a/docs/models/researchresult.md b/docs/models/researchresult.md new file mode 100644 index 0000000..2a4c454 --- /dev/null +++ b/docs/models/researchresult.md @@ -0,0 +1,19 @@ +# ResearchResult + +A JSON object containing a comprehensive answer with citations and supporting search results. When background=true, returns a task handle instead. + + +## Supported Types + +### `models.ResearchResponse` + +```python +value: models.ResearchResponse = /* values here */ +``` + +### `models.TaskResponse` + +```python +value: models.TaskResponse = /* values here */ +``` + diff --git a/docs/models/researchtaskstreamevent.md b/docs/models/researchtaskstreamevent.md new file mode 100644 index 0000000..0fe2392 --- /dev/null +++ b/docs/models/researchtaskstreamevent.md @@ -0,0 +1,12 @@ +# ResearchTaskStreamEvent + +A server-sent event for a background research task stream. + + +## Fields + +| Field | Type | Required | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | *str* | :heavy_check_mark: | Sequence number of the SSE event. | +| `event` | [models.Event](../models/event.md) | :heavy_check_mark: | The type of the SSE event. Terminal events that close the stream are: `response.done`, `complete`, `error`, and `cancelled`. The stream may also emit `completed`, `failed`, or `cancelled` as event names corresponding to the task's terminal status. | +| `data` | [models.ResearchTaskStreamEventData](../models/researchtaskstreameventdata.md) | :heavy_check_mark: | The event payload. Structure varies by event type. Common fields include type, task_id, status, data (event-specific), error, and sequence. | \ No newline at end of file diff --git a/docs/models/researchtaskstreameventdata.md b/docs/models/researchtaskstreameventdata.md new file mode 100644 index 0000000..56634db --- /dev/null +++ b/docs/models/researchtaskstreameventdata.md @@ -0,0 +1,15 @@ +# ResearchTaskStreamEventData + +The event payload. Structure varies by event type. Common fields include type, task_id, status, data (event-specific), error, and sequence. + + +## Fields + +| Field | Type | Required | Description | +| ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | +| `type` | *Optional[str]* | :heavy_minus_sign: | The event type identifier. | +| `task_id` | *Optional[str]* | :heavy_minus_sign: | The task UUID. | +| `status` | *Optional[str]* | :heavy_minus_sign: | Current task status when the event was emitted. | +| `data` | Dict[str, *Any*] | :heavy_minus_sign: | Event-specific payload data. | +| `error` | *OptionalNullable[str]* | :heavy_minus_sign: | Error message if the event represents an error. | +| `sequence` | *Optional[int]* | :heavy_minus_sign: | Event sequence number. | \ No newline at end of file diff --git a/docs/models/result.md b/docs/models/result.md new file mode 100644 index 0000000..bca20b4 --- /dev/null +++ b/docs/models/result.md @@ -0,0 +1,9 @@ +# Result + +The task result when completed. For research tasks, this contains the ResearchResponse output. + + +## Fields + +| Field | Type | Required | Description | +| ----------- | ----------- | ----------- | ----------- | \ No newline at end of file diff --git a/docs/models/streamresearchtaskrequest.md b/docs/models/streamresearchtaskrequest.md new file mode 100644 index 0000000..1b8c0b3 --- /dev/null +++ b/docs/models/streamresearchtaskrequest.md @@ -0,0 +1,9 @@ +# StreamResearchTaskRequest + + +## Fields + +| Field | Type | Required | Description | +| ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | +| `task_id` | *str* | :heavy_check_mark: | The UUID of the research task. | +| `from_id` | *Optional[int]* | :heavy_minus_sign: | Resume from a sequence number for reconnection. | \ No newline at end of file diff --git a/docs/models/taskdetail.md b/docs/models/taskdetail.md new file mode 100644 index 0000000..2322a7e --- /dev/null +++ b/docs/models/taskdetail.md @@ -0,0 +1,16 @@ +# TaskDetail + + +## Fields + +| Field | Type | Required | Description | Example | +| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `id` | *str* | :heavy_check_mark: | Unique identifier for the task. | | +| `task_type` | *str* | :heavy_check_mark: | Task type. | research | +| `status` | [models.TaskDetailStatus](../models/taskdetailstatus.md) | :heavy_check_mark: | Current status of the task. | | +| `created_at` | [datetime](https://docs.python.org/3/library/datetime.html#datetime-objects) | :heavy_check_mark: | When the task was created. | | +| `updated_at` | [datetime](https://docs.python.org/3/library/datetime.html#datetime-objects) | :heavy_check_mark: | When the task was last updated. | | +| `completed_at` | [datetime](https://docs.python.org/3/library/datetime.html#datetime-objects) | :heavy_minus_sign: | When the task completed, if applicable. | | +| `error` | *OptionalNullable[str]* | :heavy_minus_sign: | Error message if the task failed. | | +| `input` | [OptionalNullable[models.TaskDetailInput]](../models/taskdetailinput.md) | :heavy_minus_sign: | The original request input for the task. | | +| `result` | [OptionalNullable[models.Result]](../models/result.md) | :heavy_minus_sign: | The task result when completed. For research tasks, this contains the ResearchResponse output. | | \ No newline at end of file diff --git a/docs/models/taskdetailinput.md b/docs/models/taskdetailinput.md new file mode 100644 index 0000000..4470b82 --- /dev/null +++ b/docs/models/taskdetailinput.md @@ -0,0 +1,9 @@ +# TaskDetailInput + +The original request input for the task. + + +## Fields + +| Field | Type | Required | Description | +| ----------- | ----------- | ----------- | ----------- | \ No newline at end of file diff --git a/docs/models/taskdetailstatus.md b/docs/models/taskdetailstatus.md new file mode 100644 index 0000000..94533f5 --- /dev/null +++ b/docs/models/taskdetailstatus.md @@ -0,0 +1,22 @@ +# TaskDetailStatus + +Current status of the task. + +## Example Usage + +```python +from youdotcom.models import TaskDetailStatus + +value = TaskDetailStatus.QUEUED +``` + + +## Values + +| Name | Value | +| ----------- | ----------- | +| `QUEUED` | queued | +| `RUNNING` | running | +| `COMPLETED` | completed | +| `FAILED` | failed | +| `CANCELLED` | cancelled | \ No newline at end of file diff --git a/docs/models/taskresponse.md b/docs/models/taskresponse.md new file mode 100644 index 0000000..42945a3 --- /dev/null +++ b/docs/models/taskresponse.md @@ -0,0 +1,12 @@ +# TaskResponse + + +## Fields + +| Field | Type | Required | Description | Example | +| -------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `task_id` | *str* | :heavy_check_mark: | Unique identifier for the task. | | +| `type` | *str* | :heavy_check_mark: | Task type. | research | +| `status` | [models.TaskResponseStatus](../models/taskresponsestatus.md) | :heavy_check_mark: | Current status of the task. | queued | +| `stream_url` | *str* | :heavy_check_mark: | URL to stream task events via SSE. | /v1/research/a1b2c3d4-0000-0000-0000-000000000000/stream | +| `created_at` | [datetime](https://docs.python.org/3/library/datetime.html#datetime-objects) | :heavy_check_mark: | When the task was created. | | \ No newline at end of file diff --git a/docs/models/taskresponsestatus.md b/docs/models/taskresponsestatus.md new file mode 100644 index 0000000..b6ee96e --- /dev/null +++ b/docs/models/taskresponsestatus.md @@ -0,0 +1,22 @@ +# TaskResponseStatus + +Current status of the task. + +## Example Usage + +```python +from youdotcom.models import TaskResponseStatus + +value = TaskResponseStatus.QUEUED +``` + + +## Values + +| Name | Value | +| ----------- | ----------- | +| `QUEUED` | queued | +| `RUNNING` | running | +| `COMPLETED` | completed | +| `FAILED` | failed | +| `CANCELLED` | cancelled | \ No newline at end of file diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index be10607..58c8b5a 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -18,6 +18,8 @@ Comprehensive API for You.com services: * [search_post](#search_post) - Returns a list of unified search results from web and news sources * [research](#research) - Returns comprehensive research-grade answers with multi-step reasoning +* [get_research_task](#get_research_task) - Get the status of a background research task +* [stream_research_task](#stream_research_task) - Stream updates for a background research task * [finance_research](#finance_research) - Returns comprehensive finance-grade research answers with multi-step reasoning ## search_post @@ -419,7 +421,7 @@ with You( | Parameter | Type | Required | Description | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `input` | *str* | :heavy_check_mark: | The research question or complex query requiring in-depth investigation and multi-step reasoning.

Note: The maximum length of the input is 40,000 characters. | -| `research_effort` | [Optional[models.ResearchEffort]](../../models/researcheffort.md) | :heavy_minus_sign: | Controls how much time and effort the Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time.

Available levels:
- `lite`: Returns answers quickly. Good for straightforward questions that just need a fast, reliable answer.
- `standard`: The default. Balances speed and depth, a good fit for most questions.
- `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed.
- `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result. | +| `research_effort` | [Optional[models.ResearchEffort]](../../models/researcheffort.md) | :heavy_minus_sign: | Controls how much time and effort the Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time.

Available levels:
- `lite`: Returns answers quickly. Good for straightforward questions that just need a fast, reliable answer.
- `standard`: The default. Balances speed and depth, a good fit for most questions.
- `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed.
- `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result.
- `frontier`: The highest-quality tier. Runs over longer durations with improved quality and accuracy. Only works with the task-based API (`background=true`); sending `frontier` without `background=true` returns a 422. | | `source_control` | [Optional[models.SourceControl]](../../models/sourcecontrol.md) | :heavy_minus_sign: | Beta. Controls which web sources the research agent searches and visits. Use this to allow specific domains, block specific domains, boost specific domains, filter by recency, or focus web results by country.

`include_domains` and `exclude_domains` cannot be used together. Each domain list is capped at 500 entries. `exclude_domains` also blocks the research agent from visiting pages on those domains during browsing. `boost_domains` gives matching domains a relative ranking boost without filtering out other domains. It can be combined with `exclude_domains` but cannot be combined with `include_domains`. | | `output_schema` | Dict[str, *Any*] | :heavy_minus_sign: | Beta. Requests structured JSON output in output.content using a supported JSON Schema subset. Supported only with research_effort values standard, deep, and exhaustive. Sending output_schema with research_effort: "lite" returns 422.

Schema rules: Root must be a JSON object. Top-level anyOf is not allowed. Every object must define properties and set additionalProperties: false. Every property must be listed in required. Recursive schemas are not supported.

Limits: Max nesting depth 5, max total properties 100, max total enum values 500, max total schema string budget 25,000. | | `retries` | [Optional[utils.RetryConfig]](../../models/utils/retryconfig.md) | :heavy_minus_sign: | Configuration to override the default retry behavior of the client. | @@ -438,6 +440,97 @@ with You( | errors.ResearchInternalServerError | 500 | application/json | | errors.YouDefaultError | 4XX, 5XX | \*/\* | +## get_research_task + +Poll the status of a background research task created with background=true. When the task is completed, the result is included in the response. + +### Example Usage + + +```python +import os +from youdotcom import You + + +with You( + api_key_auth=os.getenv("YDC_API_KEY", ""), +) as you: + + res = you.get_research_task(task_id="586a9bc3-2c52-499c-a61d-be3cc9170c51") + + # Handle response + print(res) + +``` + +### Parameters + +| Parameter | Type | Required | Description | +| ------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `task_id` | *str* | :heavy_check_mark: | The UUID of the research task. | +| `retries` | [Optional[utils.RetryConfig]](../../models/utils/retryconfig.md) | :heavy_minus_sign: | Configuration to override the default retry behavior of the client. | + +### Response + +**[models.TaskDetail](../../models/taskdetail.md)** + +### Errors + +| Error Type | Status Code | Content Type | +| ----------------------------------------- | ----------------------------------------- | ----------------------------------------- | +| errors.GetResearchTaskUnauthorizedError | 401 | application/json | +| errors.GetResearchTaskForbiddenError | 403 | application/json | +| errors.GetResearchTaskNotFoundError | 404 | application/json | +| errors.GetResearchTaskInternalServerError | 500 | application/json | +| errors.YouDefaultError | 4XX, 5XX | \*/\* | + +## stream_research_task + +Stream real-time updates for a background research task via Server-Sent Events (SSE). Supports reconnection via the from_id query parameter to replay missed events. The connection closes automatically when the task reaches a terminal state. + +### Example Usage + + +```python +import os +from youdotcom import You + + +with You( + api_key_auth=os.getenv("YDC_API_KEY", ""), +) as you: + + res = you.stream_research_task(task_id="b431835b-e51d-453e-a623-25615ac31489", from_id=0) + + with res as event_stream: + for event in event_stream: + # handle event + print(event, flush=True) + +``` + +### Parameters + +| Parameter | Type | Required | Description | +| ------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `task_id` | *str* | :heavy_check_mark: | The UUID of the research task. | +| `from_id` | *Optional[int]* | :heavy_minus_sign: | Resume from a sequence number for reconnection. | +| `retries` | [Optional[utils.RetryConfig]](../../models/utils/retryconfig.md) | :heavy_minus_sign: | Configuration to override the default retry behavior of the client. | + +### Response + +**[Union[eventstreaming.EventStream[models.ResearchTaskStreamEvent], eventstreaming.EventStreamAsync[models.ResearchTaskStreamEvent]]](../../models/researchtaskstreamevent.md)** + +### Errors + +| Error Type | Status Code | Content Type | +| -------------------------------------------- | -------------------------------------------- | -------------------------------------------- | +| errors.StreamResearchTaskUnauthorizedError | 401 | application/json | +| errors.StreamResearchTaskForbiddenError | 403 | application/json | +| errors.StreamResearchTaskNotFoundError | 404 | application/json | +| errors.StreamResearchTaskInternalServerError | 500 | application/json | +| errors.YouDefaultError | 4XX, 5XX | \*/\* | + ## finance_research The Finance Research API is purpose-built for financial questions. Like the Research API, it runs multiple searches, reads through sources, and synthesizes everything into a thorough, well-cited answer — but its retrieval index is optimized for financial data: earnings reports, SEC filings, analyst coverage, market data, and financial news. @@ -629,7 +722,7 @@ with You( | Parameter | Type | Required | Description | Example | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `input` | *str* | :heavy_check_mark: | The financial research question or complex query requiring in-depth investigation and multi-step reasoning.

Note: The maximum length of the input is 40,000 characters. | What were the key drivers of NVIDIA's revenue growth in fiscal year 2025? | -| `research_effort` | [Optional[models.FinanceResearchEffort]](../../models/financeresearcheffort.md) | :heavy_minus_sign: | Controls how much time and effort the Finance Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time.

Available levels:
- `deep`: The default. Spends more time researching and cross-referencing sources. Good for most financial questions, including multi-company comparisons, earnings analysis, and regulatory research.
- `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex financial research tasks where you want the highest quality result. | deep | +| `research_effort` | [Optional[models.FinanceResearchEffort]](../../models/financeresearcheffort.md) | :heavy_minus_sign: | Controls how much time and effort the Finance Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time.

Available levels:
- `lite`: Returns answers quickly. Good for straightforward financial questions that just need a fast, reliable answer.
- `deep`: The default. Spends more time researching and cross-referencing sources. Good for most financial questions, including multi-company comparisons, earnings analysis, and regulatory research.
- `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex financial research tasks where you want the highest quality result. | deep | | `retries` | [Optional[utils.RetryConfig]](../../models/utils/retryconfig.md) | :heavy_minus_sign: | Configuration to override the default retry behavior of the client. | | ### Response diff --git a/examples/api-example-calls.py b/examples/api-example-calls.py index a5fa041..9136685 100755 --- a/examples/api-example-calls.py +++ b/examples/api-example-calls.py @@ -19,6 +19,7 @@ """ from typing import Optional +import time from youdotcom import You from youdotcom.models import ( ResearchTool, @@ -44,6 +45,7 @@ FinanceResearchEffort, FinanceResearchResponse, ResearchResponse, + TaskResponse, ) from youdotcom.utils import eventstreaming @@ -295,6 +297,128 @@ def research_request(): print(f" - {source.title or 'Untitled'}: {source.url}") +def research_background_request(): + """ + Research API with background mode: returns a task handle instead of the final answer. + Poll status with `you.get_research_task(task_id)` or stream events with + `you.stream_research_task(task_id)`. + """ + print("\n🚀 Running Research Background Request...\n") + + assert you is not None, "SDK client not initialized" + + res = you.research( + input="Compare the profitability of NVIDIA, AMD, and Intel over the past 5 fiscal years.", + research_effort=ResearchEffort.DEEP, + background=True, + ) + + assert isinstance(res, TaskResponse) + print(f"Queued task {res.task_id} (status: {res.status.value})") + print(f"Stream URL: {res.stream_url}") + + # Optional: poll until completion + print("\nPolling for completion...") + while True: + status_res = you.get_research_task(task_id=res.task_id) + status = status_res.status.value + print(f" status: {status}") + if status in ("completed", "failed", "cancelled"): + break + time.sleep(5) + + if status == "completed": + # The Result model uses extra="allow", so model_dump() recovers + # the full ResearchResponse payload from the task detail. + print("\nFinal answer (preview):") + payload = status_res.result.model_dump() if status_res.result else {} + content = payload.get("output", {}).get("content", "") + if isinstance(content, str): + print(content[:500] + ("..." if len(content) > 500 else "")) + + +def research_and_wait_example(): + """ + Research API with the research_and_wait helper: submit in background + mode and wait for completion in one call. Returns the final TaskDetail. + """ + from youdotcom.research_helpers import research_and_wait + from youdotcom.models import TaskDetail + + print("\n🚀 Running Research and Wait (Helper)...\n") + + assert you is not None, "SDK client not initialized" + + try: + detail = research_and_wait( + you, + timeout_s=120.0, + input="Compare the profitability of NVIDIA, AMD, and Intel over the past 5 fiscal years.", + research_effort=ResearchEffort.DEEP, + ) + except TypeError as e: + if "TaskResponse" in str(e): + print(" Background mode not enabled on server. Skipping.") + return + raise + + assert isinstance(detail, TaskDetail) + print(f"Task completed: {detail.status.value}") + if detail.result: + payload = detail.result.model_dump() + content = payload.get("output", {}).get("content", "") + if isinstance(content, str): + print(f"\nAnswer (preview):\n{content[:500]}...") + + +def research_stream_example(): + """ + Research API with streaming: submit in background mode, then stream + real-time SSE events with the tolerant stream_research helper. + + The tolerant helper surfaces undocumented intermediate event types + (e.g. research.searching, response.created) as raw dicts instead of + crashing on pydantic validation. Recommended over the generated + you.stream_research_task() for real tasks. + """ + from youdotcom.research_helpers import research_background, stream_research + + print("\n🚀 Running Research Stream (Helper)...\n") + + assert you is not None, "SDK client not initialized" + + try: + task = research_background( + you, + input="Compare the profitability of NVIDIA, AMD, and Intel over the past 5 fiscal years.", + research_effort=ResearchEffort.DEEP, + ) + except TypeError as e: + if "TaskResponse" in str(e): + print(" Background mode not enabled on server. Skipping.") + return + raise + + print(f"Queued task {task.task_id}, streaming events...\n") + + for event in stream_research(you, task_id=task.task_id): + print(f" event: {event.event} data: {str(event.data)[:120]}") + if event.event in ("response.done", "complete", "completed"): + print("\n Task completed via stream.") + break + if event.event in ("error", "failed", "cancelled"): + print(f"\n Task ended with: {event.event}") + break + + # Fetch the final result via a GET + detail = you.get_research_task(task_id=task.task_id) + if detail.status.value == "completed" and detail.result: + payload = detail.result.model_dump() + content = payload.get("output", {}).get("content", "") + if isinstance(content, str): + print(f"\nFinal answer (preview):\n{content[:500]}...") + + def research_output_schema_request(): """ Research API with `output_schema` for structured JSON output. @@ -409,6 +533,9 @@ def content_request_with_max_age(): {"name": "Content Request", "fn": content_request}, {"name": "Content Request (max_age)", "fn": content_request_with_max_age}, {"name": "Research Request", "fn": research_request}, + {"name": "Research Background Mode", "fn": research_background_request}, + {"name": "Research and Wait (Helper)", "fn": research_and_wait_example}, + {"name": "Research Stream (Helper)", "fn": research_stream_example}, {"name": "Research with output_schema", "fn": research_output_schema_request}, {"name": "Finance Research Request", "fn": finance_research_request}, ] diff --git a/overlays/python_overlay.yaml b/overlays/python_overlay.yaml index 678fac2..905aed5 100644 --- a/overlays/python_overlay.yaml +++ b/overlays/python_overlay.yaml @@ -45,3 +45,77 @@ actions: # the overlay makes this regen-durable until the upstream spec drops it. - target: $["components"]["schemas"]["WebResult"]["properties"]["authors"] remove: true + # Background-mode research: TaskDetail.result is an inline object schema + # that Speakeasy generates as `class Result(BaseModel): pass`, which + # round-trips as an empty dict. Inject additionalProperties: true so the + # full ResearchResponse payload (output.content, sources, etc.) survives + # deserialization. The Result model in taskdetail.py already has + # extra="allow"; this overlay ensures future regens preserve it. + - target: $["components"]["schemas"]["TaskDetail"]["properties"]["result"] + update: + additionalProperties: true + # Same fix for TaskDetail.input: the server stores the original request + # as a Dict[str, Any] (input, research_effort, source_control, etc.) but + # Speakeasy generates an empty TaskDetailInput BaseModel that drops all + # fields. Inject additionalProperties: true so the input round-trips. + # The TaskDetailInput model in taskdetail.py already has extra="allow". + - target: $["components"]["schemas"]["TaskDetail"]["properties"]["input"] + update: + additionalProperties: true + # Add the `frontier` research_effort tier to the ResearchEffort enum. + # Frontier is a long-running, higher-quality tier that only works with + # the task-based (background=true) API. The upstream spec doesn't yet + # include it, so this overlay injects the enum value and updates the + # description to document the new tier. Regen-durable: future Speakeasy + # regenerations will pick up `frontier` from the overlay-applied spec. + - target: $["components"]["schemas"]["ResearchEffort"]["enum"] + update: + - lite + - standard + - deep + - exhaustive + - frontier + - target: $["components"]["schemas"]["ResearchEffort"]["description"] + update: >- + Controls how much time and effort the Research API spends on your + question. Higher effort levels run more searches and dig deeper into + sources, at the cost of a longer response time. + + Available levels: + - `lite`: Returns answers quickly. Good for straightforward questions + that just need a fast, reliable answer. + - `standard`: The default. Balances speed and depth, a good fit for + most questions. + - `deep`: Spends more time researching and cross-referencing sources. + Use this when accuracy and thoroughness matter more than speed. + - `exhaustive`: The most thorough option. Explores the topic as fully + as possible, best suited for complex research tasks where you want + the highest quality result. + - `frontier`: The highest-quality tier. Runs over longer durations + with improved quality and accuracy. Only works with the task-based + API (`background=true`); sending `frontier` without `background=true` + returns a 422. + # Add the `lite` research_effort tier to the FinanceResearchEffort enum. + # The upstream spec doesn't yet include it, so this overlay injects the + # enum value and updates the description. Regen-durable. + - target: $["components"]["schemas"]["FinanceResearchEffort"]["enum"] + update: + - lite + - deep + - exhaustive + - target: $["components"]["schemas"]["FinanceResearchEffort"]["description"] + update: >- + Controls how much time and effort the Finance Research API spends on + your question. Higher effort levels run more searches and dig deeper + into sources, at the cost of a longer response time. + + Available levels: + - `lite`: Returns answers quickly. Good for straightforward financial + questions that just need a fast, reliable answer. + - `deep`: The default. Spends more time researching and + cross-referencing sources. Good for most financial questions, + including multi-company comparisons, earnings analysis, and + regulatory research. + - `exhaustive`: The most thorough option. Explores the topic as fully + as possible, best suited for complex financial research tasks where + you want the highest quality result. diff --git a/pyproject.toml b/pyproject.toml index ee856fd..e14db01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "youdotcom" -version = "2.4.0" +version = "2.5.0" description = "The official You.com Python SDK." authors = [{ name = "You.com" },] readme = "README.md" diff --git a/src/youdotcom/_version.py b/src/youdotcom/_version.py index 5233a7d..3cd2c70 100644 --- a/src/youdotcom/_version.py +++ b/src/youdotcom/_version.py @@ -3,10 +3,10 @@ import importlib.metadata __title__: str = "youdotcom" -__version__: str = "2.4.0" +__version__: str = "2.5.0" __openapi_doc_version__: str = "1.0.0" __gen_version__: str = "2.918.1" -__user_agent__: str = "speakeasy-sdk/python 2.4.0 2.918.1 1.0.0 youdotcom" +__user_agent__: str = "speakeasy-sdk/python 2.5.0 2.918.1 1.0.0 youdotcom" try: if __package__ is not None: diff --git a/src/youdotcom/errors/__init__.py b/src/youdotcom/errors/__init__.py index fe2e187..aea2e79 100644 --- a/src/youdotcom/errors/__init__.py +++ b/src/youdotcom/errors/__init__.py @@ -40,6 +40,16 @@ ForbiddenResponseError, ForbiddenResponseErrorData, ) + from .getresearchtaskop import ( + GetResearchTaskForbiddenError, + GetResearchTaskForbiddenErrorData, + GetResearchTaskInternalServerError, + GetResearchTaskInternalServerErrorData, + GetResearchTaskNotFoundError, + GetResearchTaskNotFoundErrorData, + GetResearchTaskUnauthorizedError, + GetResearchTaskUnauthorizedErrorData, + ) from .internalservererror_response import ( InternalServerErrorResponse, InternalServerErrorResponseData, @@ -56,6 +66,16 @@ ResearchUnprocessableEntityErrorData, ) from .responsevalidationerror import ResponseValidationError + from .streamresearchtaskop import ( + StreamResearchTaskForbiddenError, + StreamResearchTaskForbiddenErrorData, + StreamResearchTaskInternalServerError, + StreamResearchTaskInternalServerErrorData, + StreamResearchTaskNotFoundError, + StreamResearchTaskNotFoundErrorData, + StreamResearchTaskUnauthorizedError, + StreamResearchTaskUnauthorizedErrorData, + ) from .unauthorized_response_error import ( UnauthorizedResponseError, UnauthorizedResponseErrorData, @@ -89,6 +109,14 @@ "FinanceResearchUnprocessableEntityErrorData", "ForbiddenResponseError", "ForbiddenResponseErrorData", + "GetResearchTaskForbiddenError", + "GetResearchTaskForbiddenErrorData", + "GetResearchTaskInternalServerError", + "GetResearchTaskInternalServerErrorData", + "GetResearchTaskNotFoundError", + "GetResearchTaskNotFoundErrorData", + "GetResearchTaskUnauthorizedError", + "GetResearchTaskUnauthorizedErrorData", "InternalServerErrorResponse", "InternalServerErrorResponseData", "NoResponseError", @@ -101,6 +129,14 @@ "ResearchUnprocessableEntityError", "ResearchUnprocessableEntityErrorData", "ResponseValidationError", + "StreamResearchTaskForbiddenError", + "StreamResearchTaskForbiddenErrorData", + "StreamResearchTaskInternalServerError", + "StreamResearchTaskInternalServerErrorData", + "StreamResearchTaskNotFoundError", + "StreamResearchTaskNotFoundErrorData", + "StreamResearchTaskUnauthorizedError", + "StreamResearchTaskUnauthorizedErrorData", "UnauthorizedResponseError", "UnauthorizedResponseErrorData", "UnprocessableEntityResponseError", @@ -132,6 +168,14 @@ "FinanceResearchUnprocessableEntityErrorData": ".finance_researchop", "ForbiddenResponseError": ".forbidden_response_error", "ForbiddenResponseErrorData": ".forbidden_response_error", + "GetResearchTaskForbiddenError": ".getresearchtaskop", + "GetResearchTaskForbiddenErrorData": ".getresearchtaskop", + "GetResearchTaskInternalServerError": ".getresearchtaskop", + "GetResearchTaskInternalServerErrorData": ".getresearchtaskop", + "GetResearchTaskNotFoundError": ".getresearchtaskop", + "GetResearchTaskNotFoundErrorData": ".getresearchtaskop", + "GetResearchTaskUnauthorizedError": ".getresearchtaskop", + "GetResearchTaskUnauthorizedErrorData": ".getresearchtaskop", "InternalServerErrorResponse": ".internalservererror_response", "InternalServerErrorResponseData": ".internalservererror_response", "NoResponseError": ".no_response_error", @@ -144,6 +188,14 @@ "ResearchUnprocessableEntityError": ".researchop", "ResearchUnprocessableEntityErrorData": ".researchop", "ResponseValidationError": ".responsevalidationerror", + "StreamResearchTaskForbiddenError": ".streamresearchtaskop", + "StreamResearchTaskForbiddenErrorData": ".streamresearchtaskop", + "StreamResearchTaskInternalServerError": ".streamresearchtaskop", + "StreamResearchTaskInternalServerErrorData": ".streamresearchtaskop", + "StreamResearchTaskNotFoundError": ".streamresearchtaskop", + "StreamResearchTaskNotFoundErrorData": ".streamresearchtaskop", + "StreamResearchTaskUnauthorizedError": ".streamresearchtaskop", + "StreamResearchTaskUnauthorizedErrorData": ".streamresearchtaskop", "UnauthorizedResponseError": ".unauthorized_response_error", "UnauthorizedResponseErrorData": ".unauthorized_response_error", "UnprocessableEntityResponseError": ".unprocessableentity_response_error", diff --git a/src/youdotcom/errors/getresearchtaskop.py b/src/youdotcom/errors/getresearchtaskop.py new file mode 100644 index 0000000..1c3cda1 --- /dev/null +++ b/src/youdotcom/errors/getresearchtaskop.py @@ -0,0 +1,92 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from dataclasses import dataclass, field +import httpx +from typing import Optional +from youdotcom.errors import YouError +from youdotcom.types import BaseModel + + +class GetResearchTaskInternalServerErrorData(BaseModel): + detail: Optional[str] = None + + +@dataclass(unsafe_hash=True) +class GetResearchTaskInternalServerError(YouError): + r"""Internal Server Error.""" + + data: GetResearchTaskInternalServerErrorData = field(hash=False) + + def __init__( + self, + data: GetResearchTaskInternalServerErrorData, + raw_response: httpx.Response, + body: Optional[str] = None, + ): + message = body or raw_response.text + super().__init__(message, raw_response, body) + object.__setattr__(self, "data", data) + + +class GetResearchTaskNotFoundErrorData(BaseModel): + detail: Optional[str] = None + + +@dataclass(unsafe_hash=True) +class GetResearchTaskNotFoundError(YouError): + r"""Task not found or not authorized.""" + + data: GetResearchTaskNotFoundErrorData = field(hash=False) + + def __init__( + self, + data: GetResearchTaskNotFoundErrorData, + raw_response: httpx.Response, + body: Optional[str] = None, + ): + message = body or raw_response.text + super().__init__(message, raw_response, body) + object.__setattr__(self, "data", data) + + +class GetResearchTaskForbiddenErrorData(BaseModel): + detail: Optional[str] = None + + +@dataclass(unsafe_hash=True) +class GetResearchTaskForbiddenError(YouError): + r"""Forbidden. API key lacks scope for this path.""" + + data: GetResearchTaskForbiddenErrorData = field(hash=False) + + def __init__( + self, + data: GetResearchTaskForbiddenErrorData, + raw_response: httpx.Response, + body: Optional[str] = None, + ): + message = body or raw_response.text + super().__init__(message, raw_response, body) + object.__setattr__(self, "data", data) + + +class GetResearchTaskUnauthorizedErrorData(BaseModel): + detail: Optional[str] = None + + +@dataclass(unsafe_hash=True) +class GetResearchTaskUnauthorizedError(YouError): + r"""Unauthorized. Problems with API key.""" + + data: GetResearchTaskUnauthorizedErrorData = field(hash=False) + + def __init__( + self, + data: GetResearchTaskUnauthorizedErrorData, + raw_response: httpx.Response, + body: Optional[str] = None, + ): + message = body or raw_response.text + super().__init__(message, raw_response, body) + object.__setattr__(self, "data", data) diff --git a/src/youdotcom/errors/streamresearchtaskop.py b/src/youdotcom/errors/streamresearchtaskop.py new file mode 100644 index 0000000..a2ee4a0 --- /dev/null +++ b/src/youdotcom/errors/streamresearchtaskop.py @@ -0,0 +1,92 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from dataclasses import dataclass, field +import httpx +from typing import Optional +from youdotcom.errors import YouError +from youdotcom.types import BaseModel + + +class StreamResearchTaskInternalServerErrorData(BaseModel): + detail: Optional[str] = None + + +@dataclass(unsafe_hash=True) +class StreamResearchTaskInternalServerError(YouError): + r"""Internal Server Error.""" + + data: StreamResearchTaskInternalServerErrorData = field(hash=False) + + def __init__( + self, + data: StreamResearchTaskInternalServerErrorData, + raw_response: httpx.Response, + body: Optional[str] = None, + ): + message = body or raw_response.text + super().__init__(message, raw_response, body) + object.__setattr__(self, "data", data) + + +class StreamResearchTaskNotFoundErrorData(BaseModel): + detail: Optional[str] = None + + +@dataclass(unsafe_hash=True) +class StreamResearchTaskNotFoundError(YouError): + r"""Task not found or not authorized.""" + + data: StreamResearchTaskNotFoundErrorData = field(hash=False) + + def __init__( + self, + data: StreamResearchTaskNotFoundErrorData, + raw_response: httpx.Response, + body: Optional[str] = None, + ): + message = body or raw_response.text + super().__init__(message, raw_response, body) + object.__setattr__(self, "data", data) + + +class StreamResearchTaskForbiddenErrorData(BaseModel): + detail: Optional[str] = None + + +@dataclass(unsafe_hash=True) +class StreamResearchTaskForbiddenError(YouError): + r"""Forbidden. API key lacks scope for this path.""" + + data: StreamResearchTaskForbiddenErrorData = field(hash=False) + + def __init__( + self, + data: StreamResearchTaskForbiddenErrorData, + raw_response: httpx.Response, + body: Optional[str] = None, + ): + message = body or raw_response.text + super().__init__(message, raw_response, body) + object.__setattr__(self, "data", data) + + +class StreamResearchTaskUnauthorizedErrorData(BaseModel): + detail: Optional[str] = None + + +@dataclass(unsafe_hash=True) +class StreamResearchTaskUnauthorizedError(YouError): + r"""Unauthorized. Problems with API key.""" + + data: StreamResearchTaskUnauthorizedErrorData = field(hash=False) + + def __init__( + self, + data: StreamResearchTaskUnauthorizedErrorData, + raw_response: httpx.Response, + body: Optional[str] = None, + ): + message = body or raw_response.text + super().__init__(message, raw_response, body) + object.__setattr__(self, "data", data) diff --git a/src/youdotcom/models/__init__.py b/src/youdotcom/models/__init__.py index 0aa0f8b..ad650a3 100644 --- a/src/youdotcom/models/__init__.py +++ b/src/youdotcom/models/__init__.py @@ -85,6 +85,10 @@ from .financeresearcheffort import FinanceResearchEffort from .freshness import Freshness from .freshnessvalue import FreshnessValue, FreshnessValueTypedDict + from .getresearchtaskop import ( + GetResearchTaskRequest, + GetResearchTaskRequestTypedDict, + ) from .language import Language from .livecrawl import LiveCrawl from .livecrawlformats import LiveCrawlFormats @@ -102,6 +106,8 @@ ResearchLocTypedDict, ResearchRequest, ResearchRequestTypedDict, + ResearchResult, + ResearchResultTypedDict, SourceControl, SourceControlTypedDict, ) @@ -116,6 +122,13 @@ Source, SourceTypedDict, ) + from .researchtaskstreamevent import ( + Event, + ResearchTaskStreamEvent, + ResearchTaskStreamEventData, + ResearchTaskStreamEventDataTypedDict, + ResearchTaskStreamEventTypedDict, + ) from .researchtool import ResearchTool, ResearchToolTypedDict from .response_created import ResponseCreated, ResponseCreatedTypedDict from .response_done import ( @@ -162,6 +175,20 @@ SearchResponseTypedDict, ) from .security import Security, SecurityTypedDict + from .streamresearchtaskop import ( + StreamResearchTaskRequest, + StreamResearchTaskRequestTypedDict, + ) + from .taskdetail import ( + Result, + ResultTypedDict, + TaskDetail, + TaskDetailInput, + TaskDetailInputTypedDict, + TaskDetailStatus, + TaskDetailTypedDict, + ) + from .taskresponse import TaskResponse, TaskResponseStatus, TaskResponseTypedDict from .verbosity import Verbosity from .webresult import WebResult, WebResultTypedDict from .websearchtool import WebSearchTool, WebSearchToolTypedDict @@ -204,6 +231,7 @@ "DataTypedDict", "Detail", "DetailTypedDict", + "Event", "ExpressAgentRunsRequest", "ExpressAgentRunsRequestTypedDict", "FinanceResearchContentType", @@ -227,6 +255,8 @@ "Freshness", "FreshnessValue", "FreshnessValueTypedDict", + "GetResearchTaskRequest", + "GetResearchTaskRequestTypedDict", "Input", "InputTypedDict", "Language", @@ -251,7 +281,13 @@ "ResearchRequest", "ResearchRequestTypedDict", "ResearchResponse", + "ResearchResult", + "ResearchResultTypedDict", "ResearchResponseTypedDict", + "ResearchTaskStreamEvent", + "ResearchTaskStreamEventData", + "ResearchTaskStreamEventDataTypedDict", + "ResearchTaskStreamEventTypedDict", "ResearchTool", "ResearchToolTypedDict", "ResponseCreated", @@ -278,6 +314,8 @@ "ResponseOutputTextDeltaTypedDict", "ResponseStarting", "ResponseStartingTypedDict", + "Result", + "ResultTypedDict", "Results", "ResultsTypedDict", "Role", @@ -299,6 +337,16 @@ "SourceControl", "SourceControlTypedDict", "SourceTypedDict", + "StreamResearchTaskRequest", + "StreamResearchTaskRequestTypedDict", + "TaskDetail", + "TaskDetailInput", + "TaskDetailInputTypedDict", + "TaskDetailStatus", + "TaskDetailTypedDict", + "TaskResponse", + "TaskResponseStatus", + "TaskResponseTypedDict", "Tool", "ToolTypedDict", "Type", @@ -379,6 +427,8 @@ "Freshness": ".freshness", "FreshnessValue": ".freshnessvalue", "FreshnessValueTypedDict": ".freshnessvalue", + "GetResearchTaskRequest": ".getresearchtaskop", + "GetResearchTaskRequestTypedDict": ".getresearchtaskop", "Language": ".language", "LiveCrawl": ".livecrawl", "LiveCrawlFormats": ".livecrawlformats", @@ -396,6 +446,8 @@ "ResearchLocTypedDict": ".researchop", "ResearchRequest": ".researchop", "ResearchRequestTypedDict": ".researchop", + "ResearchResult": ".researchop", + "ResearchResultTypedDict": ".researchop", "SourceControl": ".researchop", "SourceControlTypedDict": ".researchop", "Content": ".researchresponse", @@ -407,6 +459,11 @@ "ResearchResponseTypedDict": ".researchresponse", "Source": ".researchresponse", "SourceTypedDict": ".researchresponse", + "Event": ".researchtaskstreamevent", + "ResearchTaskStreamEvent": ".researchtaskstreamevent", + "ResearchTaskStreamEventData": ".researchtaskstreamevent", + "ResearchTaskStreamEventDataTypedDict": ".researchtaskstreamevent", + "ResearchTaskStreamEventTypedDict": ".researchtaskstreamevent", "ResearchTool": ".researchtool", "ResearchToolTypedDict": ".researchtool", "ResponseCreated": ".response_created", @@ -449,6 +506,18 @@ "SearchResponseTypedDict": ".searchresponse", "Security": ".security", "SecurityTypedDict": ".security", + "StreamResearchTaskRequest": ".streamresearchtaskop", + "StreamResearchTaskRequestTypedDict": ".streamresearchtaskop", + "TaskDetail": ".taskdetail", + "TaskDetailInput": ".taskdetail", + "TaskDetailInputTypedDict": ".taskdetail", + "TaskDetailStatus": ".taskdetail", + "TaskDetailTypedDict": ".taskdetail", + "Result": ".taskdetail", + "ResultTypedDict": ".taskdetail", + "TaskResponse": ".taskresponse", + "TaskResponseStatus": ".taskresponse", + "TaskResponseTypedDict": ".taskresponse", "Verbosity": ".verbosity", "WebResult": ".webresult", "WebResultTypedDict": ".webresult", diff --git a/src/youdotcom/models/finance_researchop.py b/src/youdotcom/models/finance_researchop.py index 1d886fd..47259fd 100644 --- a/src/youdotcom/models/finance_researchop.py +++ b/src/youdotcom/models/finance_researchop.py @@ -19,6 +19,7 @@ class FinanceResearchRequestTypedDict(TypedDict): r"""Controls how much time and effort the Finance Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time. Available levels: + - `lite`: Returns answers quickly. Good for straightforward financial questions that just need a fast, reliable answer. - `deep`: The default. Spends more time researching and cross-referencing sources. Good for most financial questions, including multi-company comparisons, earnings analysis, and regulatory research. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex financial research tasks where you want the highest quality result. """ @@ -35,6 +36,7 @@ class FinanceResearchRequest(BaseModel): r"""Controls how much time and effort the Finance Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time. Available levels: + - `lite`: Returns answers quickly. Good for straightforward financial questions that just need a fast, reliable answer. - `deep`: The default. Spends more time researching and cross-referencing sources. Good for most financial questions, including multi-company comparisons, earnings analysis, and regulatory research. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex financial research tasks where you want the highest quality result. """ diff --git a/src/youdotcom/models/financeresearcheffort.py b/src/youdotcom/models/financeresearcheffort.py index 417d805..909b887 100644 --- a/src/youdotcom/models/financeresearcheffort.py +++ b/src/youdotcom/models/financeresearcheffort.py @@ -1,5 +1,8 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" +# Manual overlay: `lite` tier added via overlays/python_overlay.yaml +# until the upstream OpenAPI spec includes it. See that file for details. + from __future__ import annotations from enum import Enum @@ -8,9 +11,11 @@ class FinanceResearchEffort(str, Enum): r"""Controls how much time and effort the Finance Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time. Available levels: + - `lite`: Returns answers quickly. Good for straightforward financial questions that just need a fast, reliable answer. - `deep`: The default. Spends more time researching and cross-referencing sources. Good for most financial questions, including multi-company comparisons, earnings analysis, and regulatory research. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex financial research tasks where you want the highest quality result. """ + LITE = "lite" DEEP = "deep" EXHAUSTIVE = "exhaustive" diff --git a/src/youdotcom/models/getresearchtaskop.py b/src/youdotcom/models/getresearchtaskop.py new file mode 100644 index 0000000..0330943 --- /dev/null +++ b/src/youdotcom/models/getresearchtaskop.py @@ -0,0 +1,18 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from typing_extensions import Annotated, TypedDict +from youdotcom.types import BaseModel +from youdotcom.utils import FieldMetadata, PathParamMetadata + + +class GetResearchTaskRequestTypedDict(TypedDict): + task_id: str + r"""The UUID of the research task.""" + + +class GetResearchTaskRequest(BaseModel): + task_id: Annotated[ + str, FieldMetadata(path=PathParamMetadata(style="simple", explode=False)) + ] + r"""The UUID of the research task.""" diff --git a/src/youdotcom/models/researcheffort.py b/src/youdotcom/models/researcheffort.py index 2384195..506ea33 100644 --- a/src/youdotcom/models/researcheffort.py +++ b/src/youdotcom/models/researcheffort.py @@ -1,5 +1,8 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" +# Manual overlay: `frontier` tier added via overlays/python_overlay.yaml +# until the upstream OpenAPI spec includes it. See that file for details. + from __future__ import annotations from enum import Enum @@ -12,9 +15,11 @@ class ResearchEffort(str, Enum): - `standard`: The default. Balances speed and depth, a good fit for most questions. - `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result. + - `frontier`: The highest-quality tier. Runs over longer durations with improved quality and accuracy. Only works with the task-based API (``background=true``); sending ``frontier`` without ``background=true`` returns a 422. """ LITE = "lite" STANDARD = "standard" DEEP = "deep" EXHAUSTIVE = "exhaustive" + FRONTIER = "frontier" diff --git a/src/youdotcom/models/researchop.py b/src/youdotcom/models/researchop.py index 15231ba..15349a0 100644 --- a/src/youdotcom/models/researchop.py +++ b/src/youdotcom/models/researchop.py @@ -2,6 +2,8 @@ from __future__ import annotations from .researcheffort import ResearchEffort +from .researchresponse import ResearchResponse, ResearchResponseTypedDict +from .taskresponse import TaskResponse, TaskResponseTypedDict from pydantic import model_serializer from typing import Any, Dict, List, Optional, Union from typing_extensions import NotRequired, TypeAliasType, TypedDict @@ -86,7 +88,10 @@ class ResearchRequestTypedDict(TypedDict): - `standard`: The default. Balances speed and depth, a good fit for most questions. - `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result. + - `frontier`: The highest-quality tier. Runs over longer durations with improved quality and accuracy. Only works with the task-based API (`background=true`); sending `frontier` without `background=true` returns a 422. """ + background: NotRequired[bool] + r"""When true, queue a research task and return a task handle immediately instead of waiting for the result inline. Defaults to synchronous. When enabled, the response is a TaskResponse object with a task_id and stream_url for polling progress via GET /v1/research/{task_id} or streaming via GET /v1/research/{task_id}/stream.""" source_control: NotRequired[SourceControlTypedDict] r"""Beta. Controls which web sources the research agent searches and visits. Use this to allow specific domains, block specific domains, boost specific domains, filter by recency, or focus web results by country. @@ -116,8 +121,12 @@ class ResearchRequest(BaseModel): - `standard`: The default. Balances speed and depth, a good fit for most questions. - `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result. + - `frontier`: The highest-quality tier. Runs over longer durations with improved quality and accuracy. Only works with the task-based API (`background=true`); sending `frontier` without `background=true` returns a 422. """ + background: Optional[bool] = False + r"""When true, queue a research task and return a task handle immediately instead of waiting for the result inline. Defaults to synchronous. When enabled, the response is a TaskResponse object with a task_id and stream_url for polling progress via GET /v1/research/{task_id} or streaming via GET /v1/research/{task_id}/stream.""" + source_control: Optional[SourceControl] = None r"""Beta. Controls which web sources the research agent searches and visits. Use this to allow specific domains, block specific domains, boost specific domains, filter by recency, or focus web results by country. @@ -134,7 +143,9 @@ class ResearchRequest(BaseModel): @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["research_effort", "source_control", "output_schema"]) + optional_fields = set( + ["research_effort", "background", "source_control", "output_schema"] + ) serialized = handler(self) m = {} @@ -217,3 +228,16 @@ def serialize_model(self, handler): m[k] = val return m + + +ResearchResultTypedDict = TypeAliasType( + "ResearchResultTypedDict", + Union[ResearchResponseTypedDict, TaskResponseTypedDict], +) +r"""A JSON object containing a comprehensive answer with citations and supporting search results. When background=true, returns a task handle instead.""" + + +ResearchResult = TypeAliasType( + "ResearchResult", Union[ResearchResponse, TaskResponse] +) +r"""A JSON object containing a comprehensive answer with citations and supporting search results. When background=true, returns a task handle instead.""" diff --git a/src/youdotcom/models/researchtaskstreamevent.py b/src/youdotcom/models/researchtaskstreamevent.py new file mode 100644 index 0000000..fe7f123 --- /dev/null +++ b/src/youdotcom/models/researchtaskstreamevent.py @@ -0,0 +1,113 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from enum import Enum +from pydantic import model_serializer +from typing import Any, Dict, Optional +from typing_extensions import NotRequired, TypedDict +from youdotcom.types import BaseModel, Nullable, OptionalNullable, UNSET, UNSET_SENTINEL + + +class Event(str, Enum): + r"""The type of the SSE event. Terminal events that close the stream are: `response.done`, `complete`, `error`, and `cancelled`. The stream may also emit `completed`, `failed`, or `cancelled` as event names corresponding to the task's terminal status. + """ + + CONNECTED = "connected" + RESPONSE_DONE = "response.done" + COMPLETE = "complete" + COMPLETED = "completed" + ERROR = "error" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ResearchTaskStreamEventDataTypedDict(TypedDict): + r"""The event payload. Structure varies by event type. Common fields include type, task_id, status, data (event-specific), error, and sequence.""" + + type: NotRequired[str] + r"""The event type identifier.""" + task_id: NotRequired[str] + r"""The task UUID.""" + status: NotRequired[str] + r"""Current task status when the event was emitted.""" + data: NotRequired[Dict[str, Any]] + r"""Event-specific payload data.""" + error: NotRequired[Nullable[str]] + r"""Error message if the event represents an error.""" + sequence: NotRequired[int] + r"""Event sequence number.""" + + +class ResearchTaskStreamEventData(BaseModel): + r"""The event payload. Structure varies by event type. Common fields include type, task_id, status, data (event-specific), error, and sequence.""" + + type: Optional[str] = None + r"""The event type identifier.""" + + task_id: Optional[str] = None + r"""The task UUID.""" + + status: Optional[str] = None + r"""Current task status when the event was emitted.""" + + data: Optional[Dict[str, Any]] = None + r"""Event-specific payload data.""" + + error: OptionalNullable[str] = UNSET + r"""Error message if the event represents an error.""" + + sequence: Optional[int] = None + r"""Event sequence number.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set( + ["type", "task_id", "status", "data", "error", "sequence"] + ) + nullable_fields = set(["error"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + is_nullable_and_explicitly_set = ( + k in nullable_fields + and (self.__pydantic_fields_set__.intersection({n})) # pylint: disable=no-member + ) + + if val != UNSET_SENTINEL: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): + m[k] = val + + return m + + +class ResearchTaskStreamEventTypedDict(TypedDict): + r"""A server-sent event for a background research task stream.""" + + id: str + r"""Sequence number of the SSE event.""" + event: Event + r"""The type of the SSE event. Terminal events that close the stream are: `response.done`, `complete`, `error`, and `cancelled`. The stream may also emit `completed`, `failed`, or `cancelled` as event names corresponding to the task's terminal status. + """ + data: ResearchTaskStreamEventDataTypedDict + r"""The event payload. Structure varies by event type. Common fields include type, task_id, status, data (event-specific), error, and sequence.""" + + +class ResearchTaskStreamEvent(BaseModel): + r"""A server-sent event for a background research task stream.""" + + id: str + r"""Sequence number of the SSE event.""" + + event: Event + r"""The type of the SSE event. Terminal events that close the stream are: `response.done`, `complete`, `error`, and `cancelled`. The stream may also emit `completed`, `failed`, or `cancelled` as event names corresponding to the task's terminal status. + """ + + data: ResearchTaskStreamEventData + r"""The event payload. Structure varies by event type. Common fields include type, task_id, status, data (event-specific), error, and sequence.""" diff --git a/src/youdotcom/models/streamresearchtaskop.py b/src/youdotcom/models/streamresearchtaskop.py new file mode 100644 index 0000000..6b57b1d --- /dev/null +++ b/src/youdotcom/models/streamresearchtaskop.py @@ -0,0 +1,44 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from pydantic import model_serializer +from typing import Optional +from typing_extensions import Annotated, NotRequired, TypedDict +from youdotcom.types import BaseModel, UNSET_SENTINEL +from youdotcom.utils import FieldMetadata, PathParamMetadata, QueryParamMetadata + + +class StreamResearchTaskRequestTypedDict(TypedDict): + task_id: str + r"""The UUID of the research task.""" + from_id: NotRequired[int] + r"""Resume from a sequence number for reconnection.""" + + +class StreamResearchTaskRequest(BaseModel): + task_id: Annotated[ + str, FieldMetadata(path=PathParamMetadata(style="simple", explode=False)) + ] + r"""The UUID of the research task.""" + + from_id: Annotated[ + Optional[int], + FieldMetadata(query=QueryParamMetadata(style="form", explode=True)), + ] = 0 + r"""Resume from a sequence number for reconnection.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["from_id"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m diff --git a/src/youdotcom/models/taskdetail.py b/src/youdotcom/models/taskdetail.py new file mode 100644 index 0000000..f039294 --- /dev/null +++ b/src/youdotcom/models/taskdetail.py @@ -0,0 +1,111 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from datetime import datetime +from enum import Enum +from pydantic import ConfigDict, model_serializer +from typing_extensions import NotRequired, TypedDict +from youdotcom.types import BaseModel, Nullable, OptionalNullable, UNSET, UNSET_SENTINEL + + +class TaskDetailStatus(str, Enum): + r"""Current status of the task.""" + + QUEUED = "queued" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class TaskDetailInputTypedDict(TypedDict): + r"""The original request input for the task.""" + + +class TaskDetailInput(BaseModel): + r"""The original request input for the task.""" + model_config = ConfigDict(extra="allow") + + +class ResultTypedDict(TypedDict): + r"""The task result when completed. For research tasks, this contains the ResearchResponse output.""" + + +class Result(BaseModel): + r"""The task result when completed. For research tasks, this contains the ResearchResponse output.""" + model_config = ConfigDict(extra="allow") + + +class TaskDetailTypedDict(TypedDict): + id: str + r"""Unique identifier for the task.""" + task_type: str + r"""Task type.""" + status: TaskDetailStatus + r"""Current status of the task.""" + created_at: datetime + r"""When the task was created.""" + updated_at: datetime + r"""When the task was last updated.""" + completed_at: NotRequired[Nullable[datetime]] + r"""When the task completed, if applicable.""" + error: NotRequired[Nullable[str]] + r"""Error message if the task failed.""" + input: NotRequired[Nullable[TaskDetailInputTypedDict]] + r"""The original request input for the task.""" + result: NotRequired[Nullable[ResultTypedDict]] + r"""The task result when completed. For research tasks, this contains the ResearchResponse output.""" + + +class TaskDetail(BaseModel): + id: str + r"""Unique identifier for the task.""" + + task_type: str + r"""Task type.""" + + status: TaskDetailStatus + r"""Current status of the task.""" + + created_at: datetime + r"""When the task was created.""" + + updated_at: datetime + r"""When the task was last updated.""" + + completed_at: OptionalNullable[datetime] = UNSET + r"""When the task completed, if applicable.""" + + error: OptionalNullable[str] = UNSET + r"""Error message if the task failed.""" + + input: OptionalNullable[TaskDetailInput] = UNSET + r"""The original request input for the task.""" + + result: OptionalNullable[Result] = UNSET + r"""The task result when completed. For research tasks, this contains the ResearchResponse output.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["completed_at", "error", "input", "result"]) + nullable_fields = set(["completed_at", "error", "input", "result"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + is_nullable_and_explicitly_set = ( + k in nullable_fields + and (self.__pydantic_fields_set__.intersection({n})) # pylint: disable=no-member + ) + + if val != UNSET_SENTINEL: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): + m[k] = val + + return m diff --git a/src/youdotcom/models/taskresponse.py b/src/youdotcom/models/taskresponse.py new file mode 100644 index 0000000..490476d --- /dev/null +++ b/src/youdotcom/models/taskresponse.py @@ -0,0 +1,47 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from datetime import datetime +from enum import Enum +from typing_extensions import TypedDict +from youdotcom.types import BaseModel + + +class TaskResponseStatus(str, Enum): + r"""Current status of the task.""" + + QUEUED = "queued" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class TaskResponseTypedDict(TypedDict): + task_id: str + r"""Unique identifier for the task.""" + type: str + r"""Task type.""" + status: TaskResponseStatus + r"""Current status of the task.""" + stream_url: str + r"""URL to stream task events via SSE.""" + created_at: datetime + r"""When the task was created.""" + + +class TaskResponse(BaseModel): + task_id: str + r"""Unique identifier for the task.""" + + type: str + r"""Task type.""" + + status: TaskResponseStatus + r"""Current status of the task.""" + + stream_url: str + r"""URL to stream task events via SSE.""" + + created_at: datetime + r"""When the task was created.""" diff --git a/src/youdotcom/research_helpers.py b/src/youdotcom/research_helpers.py new file mode 100644 index 0000000..a4b0078 --- /dev/null +++ b/src/youdotcom/research_helpers.py @@ -0,0 +1,737 @@ +"""Hand-maintained research workflow helpers. + +This module is NOT regenerated by Speakeasy. It adds convenience helpers on +top of the auto-generated research endpoints: + +- ``research_background`` / ``research_background_async``: Submit a research + task with ``background=True`` and return the ``TaskResponse`` directly so + callers don't need to narrow the ``Union[ResearchResponse, TaskResponse]`` + response shape. +- ``poll_research_task`` / ``poll_research_task_async``: Poll + ``GET /v1/research/{task_id}`` until the task reaches a terminal state + (``completed``, ``failed``, ``cancelled``). +- ``research_and_wait`` / ``research_and_wait_async``: Submit a research task + with ``background=True``, then stream SSE events until a terminal event + arrives, and fetch the final ``TaskDetail``. +- ``stream_research`` / ``stream_research_async``: Iterate the SSE event + stream with a tolerant decoder that surfaces non-typed event names + (``research.searching``, etc.) as raw dicts instead of crashing on + validation. Use this when the server may emit event types outside the + documented enum. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from dataclasses import dataclass +from typing import Any, AsyncIterator, Iterator, Mapping, Optional + +import httpx + +import youdotcom.models as _models +from youdotcom import errors as _errors +from youdotcom._hooks.types import HookContext +from youdotcom.sdk import You +from youdotcom.models import TaskDetail, TaskResponse +from youdotcom.utils import eventstreaming, get_security_from_env, match_response, match_status_codes +from youdotcom.utils.serializers import stream_to_text, stream_to_text_async +from youdotcom.utils.unmarshal_json_response import unmarshal_json_response + + +_DEFAULT_POLL_INTERVAL_S = 2.0 +_DEFAULT_POLL_TIMEOUT_S = 600.0 +_FRONTIER_TIMEOUT_S = 14400.0 # 4 hours — frontier tasks can run this long +_REPOLL_MAX_ATTEMPTS = 3 # bounded re-poll on completion race (stream OK but GET not yet completed) +_REPOLL_INTERVAL_S = 1.0 +_TERMINAL_TASK_STATUSES = frozenset({"completed", "failed", "cancelled"}) +_TERMINAL_STREAM_EVENTS_OK = frozenset({"response.done", "complete", "completed"}) +_TERMINAL_STREAM_EVENTS_ERR = frozenset({"error", "failed", "cancelled"}) + + +def _resolve_default_timeout(kwargs: Mapping[str, Any]) -> float: + """Pick a sensible default ``timeout_s`` based on ``research_effort``. + + Frontier tasks can run up to 4 hours; all other effort tiers complete + well within the standard 10-minute default. Only called when the caller + did not explicitly pass ``timeout_s``. + """ + effort = kwargs.get("research_effort") + if effort is not None: + effort_val = effort.value if hasattr(effort, "value") else str(effort) + if effort_val == "frontier": + return _FRONTIER_TIMEOUT_S + return _DEFAULT_POLL_TIMEOUT_S + +# Typed-error branches for stream open: (status, content_type, DataClass, ErrorClass). +# Mirrors the generated stream_research_task error handling so callers of +# stream_research get the same structured exceptions as the generated method. +_STREAM_ERROR_BRANCHES = ( + ("401", "application/json", _errors.StreamResearchTaskUnauthorizedErrorData, _errors.StreamResearchTaskUnauthorizedError), + ("403", "application/json", _errors.StreamResearchTaskForbiddenErrorData, _errors.StreamResearchTaskForbiddenError), + ("404", "application/json", _errors.StreamResearchTaskNotFoundErrorData, _errors.StreamResearchTaskNotFoundError), + ("500", "application/json", _errors.StreamResearchTaskInternalServerErrorData, _errors.StreamResearchTaskInternalServerError), +) + + +@dataclass +class RawStreamEvent: + """Tolerant SSE event representation. + + Attributes: + id: SSE event ID (``id:`` directive) when supplied. + event: Raw event name as received from the server, NOT validated + against the documented enum. + data: Parsed JSON payload when the data line is valid JSON, + otherwise the raw string. + """ + + id: Optional[str] + event: Optional[str] + data: Any + + +def _decode_raw_event(raw_json: str) -> RawStreamEvent: + """Tolerant SSE decoder used by ``stream_research``. + + Accepts any JSON object regardless of whether ``event`` matches the + documented enum, so unknown workflow events pass through instead of + failing pydantic validation. + """ + parsed = json.loads(raw_json) + if not isinstance(parsed, dict): + return RawStreamEvent(id=None, event=None, data=parsed) + return RawStreamEvent( + id=parsed.get("id"), + event=parsed.get("event"), + data=parsed.get("data"), + ) + + +def research_background(client: You, **kwargs: Any) -> TaskResponse: + """Submit a research task with ``background=True`` and return ``TaskResponse``. + + Equivalent to ``client.research(..., background=True)`` but asserts the + response is a ``TaskResponse`` so callers get a strong return type + without needing to narrow ``Union[ResearchResponse, TaskResponse]``. + """ + kw = dict(kwargs) + kw["background"] = True + res = client.research(**kw) + if not isinstance(res, TaskResponse): + raise TypeError( + "research_background requires background=True so the server " + "returns TaskResponse; got " + type(res).__name__ + ) + return res + + +async def research_background_async(client: You, **kwargs: Any) -> TaskResponse: + """Async variant of :func:`research_background`.""" + kw = dict(kwargs) + kw["background"] = True + res = await client.research_async(**kw) + if not isinstance(res, TaskResponse): + raise TypeError( + "research_background_async requires background=True so the " + "server returns TaskResponse; got " + type(res).__name__ + ) + return res + + +def poll_research_task( + client: You, + task_id: str, + *, + interval_s: float = _DEFAULT_POLL_INTERVAL_S, + timeout_s: float = _DEFAULT_POLL_TIMEOUT_S, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, +) -> TaskDetail: + """Poll ``GET /v1/research/{task_id}`` until ``status == "completed"``. + + Parameters: + interval_s: Seconds between poll requests. Defaults to 2.0. + timeout_s: Maximum seconds to wait before raising ``TimeoutError``. + Defaults to 600.0 (10 minutes). For ``frontier`` tasks (up to 4 + hours), pass ``timeout_s=14400``. + + Raises ``RuntimeError`` if the task ends in a non-completed terminal state + (``failed`` / ``cancelled``) and ``TimeoutError`` if ``timeout_s`` elapses + before completion. + """ + deadline = time.monotonic() + timeout_s + while True: + detail = client.get_research_task( + task_id=task_id, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + status = detail.status.value + if status in _TERMINAL_TASK_STATUSES: + if status != "completed": + raise RuntimeError( + f"research task {task_id} ended in non-completed state: {status}" + ) + return detail + if time.monotonic() >= deadline: + raise TimeoutError( + f"research task {task_id} did not complete within {timeout_s}s" + ) + time.sleep(interval_s) + + +async def poll_research_task_async( + client: You, + task_id: str, + *, + interval_s: float = _DEFAULT_POLL_INTERVAL_S, + timeout_s: float = _DEFAULT_POLL_TIMEOUT_S, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, +) -> TaskDetail: + """Async variant of :func:`poll_research_task`. + + See :func:`poll_research_task` for parameter defaults (interval_s=2.0, + timeout_s=600.0). + """ + deadline = time.monotonic() + timeout_s + while True: + detail = await client.get_research_task_async( + task_id=task_id, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + status = detail.status.value + if status in _TERMINAL_TASK_STATUSES: + if status != "completed": + raise RuntimeError( + f"research task {task_id} ended in non-completed state: {status}" + ) + return detail + if time.monotonic() >= deadline: + raise TimeoutError( + f"research task {task_id} did not complete within {timeout_s}s" + ) + await asyncio.sleep(interval_s) + + +def _resolve_from_final_get( + client: You, + task_id: str, + result: Optional[str], + timeout_s: float, + *, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + timed_out: bool = False, +) -> TaskDetail: + """Fetch the final ``TaskDetail`` and resolve based on the stream result. + + Called after the SSE stream terminates (terminal event, timeout, or close + without terminal event). When ``result`` is ``"ok"``, verifies the task is + completed with a short bounded re-poll to tolerate backend commit races. + When ``result`` is a non-None error event name, raises ``RuntimeError``. + When ``result`` is None (timeout or stream close), performs a final GET + and returns the detail if completed, raises ``RuntimeError`` for terminal + non-completed, or ``TimeoutError`` if still running. + """ + if result == "ok": + # The stream signalled completion, but the persisted task status may + # not be committed yet (distributed backend race). Re-poll a few times + # before giving up instead of failing on the first GET. + for _ in range(_REPOLL_MAX_ATTEMPTS): + detail = client.get_research_task( + task_id=task_id, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + if detail.status.value == "completed": + return detail + if detail.status.value in _TERMINAL_TASK_STATUSES: + raise RuntimeError( + f"research task {task_id} ended in non-completed state: " + f"{detail.status.value}" + ) + time.sleep(_REPOLL_INTERVAL_S) + raise RuntimeError( + f"research task {task_id} stream signalled completion " + f"but GET returned status={detail.status.value} " + f"after {_REPOLL_MAX_ATTEMPTS} attempts" + ) + if result is not None: + raise RuntimeError( + f"research task {task_id} ended in non-completed state: {result}" + ) + # result is None: timeout or stream close without terminal event + detail = client.get_research_task( + task_id=task_id, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + status = detail.status.value + if status == "completed": + return detail + if status in _TERMINAL_TASK_STATUSES: + raise RuntimeError( + f"research task {task_id} ended in non-completed state: {status}" + ) + if timed_out: + raise TimeoutError( + f"research task {task_id} did not complete within {timeout_s}s " + f"(status: {status})" + ) + raise TimeoutError( + f"research task {task_id} stream closed without terminal event " + f"and task is still {status}" + ) + + +async def _resolve_from_final_get_async( + client: You, + task_id: str, + result: Optional[str], + timeout_s: float, + *, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + timed_out: bool = False, +) -> TaskDetail: + """Async counterpart of :func:`_resolve_from_final_get`.""" + if result == "ok": + for _ in range(_REPOLL_MAX_ATTEMPTS): + detail = await client.get_research_task_async( + task_id=task_id, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + if detail.status.value == "completed": + return detail + if detail.status.value in _TERMINAL_TASK_STATUSES: + raise RuntimeError( + f"research task {task_id} ended in non-completed state: " + f"{detail.status.value}" + ) + await asyncio.sleep(_REPOLL_INTERVAL_S) + raise RuntimeError( + f"research task {task_id} stream signalled completion " + f"but GET returned status={detail.status.value} " + f"after {_REPOLL_MAX_ATTEMPTS} attempts" + ) + if result is not None: + raise RuntimeError( + f"research task {task_id} ended in non-completed state: {result}" + ) + detail = await client.get_research_task_async( + task_id=task_id, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + status = detail.status.value + if status == "completed": + return detail + if status in _TERMINAL_TASK_STATUSES: + raise RuntimeError( + f"research task {task_id} ended in non-completed state: {status}" + ) + if timed_out: + raise TimeoutError( + f"research task {task_id} did not complete within {timeout_s}s " + f"(status: {status})" + ) + raise TimeoutError( + f"research task {task_id} stream closed without terminal event " + f"and task is still {status}" + ) + + +def research_and_wait( + client: You, + *, + timeout_s: Optional[float] = None, + **kwargs: Any, +) -> TaskDetail: + """Submit a research task in background mode and wait for completion. + + Submits via :func:`research_background`, then opens the SSE stream + and reads events until a terminal event arrives. Fetches the final + ``TaskDetail`` via a ``get_research_task`` call (a second GET is + issued in timeout/stream-close fallback paths). + + The stream is opened with a per-read timeout bounded to ``timeout_s`` + so that a stalled server raises ``httpx.ReadTimeout`` deterministically + instead of leaking a blocked consumer thread. A total wall-clock deadline + is also enforced. + + Parameters: + timeout_s: Maximum seconds to wait for a terminal stream event. + When ``None`` (the default), the timeout is auto-selected based + on ``research_effort``: 600.0 (10 minutes) for standard/deep/ + exhaustive, 14400.0 (4 hours) for ``frontier``. Pass an explicit + value to override. + ``**kwargs``: Forwarded to ``client.research``, including + ``input`` and ``research_effort``. ``background=True`` is set + automatically. ``server_url``, ``timeout_ms``, and + ``http_headers`` are forwarded to all sub-requests. + + Returns: + ``TaskDetail`` with ``status == completed`` and the task's + ``result`` attribute populated. + + Raises: + TimeoutError: If no terminal event arrives within ``timeout_s`` + and the task is still non-terminal. + RuntimeError: If the task ends in a non-completed terminal state. + """ + if timeout_s is None: + timeout_s = _resolve_default_timeout(kwargs) + server_url = kwargs.get("server_url") + timeout_ms = kwargs.get("timeout_ms") + http_headers = kwargs.get("http_headers") + task = research_background(client, **kwargs) + + # The SSE per-read timeout is bounded to timeout_s so a silent stall + # can never exceed the total wait budget. This is decoupled from the + # caller's timeout_ms (which governs individual HTTP requests like the + # POST and GET, not SSE event gaps). + stream_timeout_ms = int(timeout_s * 1000) + try: + stream = _open_raw_stream( + client, task.task_id, http_headers=http_headers, + server_url=server_url, timeout_ms=stream_timeout_ms, + ) + except httpx.TransportError: + # Transient connection failure (can't reach server, etc.): the task + # is already submitted. Fall back to polling. Typed errors (401/403/ + # 404/500 from _raise_stream_error) propagate to the caller. + return poll_research_task( + client, task.task_id, + interval_s=_DEFAULT_POLL_INTERVAL_S, + timeout_s=timeout_s, + server_url=server_url, timeout_ms=timeout_ms, + http_headers=http_headers, + ) + result: Optional[str] = None + timed_out = False + deadline = time.monotonic() + timeout_s + try: + for evt in stream: + if evt.event in _TERMINAL_STREAM_EVENTS_OK: + result = "ok" + break + if evt.event in _TERMINAL_STREAM_EVENTS_ERR: + result = evt.event + break + if time.monotonic() >= deadline: + timed_out = True + break + except httpx.TimeoutException: + timed_out = True + except httpx.TransportError: + # Mid-stream network failure (e.g. RemoteProtocolError on a dropped + # connection): the task is already submitted and running. Fall back + # to polling. Typed SDK errors propagate to the caller. + stream.close() + return poll_research_task( + client, task.task_id, + interval_s=_DEFAULT_POLL_INTERVAL_S, + timeout_s=timeout_s, + server_url=server_url, timeout_ms=timeout_ms, + http_headers=http_headers, + ) + finally: + stream.close() + + return _resolve_from_final_get( + client, task.task_id, result, timeout_s, + server_url=server_url, timeout_ms=timeout_ms, http_headers=http_headers, + timed_out=timed_out, + ) + + +async def research_and_wait_async( + client: You, + *, + timeout_s: Optional[float] = None, + **kwargs: Any, +) -> TaskDetail: + """Async variant of :func:`research_and_wait`. + + See :func:`research_and_wait` for parameter details and auto-timeout + behavior (600s default, 14400s for frontier). The SSE per-read timeout + is bounded to ``timeout_s``, matching the sync variant. + """ + if timeout_s is None: + timeout_s = _resolve_default_timeout(kwargs) + server_url = kwargs.get("server_url") + timeout_ms = kwargs.get("timeout_ms") + http_headers = kwargs.get("http_headers") + task = await research_background_async(client, **kwargs) + + # The SSE per-read timeout is bounded to timeout_s (same rationale as + # sync). Decoupled from the caller's timeout_ms, which governs + # individual HTTP requests (POST, GET), not SSE event gaps. + stream_timeout_ms = int(timeout_s * 1000) + + async def _consume() -> Optional[str]: + async for evt in stream_research_async( + client, task.task_id, + server_url=server_url, timeout_ms=stream_timeout_ms, + http_headers=http_headers, + ): + if evt.event in _TERMINAL_STREAM_EVENTS_OK: + return "ok" + if evt.event in _TERMINAL_STREAM_EVENTS_ERR: + return evt.event + return None + + timed_out = False + try: + result = await asyncio.wait_for(_consume(), timeout=timeout_s) + except (asyncio.TimeoutError, httpx.TimeoutException): + timed_out = True + result = None + except httpx.TransportError: + # Transient connection failure (can't reach server, dropped + # connection, etc.): the task is already submitted. Fall back to + # polling. Typed errors (401/403/404/500) propagate to the caller. + return await poll_research_task_async( + client, task.task_id, + interval_s=_DEFAULT_POLL_INTERVAL_S, + timeout_s=timeout_s, + server_url=server_url, timeout_ms=timeout_ms, + http_headers=http_headers, + ) + + return await _resolve_from_final_get_async( + client, task.task_id, result, timeout_s, + server_url=server_url, timeout_ms=timeout_ms, http_headers=http_headers, + timed_out=timed_out, + ) + + +def _raise_stream_error(http_res: Any) -> None: + """Raise the typed ``StreamResearchTask*Error`` for non-200 responses. + + Mirrors the error-handling branches in the generated + ``client.stream_research_task`` so that callers of ``stream_research`` + get the same structured exceptions (401/403/404/500) as the generated + method instead of a generic ``RuntimeError``. + """ + response_data: Any = None + for status, content_type, data_cls, err_cls in _STREAM_ERROR_BRANCHES: + if match_response(http_res, status, content_type): + text = stream_to_text(http_res) + response_data = unmarshal_json_response(data_cls, http_res, text) + raise err_cls(response_data, http_res, text) + if match_response(http_res, "4XX", "*") or match_response(http_res, "5XX", "*"): + text = stream_to_text(http_res) + raise _errors.YouDefaultError("API error occurred", http_res, text) + text = stream_to_text(http_res) + raise _errors.YouDefaultError("Unexpected response received", http_res, text) + + +async def _raise_stream_error_async(http_res: Any) -> None: + """Async counterpart of :func:`_raise_stream_error`.""" + response_data: Any = None + for status, content_type, data_cls, err_cls in _STREAM_ERROR_BRANCHES: + if match_response(http_res, status, content_type): + text = await stream_to_text_async(http_res) + response_data = unmarshal_json_response(data_cls, http_res, text) + raise err_cls(response_data, http_res, text) + if match_response(http_res, "4XX", "*") or match_response(http_res, "5XX", "*"): + text = await stream_to_text_async(http_res) + raise _errors.YouDefaultError("API error occurred", http_res, text) + text = await stream_to_text_async(http_res) + raise _errors.YouDefaultError("Unexpected response received", http_res, text) + + +def _stream_build_kwargs( + client: You, + task_id: str, + *, + http_headers: Optional[Mapping[str, str]], + from_id: int, + server_url: Optional[str], + timeout_ms: Optional[int], +) -> dict[str, Any]: + """Build common kwargs dict for ``_build_request[_async]``.""" + # pylint: disable=protected-access + base_url = server_url if server_url is not None else client._get_url(None, None) + if timeout_ms is None: + timeout_ms = client.sdk_configuration.timeout_ms + return { + "method": "GET", + "path": "/v1/research/{task_id}/stream", + "base_url": base_url, + "url_variables": None, + "request": _models.StreamResearchTaskRequest(task_id=task_id, from_id=from_id), + "request_body_required": False, + "request_has_path_params": True, + "request_has_query_params": True, + "user_agent_header": "user-agent", + "accept_header_value": "text/event-stream", + "http_headers": http_headers, + "security": client.sdk_configuration.security, + "allow_empty_value": None, + "timeout_ms": timeout_ms, + } + + +def _stream_hook_ctx(client: You, base_url: str) -> HookContext: + """Build the shared ``HookContext`` for stream-open requests.""" + return HookContext( + config=client.sdk_configuration, + base_url=base_url or "", + operation_id="streamResearchTask", + oauth2_scopes=None, + security_source=get_security_from_env( + client.sdk_configuration.security, _models.Security + ), + tags=None, + extensions=None, + ) + + +def _open_raw_stream( + client: You, + task_id: str, + *, + http_headers: Optional[Mapping[str, str]], + from_id: int = 0, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, +) -> eventstreaming.EventStream[RawStreamEvent]: + """Issue the SSE request directly so we can pass a tolerant decoder. + + Mirrors the request setup used by ``client.stream_research_task`` but + wires :func:`_decode_raw_event` into the ``EventStream`` instead of the + strict pydantic ``ResearchTaskStreamEvent`` decoder. + """ + kwargs = _stream_build_kwargs( + client, task_id, http_headers=http_headers, from_id=from_id, + server_url=server_url, timeout_ms=timeout_ms, + ) + base_url = kwargs["base_url"] + # pylint: disable=protected-access + req = client._build_request(**kwargs) + http_res = client.do_request( + hook_ctx=_stream_hook_ctx(client, base_url), + request=req, + is_error_status_code=lambda c: match_status_codes(["4XX", "5XX"], c), + stream=True, + # Diverges from generated stream_research_task which retries + # [429,500,502,503,504] on stream-open. We skip retries so a + # transient 5xx doesn't silently reopen a half-consumed stream. + retry_config=None, + ) + if not match_response(http_res, "200", "text/event-stream"): + _raise_stream_error(http_res) + return eventstreaming.EventStream( + http_res, + _decode_raw_event, + client_ref=client, + data_required=False, + ) + + +def stream_research( + client: You, + task_id: str, + *, + http_headers: Optional[Mapping[str, str]] = None, + from_id: int = 0, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, +) -> Iterator[RawStreamEvent]: + """SSE iterator that accepts unknown event names without failing. + + Each yielded item is a :class:`RawStreamEvent` carrying the raw ``event`` + string and parsed JSON ``data`` payload. Useful when the server may emit + intermediate workflow events with names outside the documented enum + (e.g. ``research.searching``, ``response.created``, + ``response.output_item.added``). + + Parameters: + from_id: Sequence number to resume from (reconnection). ``0`` starts at + the beginning of the stream. + """ + with _open_raw_stream( + client, task_id, http_headers=http_headers, from_id=from_id, + server_url=server_url, timeout_ms=timeout_ms, + ) as stream: + yield from stream + + +async def _open_raw_stream_async( + client: You, + task_id: str, + *, + http_headers: Optional[Mapping[str, str]], + from_id: int = 0, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, +) -> eventstreaming.EventStreamAsync[RawStreamEvent]: + """Async counterpart of :func:`_open_raw_stream`.""" + kwargs = _stream_build_kwargs( + client, task_id, http_headers=http_headers, from_id=from_id, + server_url=server_url, timeout_ms=timeout_ms, + ) + base_url = kwargs["base_url"] + # pylint: disable=protected-access + req = client._build_request_async(**kwargs) + http_res = await client.do_request_async( + hook_ctx=_stream_hook_ctx(client, base_url), + request=req, + is_error_status_code=lambda c: match_status_codes(["4XX", "5XX"], c), + stream=True, + retry_config=None, + ) + if not match_response(http_res, "200", "text/event-stream"): + await _raise_stream_error_async(http_res) + return eventstreaming.EventStreamAsync( + http_res, + _decode_raw_event, + client_ref=client, + data_required=False, + ) + + +async def stream_research_async( + client: You, + task_id: str, + *, + http_headers: Optional[Mapping[str, str]] = None, + from_id: int = 0, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, +) -> AsyncIterator[RawStreamEvent]: + """Async variant of :func:`stream_research`. + + Parameters: + from_id: Sequence number to resume from (reconnection). ``0`` starts at + the beginning of the stream. + """ + # Close the underlying httpx SSE response deterministically on consumer + # break/return/throw. EventStreamAsync exposes async close() (not aclose), + # so a try/finally is used rather than contextlib.aclosing. + stream = await _open_raw_stream_async( + client, task_id, http_headers=http_headers, from_id=from_id, + server_url=server_url, timeout_ms=timeout_ms, + ) + try: + async for evt in stream: + yield evt + finally: + await stream.close() diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index 5d95cb9..166fce4 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -24,7 +24,7 @@ from youdotcom import errors, models, utils from youdotcom._hooks import HookContext, SDKHooks from youdotcom.types import OptionalNullable, UNSET -from youdotcom.utils import get_security_from_env +from youdotcom.utils import eventstreaming, get_security_from_env from youdotcom.utils.unmarshal_json_response import unmarshal_json_response if TYPE_CHECKING: @@ -532,6 +532,7 @@ def research( research_effort: Optional[ models.ResearchEffort ] = models.ResearchEffort.STANDARD, + background: Optional[bool] = False, source_control: Optional[ Union[models.SourceControl, models.SourceControlTypedDict] ] = None, @@ -540,7 +541,7 @@ def research( server_url: Optional[str] = None, timeout_ms: Optional[int] = None, http_headers: Optional[Mapping[str, str]] = None, - ) -> models.ResearchResponse: + ) -> models.ResearchResult: r"""Returns comprehensive research-grade answers with multi-step reasoning Research goes beyond a single web search. In response to your question, it runs multiple searches, reads through the sources, and synthesizes everything into a thorough, well-cited answer. Use it when a question is too complex for a simple lookup, and when you need a response you can actually trust and verify. @@ -555,6 +556,8 @@ def research( - `standard`: The default. Balances speed and depth, a good fit for most questions. - `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result. + - `frontier`: The highest-quality tier. Runs over longer durations with improved quality and accuracy. Only works with the task-based API (`background=true`); sending `frontier` without `background=true` returns a 422. + :param background: When true, queue a research task and return a task handle immediately instead of waiting for the result inline. Defaults to synchronous. When enabled, the response is a TaskResponse object with a task_id and stream_url for polling progress via GET /v1/research/{task_id} or streaming via GET /v1/research/{task_id}/stream. :param source_control: Beta. Controls which web sources the research agent searches and visits. Use this to allow specific domains, block specific domains, boost specific domains, filter by recency, or focus web results by country. `include_domains` and `exclude_domains` cannot be used together. Each domain list is capped at 500 entries. `exclude_domains` also blocks the research agent from visiting pages on those domains during browsing. `boost_domains` gives matching domains a relative ranking boost without filtering out other domains. It can be combined with `exclude_domains` but cannot be combined with `include_domains`. @@ -581,6 +584,7 @@ def research( request = models.ResearchRequest( input=input, research_effort=research_effort, + background=background, source_control=utils.get_pydantic_model( source_control, Optional[models.SourceControl] ), @@ -634,7 +638,7 @@ def research( response_data: Any = None if utils.match_response(http_res, "200", "application/json"): - return unmarshal_json_response(models.ResearchResponse, http_res) + return unmarshal_json_response(models.ResearchResult, http_res) if utils.match_response(http_res, "401", "application/json"): response_data = unmarshal_json_response( errors.ResearchUnauthorizedErrorData, http_res @@ -671,6 +675,7 @@ async def research_async( research_effort: Optional[ models.ResearchEffort ] = models.ResearchEffort.STANDARD, + background: Optional[bool] = False, source_control: Optional[ Union[models.SourceControl, models.SourceControlTypedDict] ] = None, @@ -679,7 +684,7 @@ async def research_async( server_url: Optional[str] = None, timeout_ms: Optional[int] = None, http_headers: Optional[Mapping[str, str]] = None, - ) -> models.ResearchResponse: + ) -> models.ResearchResult: r"""Returns comprehensive research-grade answers with multi-step reasoning Research goes beyond a single web search. In response to your question, it runs multiple searches, reads through the sources, and synthesizes everything into a thorough, well-cited answer. Use it when a question is too complex for a simple lookup, and when you need a response you can actually trust and verify. @@ -694,6 +699,8 @@ async def research_async( - `standard`: The default. Balances speed and depth, a good fit for most questions. - `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result. + - `frontier`: The highest-quality tier. Runs over longer durations with improved quality and accuracy. Only works with the task-based API (`background=true`); sending `frontier` without `background=true` returns a 422. + :param background: When true, queue a research task and return a task handle immediately instead of waiting for the result inline. Defaults to synchronous. When enabled, the response is a TaskResponse object with a task_id and stream_url for polling progress via GET /v1/research/{task_id} or streaming via GET /v1/research/{task_id}/stream. :param source_control: Beta. Controls which web sources the research agent searches and visits. Use this to allow specific domains, block specific domains, boost specific domains, filter by recency, or focus web results by country. `include_domains` and `exclude_domains` cannot be used together. Each domain list is capped at 500 entries. `exclude_domains` also blocks the research agent from visiting pages on those domains during browsing. `boost_domains` gives matching domains a relative ranking boost without filtering out other domains. It can be combined with `exclude_domains` but cannot be combined with `include_domains`. @@ -720,6 +727,7 @@ async def research_async( request = models.ResearchRequest( input=input, research_effort=research_effort, + background=background, source_control=utils.get_pydantic_model( source_control, Optional[models.SourceControl] ), @@ -773,7 +781,7 @@ async def research_async( response_data: Any = None if utils.match_response(http_res, "200", "application/json"): - return unmarshal_json_response(models.ResearchResponse, http_res) + return unmarshal_json_response(models.ResearchResult, http_res) if utils.match_response(http_res, "401", "application/json"): response_data = unmarshal_json_response( errors.ResearchUnauthorizedErrorData, http_res @@ -803,6 +811,488 @@ async def research_async( raise errors.YouDefaultError("Unexpected response received", http_res) + def get_research_task( + self, + *, + task_id: str, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.TaskDetail: + r"""Get the status of a background research task + + Poll the status of a background research task created with background=true. When the task is completed, the result is included in the response. + + :param task_id: The UUID of the research task. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.GetResearchTaskRequest( + task_id=task_id, + ) + + req = self._build_request( + method="GET", + path="/v1/research/{task_id}", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=False, + request_has_path_params=True, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + security=self.sdk_configuration.security, + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = self.do_request( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="getResearchTask", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=None, + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.TaskDetail, http_res) + if utils.match_response(http_res, "401", "application/json"): + response_data = unmarshal_json_response( + errors.GetResearchTaskUnauthorizedErrorData, http_res + ) + raise errors.GetResearchTaskUnauthorizedError(response_data, http_res) + if utils.match_response(http_res, "403", "application/json"): + response_data = unmarshal_json_response( + errors.GetResearchTaskForbiddenErrorData, http_res + ) + raise errors.GetResearchTaskForbiddenError(response_data, http_res) + if utils.match_response(http_res, "404", "application/json"): + response_data = unmarshal_json_response( + errors.GetResearchTaskNotFoundErrorData, http_res + ) + raise errors.GetResearchTaskNotFoundError(response_data, http_res) + if utils.match_response(http_res, "500", "application/json"): + response_data = unmarshal_json_response( + errors.GetResearchTaskInternalServerErrorData, http_res + ) + raise errors.GetResearchTaskInternalServerError(response_data, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + raise errors.YouDefaultError("Unexpected response received", http_res) + + async def get_research_task_async( + self, + *, + task_id: str, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.TaskDetail: + r"""Get the status of a background research task + + Poll the status of a background research task created with background=true. When the task is completed, the result is included in the response. + + :param task_id: The UUID of the research task. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.GetResearchTaskRequest( + task_id=task_id, + ) + + req = self._build_request_async( + method="GET", + path="/v1/research/{task_id}", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=False, + request_has_path_params=True, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + security=self.sdk_configuration.security, + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await self.do_request_async( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="getResearchTask", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=None, + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.TaskDetail, http_res) + if utils.match_response(http_res, "401", "application/json"): + response_data = unmarshal_json_response( + errors.GetResearchTaskUnauthorizedErrorData, http_res + ) + raise errors.GetResearchTaskUnauthorizedError(response_data, http_res) + if utils.match_response(http_res, "403", "application/json"): + response_data = unmarshal_json_response( + errors.GetResearchTaskForbiddenErrorData, http_res + ) + raise errors.GetResearchTaskForbiddenError(response_data, http_res) + if utils.match_response(http_res, "404", "application/json"): + response_data = unmarshal_json_response( + errors.GetResearchTaskNotFoundErrorData, http_res + ) + raise errors.GetResearchTaskNotFoundError(response_data, http_res) + if utils.match_response(http_res, "500", "application/json"): + response_data = unmarshal_json_response( + errors.GetResearchTaskInternalServerErrorData, http_res + ) + raise errors.GetResearchTaskInternalServerError(response_data, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + raise errors.YouDefaultError("Unexpected response received", http_res) + + def stream_research_task( + self, + *, + task_id: str, + from_id: Optional[int] = 0, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> eventstreaming.EventStream[models.ResearchTaskStreamEvent]: + r"""Stream updates for a background research task + + Stream real-time updates for a background research task via Server-Sent Events (SSE). Supports reconnection via the from_id query parameter to replay missed events. The connection closes automatically when the task reaches a terminal state. + + :param task_id: The UUID of the research task. + :param from_id: Resume from a sequence number for reconnection. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.StreamResearchTaskRequest( + task_id=task_id, + from_id=from_id, + ) + + req = self._build_request( + method="GET", + path="/v1/research/{task_id}/stream", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=False, + request_has_path_params=True, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="text/event-stream", + http_headers=http_headers, + security=self.sdk_configuration.security, + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = self.do_request( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="streamResearchTask", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=None, + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + stream=True, + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "text/event-stream"): + return eventstreaming.EventStream( + http_res, + lambda raw: unmarshal_json_response( + models.ResearchTaskStreamEvent, http_res, raw + ), + client_ref=self, + ) + if utils.match_response(http_res, "401", "application/json"): + http_res_text = utils.stream_to_text(http_res) + response_data = unmarshal_json_response( + errors.StreamResearchTaskUnauthorizedErrorData, http_res, http_res_text + ) + raise errors.StreamResearchTaskUnauthorizedError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "403", "application/json"): + http_res_text = utils.stream_to_text(http_res) + response_data = unmarshal_json_response( + errors.StreamResearchTaskForbiddenErrorData, http_res, http_res_text + ) + raise errors.StreamResearchTaskForbiddenError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "404", "application/json"): + http_res_text = utils.stream_to_text(http_res) + response_data = unmarshal_json_response( + errors.StreamResearchTaskNotFoundErrorData, http_res, http_res_text + ) + raise errors.StreamResearchTaskNotFoundError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "500", "application/json"): + http_res_text = utils.stream_to_text(http_res) + response_data = unmarshal_json_response( + errors.StreamResearchTaskInternalServerErrorData, + http_res, + http_res_text, + ) + raise errors.StreamResearchTaskInternalServerError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError( + "Unexpected response received", http_res, http_res_text + ) + + async def stream_research_task_async( + self, + *, + task_id: str, + from_id: Optional[int] = 0, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> eventstreaming.EventStreamAsync[models.ResearchTaskStreamEvent]: + r"""Stream updates for a background research task + + Stream real-time updates for a background research task via Server-Sent Events (SSE). Supports reconnection via the from_id query parameter to replay missed events. The connection closes automatically when the task reaches a terminal state. + + :param task_id: The UUID of the research task. + :param from_id: Resume from a sequence number for reconnection. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.StreamResearchTaskRequest( + task_id=task_id, + from_id=from_id, + ) + + req = self._build_request_async( + method="GET", + path="/v1/research/{task_id}/stream", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=False, + request_has_path_params=True, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="text/event-stream", + http_headers=http_headers, + security=self.sdk_configuration.security, + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await self.do_request_async( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="streamResearchTask", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=None, + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + stream=True, + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "text/event-stream"): + return eventstreaming.EventStreamAsync( + http_res, + lambda raw: unmarshal_json_response( + models.ResearchTaskStreamEvent, http_res, raw + ), + client_ref=self, + ) + if utils.match_response(http_res, "401", "application/json"): + http_res_text = await utils.stream_to_text_async(http_res) + response_data = unmarshal_json_response( + errors.StreamResearchTaskUnauthorizedErrorData, http_res, http_res_text + ) + raise errors.StreamResearchTaskUnauthorizedError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "403", "application/json"): + http_res_text = await utils.stream_to_text_async(http_res) + response_data = unmarshal_json_response( + errors.StreamResearchTaskForbiddenErrorData, http_res, http_res_text + ) + raise errors.StreamResearchTaskForbiddenError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "404", "application/json"): + http_res_text = await utils.stream_to_text_async(http_res) + response_data = unmarshal_json_response( + errors.StreamResearchTaskNotFoundErrorData, http_res, http_res_text + ) + raise errors.StreamResearchTaskNotFoundError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "500", "application/json"): + http_res_text = await utils.stream_to_text_async(http_res) + response_data = unmarshal_json_response( + errors.StreamResearchTaskInternalServerErrorData, + http_res, + http_res_text, + ) + raise errors.StreamResearchTaskInternalServerError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError( + "Unexpected response received", http_res, http_res_text + ) + def finance_research( self, *, @@ -826,6 +1316,7 @@ def finance_research( :param research_effort: Controls how much time and effort the Finance Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time. Available levels: + - `lite`: Returns answers quickly. Good for straightforward financial questions that just need a fast, reliable answer. - `deep`: The default. Spends more time researching and cross-referencing sources. Good for most financial questions, including multi-company comparisons, earnings analysis, and regulatory research. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex financial research tasks where you want the highest quality result. :param retries: Override the default retry configuration for this method @@ -950,6 +1441,7 @@ async def finance_research_async( :param research_effort: Controls how much time and effort the Finance Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time. Available levels: + - `lite`: Returns answers quickly. Good for straightforward financial questions that just need a fast, reliable answer. - `deep`: The default. Spends more time researching and cross-referencing sources. Good for most financial questions, including multi-company comparisons, earnings analysis, and regulatory research. - `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex financial research tasks where you want the highest quality result. :param retries: Override the default retry configuration for this method diff --git a/tests/README.md b/tests/README.md index 5a8c7e3..ca38eb3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -55,6 +55,10 @@ pytest tests/ -v - `test_search.py` - Tests for the Search API (`/v1/search`) - `test_contents.py` - Tests for the Contents API (`/v1/contents`) - `test_runs.py` - Tests for the Agents/Runs API (`/v1/agents/runs`) +- `test_research.py` - Tests for the Research API (`/v1/research`) including background mode, output_schema, and source_control +- `test_research_helpers.py` - Tests for the hand-maintained `research_helpers` module (background submission, polling, streaming, research_and_wait) +- `test_security_env.py` - Tests for environment variable precedence (`YDC_API_KEY` / `YOU_API_KEY_AUTH`) +- `test_user_agent_hook.py` - Tests for the `YDCUserAgentOverrideHook` custom user-agent pass-through - `test_performance.py` - Performance/instrumentation tests measuring SDK overhead - `test_live.py` - Live API tests that run against the real You.com API (requires API key) @@ -82,6 +86,21 @@ Tests are organized into logical classes using pytest: - Tool configurations and verbosity - Error handling (unauthorized, forbidden, empty input) +**Research API**: +- Basic research functionality (standard, deep, exhaustive effort) +- Background mode (task submission, get_research_task, status polling) +- Output schema (structured JSON output, content_type object) +- Source control (include/exclude/boost domains, freshness, country) +- Error handling (unauthorized, forbidden, unprocessable entity, 422 combos) +- Stream research task (SSE success path + 404/401/403 error paths) + +**Research Helpers**: +- research_background / research_background_async (TaskResponse return) +- poll_research_task / poll_research_task_async (terminal status) +- research_and_wait / research_and_wait_async (submit + wait) +- stream_research / stream_research_async (tolerant SSE) +- RawStreamEvent decoder (_decode_raw_event) + ### Running Live Tests The `test_live.py` file contains tests that run against the real You.com API. These are skipped by default unless an API key is provided: @@ -115,7 +134,9 @@ The tests use a mock server located in `tests/mockserver/`. This server contains The mock server supports: - Success responses for all endpoints -- Error responses (401 Unauthorized, 403 Forbidden) +- Error responses (401 Unauthorized, 403 Forbidden, 404 Not Found) +- Background research task endpoints (GET /v1/research/{task_id}, GET /v1/research/{task_id}/stream) +- SSE streaming for research task updates - Multiple test scenarios per endpoint See [mockserver/README.md](mockserver/README.md) for more details. diff --git a/tests/mockserver/internal/handler/generated_handlers.go b/tests/mockserver/internal/handler/generated_handlers.go index 6db3e34..e2bdae4 100644 --- a/tests/mockserver/internal/handler/generated_handlers.go +++ b/tests/mockserver/internal/handler/generated_handlers.go @@ -16,6 +16,8 @@ func GeneratedHandlers(ctx context.Context, dir *logging.HTTPFileDirectory, rt * NewGeneratedHandler(ctx, http.MethodPost, "/v1/agents/runs", pathPostV1AgentsRuns(dir, rt)), NewGeneratedHandler(ctx, http.MethodPost, "/v1/contents", pathPostV1Contents(dir, rt)), NewGeneratedHandler(ctx, http.MethodPost, "/v1/research", pathPostV1Research(dir, rt)), + NewGeneratedHandler(ctx, http.MethodGet, "/v1/research/{task_id}", pathGetV1Research(dir, rt)), + NewGeneratedHandler(ctx, http.MethodGet, "/v1/research/{task_id}/stream", pathGetV1ResearchStream(dir, rt)), NewGeneratedHandler(ctx, http.MethodPost, "/v1/finance_research", pathPostV1FinanceResearch(dir, rt)), } } diff --git a/tests/mockserver/internal/handler/pathgetv1research.go b/tests/mockserver/internal/handler/pathgetv1research.go new file mode 100644 index 0000000..781dcdf --- /dev/null +++ b/tests/mockserver/internal/handler/pathgetv1research.go @@ -0,0 +1,111 @@ +package handler + +import ( + "encoding/json" + "fmt" + "log" + "mockserver/internal/handler/assert" + "mockserver/internal/logging" + "mockserver/internal/tracking" + "net/http" +) + +func pathGetV1Research(dir *logging.HTTPFileDirectory, rt *tracking.RequestTracker) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + test := req.Header.Get("x-speakeasy-test-name") + instanceID := req.Header.Get("x-speakeasy-test-instance-id") + + count := rt.GetRequestCount(test, instanceID) + + switch fmt.Sprintf("%s[%d]", test, count) { + case "get_/v1/research/{task_id}[0]": + dir.HandlerFunc("get_/v1/research/{task_id}", testGetV1ResearchTaskSuccess)(w, req) + case "get_/v1/research/{task_id}-not-found[0]": + testGetV1ResearchTaskNotFound(w, req) + case "get_/v1/research/{task_id}-unauthorized[0]": + testGetV1ResearchTaskUnauthorized(w, req) + case "get_/v1/research/{task_id}-forbidden[0]": + testGetV1ResearchTaskForbidden(w, req) + case "get_/v1/research/{task_id}-internal-error[0]": + testGetV1ResearchTaskInternalError(w, req) + default: + dir.HandlerFunc("get_/v1/research/{task_id}", testGetV1ResearchTaskSuccess)(w, req) + } + } +} + +// testGetV1ResearchTaskSuccess returns a TaskDetail with status "completed" +// and a populated result block, mirroring the structure used by the real API +// after a background research task finishes. +func testGetV1ResearchTaskSuccess(w http.ResponseWriter, req *http.Request) { + if err := assert.SecurityHeader(req, "X-API-Key", false); err != nil { + log.Printf("assertion error: %s\n", err) + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if err := assert.HeaderExists(req, "User-Agent"); err != nil { + log.Printf("assertion error: %s\n", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + respBody := map[string]interface{}{ + "id": "00000000-0000-0000-0000-000000000001", + "task_type": "research", + "status": "completed", + "created_at": "2026-07-09T00:00:00Z", + "updated_at": "2026-07-09T00:02:30Z", + "completed_at": "2026-07-09T00:02:30Z", + "error": nil, + "input": map[string]interface{}{ + "input": "Compare NVIDIA, AMD, and Intel revenue over 5 years", + "research_effort": "deep", + }, + "result": map[string]interface{}{ + "output": map[string]interface{}{ + "content": "# Mock Research Result\n\nMock result for completed background task.", + "content_type": "text", + "sources": []map[string]interface{}{ + { + "url": "https://example.com/research/1", + "title": "Mock Research Source 1", + "snippets": []string{"This is a relevant snippet from source 1."}, + }, + }, + }, + }, + } + + respBodyBytes, err := json.Marshal(respBody) + if err != nil { + http.Error(w, "Unable to encode response body as JSON: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(respBodyBytes) +} + +func testGetV1ResearchTaskNotFound(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"Task not found"}`)) +} + +func testGetV1ResearchTaskUnauthorized(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Invalid or expired API key"}`)) +} + +func testGetV1ResearchTaskForbidden(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"detail":"Forbidden"}`)) +} + +func testGetV1ResearchTaskInternalError(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"Internal server error"}`)) +} diff --git a/tests/mockserver/internal/handler/pathgetv1researchstream.go b/tests/mockserver/internal/handler/pathgetv1researchstream.go new file mode 100644 index 0000000..4b0c57d --- /dev/null +++ b/tests/mockserver/internal/handler/pathgetv1researchstream.go @@ -0,0 +1,130 @@ +package handler + +import ( + "encoding/json" + "fmt" + "log" + "mockserver/internal/handler/assert" + "mockserver/internal/logging" + "mockserver/internal/tracking" + "net/http" +) + +func pathGetV1ResearchStream(dir *logging.HTTPFileDirectory, rt *tracking.RequestTracker) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + test := req.Header.Get("x-speakeasy-test-name") + instanceID := req.Header.Get("x-speakeasy-test-instance-id") + + count := rt.GetRequestCount(test, instanceID) + + switch fmt.Sprintf("%s[%d]", test, count) { + case "get_/v1/research/{task_id}/stream[0]": + dir.HandlerFunc("get_/v1/research/{task_id}/stream", testGetV1ResearchStreamSuccess)(w, req) + case "get_/v1/research/{task_id}/stream-not-found[0]": + testGetV1ResearchStreamNotFound(w, req) + case "get_/v1/research/{task_id}/stream-unauthorized[0]": + testGetV1ResearchStreamUnauthorized(w, req) + case "get_/v1/research/{task_id}/stream-forbidden[0]": + testGetV1ResearchStreamForbidden(w, req) + case "get_/v1/research/{task_id}/stream-internal-error[0]": + testGetV1ResearchStreamInternalError(w, req) + default: + dir.HandlerFunc("get_/v1/research/{task_id}/stream", testGetV1ResearchStreamSuccess)(w, req) + } + } +} + +// testGetV1ResearchStreamSuccess emits the SSE sequence documented in the +// Research API stream spec: an opening `connected` event followed by a +// terminal `response.done` event, then a normal close. +// +// Terminal event types: {"response.done", "complete", "completed", +// "error", "failed", "cancelled"}. +func testGetV1ResearchStreamSuccess(w http.ResponseWriter, req *http.Request) { + if err := assert.SecurityHeader(req, "X-API-Key", false); err != nil { + log.Printf("assertion error: %s\n", err) + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if err := assert.HeaderExists(req, "User-Agent"); err != nil { + log.Printf("assertion error: %s\n", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // SSE requires these headers; flushing after each event lets the client + // receive events incrementally. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + if !ok { + log.Printf("response writer does not support flushing; stream may buffer") + } + + writeSSE := func(id, event string, data map[string]interface{}) bool { + payload, err := json.Marshal(data) + if err != nil { + log.Printf("error marshalling SSE data: %s", err) + return false + } + if _, err := fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n\n", id, event, payload); err != nil { + return false + } + if ok { + flusher.Flush() + } + return true + } + + taskID := "00000000-0000-0000-0000-000000000001" + + // 1) Opening event — sent unconditionally when the stream is opened. + if !writeSSE("0", "connected", map[string]interface{}{ + "type": "connected", + "task_id": taskID, + "status": "running", + }) { + return + } + + // 2) Terminal event — closes the stream. `response.done` is one of the + // six terminal event names the SDK treats as stream-end: + // OK: response.done, complete, completed + // ERROR: error, failed, cancelled + if !writeSSE("1", "response.done", map[string]interface{}{ + "type": "response.done", + "task_id": taskID, + "status": "completed", + "sequence": 1, + }) { + return + } +} + +func testGetV1ResearchStreamNotFound(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"Task not found"}`)) +} + +func testGetV1ResearchStreamUnauthorized(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Invalid or expired API key"}`)) +} + +func testGetV1ResearchStreamForbidden(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"detail":"Forbidden"}`)) +} + +func testGetV1ResearchStreamInternalError(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"Internal server error"}`)) +} diff --git a/tests/mockserver/internal/handler/pathpostv1research.go b/tests/mockserver/internal/handler/pathpostv1research.go index fff4d02..3b8d857 100644 --- a/tests/mockserver/internal/handler/pathpostv1research.go +++ b/tests/mockserver/internal/handler/pathpostv1research.go @@ -28,6 +28,8 @@ func pathPostV1Research(dir *logging.HTTPFileDirectory, rt *tracking.RequestTrac switch fmt.Sprintf("%s[%d]", test, count) { case "post_/v1/research[0]": dir.HandlerFunc("post_/v1/research", testPostV1ResearchSuccess)(w, req) + case "post_/v1/research-background[0]": + dir.HandlerFunc("post_/v1/research-background", testPostV1ResearchBackground)(w, req) case "post_/v1/research-unauthorized[0]": testPostV1ResearchUnauthorized(w, req) case "post_/v1/research-forbidden[0]": @@ -79,6 +81,13 @@ func testPostV1ResearchSuccess(w http.ResponseWriter, req *http.Request) { effort = "standard" } + // When `background: true` is set, return a TaskResponse shape so the SDK + // can deserialize the async task handle instead of an inline ResearchResponse. + if background, _ := requestBody["background"].(bool); background { + respondTaskResponse(w, "research", "queued", "/v1/research/00000000-0000-0000-0000-000000000001/stream") + return + } + respBody := buildResearchResponse(input, effort, requestBody) respBodyBytes, err := json.Marshal(respBody) if err != nil { @@ -144,6 +153,42 @@ func testPostV1ResearchUnauthorized(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(`{"message":"Invalid or expired API key"}`)) } +// respondTaskResponse writes a TaskResponse payload used by background-mode +// research. +func respondTaskResponse(w http.ResponseWriter, typeValue, statusValue, streamPathSuffix string) { + respBody := map[string]interface{}{ + "task_id": "00000000-0000-0000-0000-000000000001", + "type": typeValue, + "status": statusValue, + "stream_url": streamPathSuffix, + "created_at": "2026-07-09T00:00:00Z", + } + respBodyBytes, err := json.Marshal(respBody) + if err != nil { + http.Error(w, "Unable to encode response body as JSON: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(respBodyBytes) +} + +// testPostV1ResearchBackground handles the explicit `post_/v1/research-background` +// test name and returns a TaskResponse shape for background-mode submission. +func testPostV1ResearchBackground(w http.ResponseWriter, req *http.Request) { + if err := assert.SecurityHeader(req, "X-API-Key", false); err != nil { + log.Printf("assertion error: %s\n", err) + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if err := assert.ContentType(req, "application/json", true); err != nil { + log.Printf("assertion error: %s\n", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + respondTaskResponse(w, "research", "queued", "/v1/research/00000000-0000-0000-0000-000000000001/stream") +} + func testPostV1ResearchForbidden(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) diff --git a/tests/test_live.py b/tests/test_live.py index e2d23c7..ebe9304 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -34,8 +34,21 @@ AgentRunsBatchResponse, ResearchEffort, ResearchResponse, + TaskResponse, + TaskDetail, FinanceResearchEffort, ) +from youdotcom.research_helpers import ( + research_background, + poll_research_task, + research_and_wait, + stream_research, +) +from youdotcom.errors import ( + FinanceResearchUnprocessableEntityError, + ResearchUnprocessableEntityError, + YouDefaultError, +) # Skip all tests in this file if no API key is provided. @@ -407,6 +420,27 @@ def test_finance_research_basic(self, you_client): for source in res.output.sources: assert source.url is not None + def test_finance_research_lite_effort(self, you_client): + """Test finance_research with LITE effort returns a quick answer. + Skipped if the server hasn't deployed the lite tier yet.""" + with you_client as you: + try: + res = you.finance_research( + input="What was Apple's revenue in fiscal year 2024?", + research_effort=FinanceResearchEffort.LITE, + ) + except (FinanceResearchUnprocessableEntityError, YouDefaultError) as e: + if "lite" in str(e).lower() or "422" in str(e): + pytest.skip("Finance research lite tier not yet deployed on server") + raise + + assert res.output is not None + assert res.output.content is not None + assert len(res.output.content) > 0 + if res.output.sources: + for source in res.output.sources: + assert source.url is not None + class TestLiveContentsMaxAge: """Live test for Contents `max_age` parameter. @@ -447,6 +481,233 @@ def test_search_post_boost_domains_list(self, you_client): assert res.results is not None +# --------------------------------------------------------------------------- +# Background-mode research (new in 2.5.0) +# --------------------------------------------------------------------------- +# These tests exercise the live API's async task path: +# POST /v1/research?background=true -> TaskResponse +# GET /v1/research/{task_id} -> TaskDetail +# GET /v1/research/{task_id}/stream -> SSE stream +# +# All tests use LITE effort so the task finishes in 10-30s on prod. +# The suite is sequential within each test: submit, then poll or stream +# until a terminal state arrives. If background mode is not enabled on +# the server, the research() call falls back to a sync ResearchResponse +# and the test is skipped with a clear message. +# --------------------------------------------------------------------------- + +_BG_TIMEOUT_S = 120.0 # generous wall-clock for LITE background tasks + + +class TestLiveResearchBackground: + """Live tests for background-mode research (POST /v1/research?background=true).""" + + def test_background_returns_task_response(self, you_client): + """research(background=True) should return a TaskResponse, not ResearchResponse.""" + with you_client as you: + res = you.research( + input="What is the capital of France?", + research_effort=ResearchEffort.LITE, + background=True, + ) + + if isinstance(res, ResearchResponse): + pytest.skip("Background mode not enabled on server (got sync ResearchResponse)") + + assert isinstance(res, TaskResponse) + assert res.task_id is not None + assert len(res.task_id) > 0 + assert res.type == "research" + assert res.status is not None + assert res.stream_url is not None + assert res.stream_url.startswith("/v1/research/") + assert res.stream_url.endswith("/stream") + assert res.created_at is not None + + def test_get_research_task_returns_task_detail(self, you_client): + """get_research_task() should return a TaskDetail for a background task.""" + with you_client as you: + task = you.research( + input="What is the capital of France?", + research_effort=ResearchEffort.LITE, + background=True, + ) + + if isinstance(task, ResearchResponse): + pytest.skip("Background mode not enabled on server") + + assert isinstance(task, TaskResponse) + + detail = you.get_research_task(task_id=task.task_id) + assert isinstance(detail, TaskDetail) + assert detail.id == task.task_id + assert detail.task_type == "research" + assert detail.status is not None + assert detail.created_at is not None + # input should be preserved (TaskDetailInput uses extra="allow") + assert detail.input is not None + input_dump = detail.input.model_dump() + assert input_dump.get("input") == "What is the capital of France?" + + def test_poll_until_completed(self, you_client): + """Poll get_research_task() until status == completed, verify result.""" + with you_client as you: + task = you.research( + input="What is the capital of France?", + research_effort=ResearchEffort.LITE, + background=True, + ) + + if isinstance(task, ResearchResponse): + pytest.skip("Background mode not enabled on server") + + assert isinstance(task, TaskResponse) + + detail = poll_research_task( + you, + task.task_id, + interval_s=3.0, + timeout_s=_BG_TIMEOUT_S, + ) + + assert detail.status.value == "completed" + assert detail.completed_at is not None + # Result should contain the ResearchResponse payload + assert detail.result is not None + result_dump = detail.result.model_dump() + assert "output" in result_dump + output = result_dump["output"] + assert "content" in output + assert len(str(output["content"])) > 0 + + def test_research_and_wait(self, you_client): + """research_and_wait submits + streams + returns completed TaskDetail.""" + with you_client as you: + try: + detail = research_and_wait( + you, + timeout_s=_BG_TIMEOUT_S, + timeout_ms=180_000, + input="What is the capital of France?", + research_effort=ResearchEffort.LITE, + ) + except TypeError as e: + if "TaskResponse" in str(e): + pytest.skip("Background mode not enabled on server") + raise + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + assert detail.result is not None + result_dump = detail.result.model_dump() + assert "output" in result_dump + + +class TestLiveResearchBackgroundHelpers: + """Live tests for the research_helpers convenience functions.""" + + def test_research_background_helper(self, you_client): + """research_background() helper asserts and returns TaskResponse.""" + with you_client as you: + try: + task = research_background( + you, + input="What is the capital of France?", + research_effort=ResearchEffort.LITE, + ) + except TypeError as e: + if "TaskResponse" in str(e): + pytest.skip("Background mode not enabled on server") + raise + + assert isinstance(task, TaskResponse) + assert task.task_id is not None + assert task.stream_url is not None + + def test_stream_research(self, you_client): + """stream_research() yields SSE events from a live task. + + The server's SSE stream sends a 'connected' event immediately, then + pings. Terminal events may not arrive for tasks that complete + mid-stream, so we iterate with a generous timeout and verify + at least the 'connected' event was received. + """ + with you_client as you: + try: + task = research_background( + you, + input="What is the capital of France?", + research_effort=ResearchEffort.LITE, + ) + except TypeError as e: + if "TaskResponse" in str(e): + pytest.skip("Background mode not enabled on server") + raise + + assert isinstance(task, TaskResponse) + + terminal = {"response.done", "complete", "completed", "error", "failed", "cancelled"} + + events = [] + for evt in stream_research(you, task_id=task.task_id, timeout_ms=180_000): + events.append(evt) + if evt.event in terminal: + break + + assert len(events) > 0, "No SSE events received" + assert events[0].event == "connected" + # The connected event should carry task_id and status + assert events[0].data is not None + assert events[0].data.get("task_id") == task.task_id + assert events[0].data.get("status") is not None + + +# --------------------------------------------------------------------------- +# Frontier research effort (new in 2.5.0) +# --------------------------------------------------------------------------- +# Frontier only works with background=true and can run up to 4 hours. +# For a live test we use a simple query and a generous but bounded timeout. +# Marked slow so it can be skipped with `-m "not slow"`. +# --------------------------------------------------------------------------- +class TestLiveResearchFrontier: + """Live tests for frontier research effort (requires background=true).""" + + @pytest.mark.slow + def test_frontier_background_completes(self, you_client): + """research_and_wait with frontier effort auto-adjusts timeout to 4h. + Uses a simple query so it completes in a few minutes on prod.""" + with you_client as you: + try: + detail = research_and_wait( + you, + input="Who is Bill Gates?", + research_effort=ResearchEffort.FRONTIER, + timeout_s=600, # bounded for CI; auto would be 14400 + ) + except TypeError as e: + if "TaskResponse" in str(e): + pytest.skip("Background mode not enabled on server") + raise + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + assert detail.result is not None + payload = detail.result.model_dump() + content = payload.get("output", {}).get("content", "") + assert len(content) > 0 + + @pytest.mark.slow + def test_frontier_without_background_raises_422(self, you_client): + """frontier without background=true should return 422.""" + with you_client as you: + with pytest.raises((ResearchUnprocessableEntityError, YouDefaultError)): + you.research( + input="Who is Bill Gates?", + research_effort=ResearchEffort.FRONTIER, + background=False, + ) + + if __name__ == "__main__": # Run with: python -m pytest tests/test_live.py -v pytest.main([__file__, "-v"]) diff --git a/tests/test_research.py b/tests/test_research.py index de579c4..84a4919 100644 --- a/tests/test_research.py +++ b/tests/test_research.py @@ -5,7 +5,7 @@ import httpx from tests.test_client import create_test_http_client -from youdotcom import You +from youdotcom import You, errors from youdotcom.errors import ( FinanceResearchUnauthorizedError, FinanceResearchUnprocessableEntityError, @@ -20,7 +20,9 @@ FinanceResearchEffort, ResearchEffort, ResearchResponse, + TaskResponse, ) +from youdotcom.utils import eventstreaming @pytest.fixture @@ -92,6 +94,23 @@ def test_research_exhaustive_effort(self, server_url, api_key): assert res.output.content is not None assert len(res.output.content) > 0 + def test_research_frontier_effort_with_background(self, server_url, api_key): + """frontier requires background=true; the mockserver returns a + TaskResponse when background=true regardless of effort tier.""" + client = create_test_http_client("post_/v1/research-background") + + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + res = you.research( + input="Evaluate the measurable global-health impact of the Gates Foundation", + research_effort=ResearchEffort.FRONTIER, + background=True, + server_url=server_url, + ) + + assert isinstance(res, TaskResponse) + assert res.task_id is not None + assert res.status.value == "queued" + def test_research_with_sources(self, server_url, api_key): client = create_test_http_client("post_/v1/research") @@ -197,6 +216,20 @@ def test_basic_finance_research(self, server_url, api_key): assert source.url is not None assert source.title is not None + def test_finance_research_lite_effort(self, server_url, api_key): + client = create_test_http_client("post_/v1/finance_research") + + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + res = you.finance_research( + input="What was Apple's revenue in FY2024?", + research_effort=FinanceResearchEffort.LITE, + server_url=server_url, + ) + + assert res.output is not None + assert res.output.content is not None + assert "effort: lite" in res.output.content + def test_finance_research_unauthorized(self, server_url): client = create_test_http_client("post_/v1/finance_research-unauthorized") @@ -208,6 +241,151 @@ def test_finance_research_unauthorized(self, server_url): ) +# --------------------------------------------------------------------------- +# 2.5.0 background mode: queue task, poll status, stream events. +# --------------------------------------------------------------------------- + +class TestResearchBackground: + """Direct coverage for the auto-generated background/stream SDK methods.""" + + def test_research_background_returns_task_response(self, server_url, api_key): + client = create_test_http_client("post_/v1/research-background") + + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + res = you.research( + input="What is the capital of France?", + research_effort=ResearchEffort.STANDARD, + background=True, + server_url=server_url, + ) + + assert isinstance(res, TaskResponse) + assert res.task_id == "00000000-0000-0000-0000-000000000001" + assert res.status.value == "queued" + assert res.stream_url is not None + assert res.created_at is not None + + def test_get_research_task_returns_task_detail(self, server_url, api_key): + client = create_test_http_client("get_/v1/research/{task_id}") + + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + detail = you.get_research_task( + task_id="00000000-0000-0000-0000-000000000001", + server_url=server_url, + ) + + assert detail.id == "00000000-0000-0000-0000-000000000001" + assert detail.task_type == "research" + assert detail.status.value == "completed" + assert detail.created_at is not None + assert detail.completed_at is not None + # result is populated server-side; the Result model uses + # extra="allow" so model_dump() recovers the full payload. + assert detail.result is not None + assert detail.result.model_dump().get("output") is not None + # input is populated server-side; the TaskDetailInput model uses + # extra="allow" so model_dump() recovers the original request fields. + assert detail.input is not None + assert detail.input.model_dump().get("input") == "Compare NVIDIA, AMD, and Intel revenue over 5 years" + assert detail.input.model_dump().get("research_effort") == "deep" + + def test_research_background_false_returns_research_response(self, server_url, api_key): + """When background=False (explicit), return type is ResearchResponse, not TaskResponse.""" + client = create_test_http_client("post_/v1/research") + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + res = you.research( + input="What is the capital of France?", + research_effort=ResearchEffort.STANDARD, + background=False, + server_url=server_url, + ) + assert isinstance(res, ResearchResponse) + assert not isinstance(res, TaskResponse) + + @pytest.mark.asyncio + async def test_get_research_task_async_returns_task_detail(self, server_url, api_key): + async_client = httpx.AsyncClient( + headers={ + "x-speakeasy-test-name": "get_/v1/research/{task_id}", + "x-speakeasy-test-instance-id": str(uuid.uuid4()), + }, + follow_redirects=True, + ) + async with You(server_url=server_url, async_client=async_client, api_key_auth=api_key) as you: + detail = await you.get_research_task_async( + task_id="00000000-0000-0000-0000-000000000001", + server_url=server_url, + ) + assert detail.id == "00000000-0000-0000-0000-000000000001" + assert detail.status.value == "completed" + + +class TestResearchBackgroundErrors: + """Error path coverage for get_research_task().""" + + def test_get_research_task_not_found(self, server_url, api_key): + client = create_test_http_client("get_/v1/research/{task_id}-not-found") + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + with pytest.raises(errors.GetResearchTaskNotFoundError): + you.get_research_task(task_id="nonexistent", server_url=server_url) + + def test_get_research_task_unauthorized(self, server_url): + client = create_test_http_client("get_/v1/research/{task_id}-unauthorized") + with You(server_url=server_url, client=client, api_key_auth="bad-key") as you: + with pytest.raises(errors.GetResearchTaskUnauthorizedError): + you.get_research_task(task_id="test", server_url=server_url) + + def test_get_research_task_forbidden(self, server_url, api_key): + client = create_test_http_client("get_/v1/research/{task_id}-forbidden") + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + with pytest.raises(errors.GetResearchTaskForbiddenError): + you.get_research_task(task_id="test", server_url=server_url) + + def test_get_research_task_internal_error(self, server_url, api_key): + client = create_test_http_client("get_/v1/research/{task_id}-internal-error") + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + with pytest.raises(errors.GetResearchTaskInternalServerError): + you.get_research_task(task_id="test", server_url=server_url) + + +class TestStreamResearchTask: + """Direct SDK-level coverage for stream_research_task().""" + + def test_stream_research_task_returns_event_stream(self, server_url, api_key): + client = create_test_http_client("get_/v1/research/{task_id}/stream") + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + stream = you.stream_research_task( + task_id="00000000-0000-0000-0000-000000000001", + server_url=server_url, + ) + assert isinstance(stream, eventstreaming.EventStream) + events = [] + with stream as s: + for chunk in s: + events.append(chunk.data) + # The mockserver emits connected + response.done events + assert len(events) >= 2 + + def test_stream_research_task_not_found(self, server_url, api_key): + client = create_test_http_client("get_/v1/research/{task_id}/stream-not-found") + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + with pytest.raises(errors.StreamResearchTaskNotFoundError): + you.stream_research_task(task_id="nonexistent", server_url=server_url) + + def test_stream_research_task_unauthorized(self, server_url): + client = create_test_http_client("get_/v1/research/{task_id}/stream-unauthorized") + with You(server_url=server_url, client=client, api_key_auth="bad-key") as you: + with pytest.raises(errors.StreamResearchTaskUnauthorizedError): + you.stream_research_task(task_id="test", server_url=server_url) + + def test_stream_research_task_forbidden(self, server_url, api_key): + # Requires the forbidden case added in Phase 3.10.1 + client = create_test_http_client("get_/v1/research/{task_id}/stream-forbidden") + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + with pytest.raises(errors.StreamResearchTaskForbiddenError): + you.stream_research_task(task_id="test", server_url=server_url) + + # --------------------------------------------------------------------------- # 2.4.0 beta params: output_schema (structured output_content) and # source_control (domain constraints / freshness / country). @@ -411,6 +589,28 @@ def handler(request): ) sdk_client.close() + def test_frontier_without_background_raises_422(self): + """frontier requires background=true; sending it without returns 422.""" + def handler(request): + body = json.loads(request.content) + assert body.get("research_effort") == "frontier" + assert not body.get("background", False) + return httpx.Response( + 422, + headers={"content-type": "application/json"}, + content=json.dumps({"error": {"message": "frontier requires background=true"}}), + ) + + transport = httpx.MockTransport(handler) + sdk_client = httpx.Client(transport=transport) + you = You(server_url="http://mock.local", client=sdk_client, api_key_auth="test") + with pytest.raises(ResearchUnprocessableEntityError): + you.research( + input="Evaluate the Gates Foundation's global-health impact", + research_effort=ResearchEffort.FRONTIER, + ) + sdk_client.close() + class TestFinanceResearchAsync: @pytest.mark.asyncio diff --git a/tests/test_research_helpers.py b/tests/test_research_helpers.py new file mode 100644 index 0000000..9a9a7e1 --- /dev/null +++ b/tests/test_research_helpers.py @@ -0,0 +1,1550 @@ +"""Tests for research background-mode helpers in youdotcom.research_helpers.""" + +import asyncio +import json +import os +import time +import uuid + +import httpx +import pytest + +from tests.test_client import create_test_http_client +from youdotcom import You +from youdotcom.models import ( + ResearchEffort, + TaskDetail, + TaskResponse, +) +from youdotcom.research_helpers import ( + RawStreamEvent, + research_and_wait, + research_and_wait_async, + research_background, + research_background_async, + poll_research_task, + poll_research_task_async, + stream_research, + stream_research_async, + _decode_raw_event, + _resolve_default_timeout, + _FRONTIER_TIMEOUT_S, + _DEFAULT_POLL_TIMEOUT_S, +) + + +# --------------------------------------------------------------------------- +# Shared test helpers: handler factories + mock stream classes. +# --------------------------------------------------------------------------- + +_TASK_RESPONSE_JSON = json.dumps({ + "task_id": "00000000-0000-0000-0000-000000000001", + "type": "research", + "status": "queued", + "stream_url": "/v1/research/00000000-0000-0000-0000-000000000001/stream", + "created_at": "2026-07-09T00:00:00Z", +}) + +_DEFAULT_RESULT = {"output": {"content": "done", "content_type": "text", "sources": []}} + +_CONNECTED_CHUNK = b'id: 0\nevent: connected\ndata: {"type":"connected","task_id":"abc","status":"running"}\n\n' + + +def _make_task_detail_json(status: str = "completed", result: dict | None = None) -> str: + """Build a TaskDetail JSON body for GET /v1/research/{task_id} responses.""" + detail: dict = { + "id": "00000000-0000-0000-0000-000000000001", + "task_type": "research", + "status": status, + "created_at": "2026-07-09T00:00:00Z", + "updated_at": "2026-07-09T00:02:30Z", + } + if status == "completed": + detail["completed_at"] = "2026-07-09T00:02:30Z" + if result is not None: + detail["result"] = result + return json.dumps(detail) + + +class _AsyncChunks(httpx.AsyncByteStream): + """Wrap a list of bytes chunks in an AsyncByteStream for MockTransport + + AsyncClient streaming responses.""" + + def __init__(self, chunks: list[bytes]): + self._chunks = chunks + + async def __aiter__(self): + for chunk in self._chunks: + yield chunk + + +class _BlockingStream(httpx.SyncByteStream): + """Yields one event then raises ReadTimeout to simulate a stalled server + that stops sending data within the read timeout.""" + + def __iter__(self): + yield _CONNECTED_CHUNK + raise httpx.ReadTimeout("read timeout") + + +class _BlockingAsyncStream(httpx.AsyncByteStream): + """Yields one event then blocks so asyncio.wait_for times out.""" + + async def __aiter__(self): + yield _CONNECTED_CHUNK + await asyncio.sleep(100) + + +def _make_wait_handler( + *, + stream_chunks: list[bytes] | None = None, + stream_obj: httpx.SyncByteStream | httpx.AsyncByteStream | None = None, + final_status: str = "completed", + final_result: dict | None = _DEFAULT_RESULT, + is_async: bool = False, +): + """Create a MockTransport handler for research_and_wait tests. + + Returns SSE stream, POST TaskResponse, and GET TaskDetail responses. + Pass ``stream_obj`` for custom stream behavior (e.g. ``_BlockingStream``). + Pass ``stream_chunks`` for simple list-of-bytes streams. + """ + final_json = _make_task_detail_json(status=final_status, result=final_result) + + def handler(request): + url = str(request.url) + if url.endswith("/stream") or "/stream?" in url: + if stream_obj is not None: + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, stream=stream_obj, + ) + if stream_chunks is not None: + if is_async: + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, + stream=_AsyncChunks(stream_chunks), + ) + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, content=stream_chunks, + ) + return httpx.Response(200, content="{}") + if request.method == "POST" and "/v1/research" in url and "/stream" not in url: + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_TASK_RESPONSE_JSON, + ) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=final_json, + ) + + return handler + + +@pytest.fixture +def server_url(): + return os.getenv("TEST_SERVER_URL", "http://localhost:18080") + + +@pytest.fixture +def api_key(): + return "test-api-key" + + +# --------------------------------------------------------------------------- +# research_background[Async]: assert TaskResponse return type without +# forcing callers to narrow Union[ResearchResponse, TaskResponse]. +# --------------------------------------------------------------------------- + +class TestResearchBackground: + def test_research_background_returns_task_response(self, server_url, api_key): + client = create_test_http_client("post_/v1/research-background") + + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + res = research_background( + you, + input="Compare NVIDIA, AMD, and Intel revenue over 5 years", + research_effort=ResearchEffort.DEEP, + server_url=server_url, + ) + + assert isinstance(res, TaskResponse) + assert res.task_id == "00000000-0000-0000-0000-000000000001" + assert res.type == "research" + assert res.status.value == "queued" + + @pytest.mark.asyncio + async def test_research_background_async_returns_task_response(self, server_url, api_key): + async_client = httpx.AsyncClient( + headers={ + "x-speakeasy-test-name": "post_/v1/research-background", + "x-speakeasy-test-instance-id": str(uuid.uuid4()), + }, + follow_redirects=True, + ) + + async with You( + server_url=server_url, async_client=async_client, api_key_auth=api_key + ) as you: + res = await research_background_async( + you, + input="Compare NVIDIA, AMD, and Intel revenue over 5 years", + research_effort=ResearchEffort.DEEP, + server_url=server_url, + ) + + assert isinstance(res, TaskResponse) + assert res.task_id == "00000000-0000-0000-0000-000000000001" + + +class TestResearchBackgroundTypeError: + """Tests that research_background raises TypeError when the server + returns a ResearchResponse instead of TaskResponse (e.g. when the + server-side background-mode flag is disabled).""" + + def test_research_background_raises_type_error_on_sync_response(self): + """When the server ignores background=true and returns a + ResearchResponse, research_background must raise TypeError.""" + import json + + def handler(request): + # Server ignores background=true, returns a sync ResearchResponse + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps({ + "output": { + "content_type": "text", + "content": "The capital of France is Paris.", + "sources": [], + }, + }), + ) + + transport = httpx.MockTransport(handler) + sdk_client = httpx.Client(transport=transport) + you = You( + server_url="http://mock.local", + client=sdk_client, + api_key_auth="test-api-key", + ) + + with pytest.raises(TypeError, match="TaskResponse"): + research_background( + you, + input="What is the capital of France?", + research_effort=ResearchEffort.STANDARD, + ) + + @pytest.mark.asyncio + async def test_research_background_async_raises_type_error_on_sync_response(self): + """Async mirror: TypeError when server returns ResearchResponse.""" + import json + + def handler(request): + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps({ + "output": { + "content_type": "text", + "content": "The capital of France is Paris.", + "sources": [], + }, + }), + ) + + transport = httpx.MockTransport(handler) + sdk_async_client = httpx.AsyncClient(transport=transport) + you = You( + server_url="http://mock.local", + async_client=sdk_async_client, + api_key_auth="test-api-key", + ) + + with pytest.raises(TypeError, match="TaskResponse"): + await research_background_async( + you, + input="What is the capital of France?", + research_effort=ResearchEffort.STANDARD, + ) + + +# --------------------------------------------------------------------------- +# poll_research_task[Async]: poll GET /v1/research/{task_id} until terminal. +# --------------------------------------------------------------------------- + +class TestPollResearchTask: + def test_poll_returns_completed_task_detail(self, server_url, api_key): + client = create_test_http_client("get_/v1/research/{task_id}") + + with You(server_url=server_url, client=client, api_key_auth=api_key) as you: + detail = poll_research_task( + you, + "00000000-0000-0000-0000-000000000001", + interval_s=0.01, + timeout_s=2.0, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + assert detail.result is not None + + @pytest.mark.asyncio + async def test_poll_async_returns_completed_task_detail(self, server_url, api_key): + async_client = httpx.AsyncClient( + headers={ + "x-speakeasy-test-name": "get_/v1/research/{task_id}", + "x-speakeasy-test-instance-id": str(uuid.uuid4()), + }, + follow_redirects=True, + ) + + async with You( + server_url=server_url, async_client=async_client, api_key_auth=api_key + ) as you: + detail = await poll_research_task_async( + you, + "00000000-0000-0000-0000-000000000001", + interval_s=0.01, + timeout_s=2.0, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + +# --------------------------------------------------------------------------- +# research_and_wait: submit background + poll + return TaskDetail. +# The Result model uses extra="allow" so detail.result.model_dump() +# recovers the full payload; the helper returns TaskDetail. +# --------------------------------------------------------------------------- + +class TestResearchAndWait: + def test_research_and_wait_returns_completed_detail(self): + """research_and_wait submits, streams until terminal event, + then fetches the final TaskDetail.""" + handler = _make_wait_handler(stream_chunks=[ + _CONNECTED_CHUNK, + b'id: 1\nevent: response.done\ndata: {"type":"response.done","task_id":"abc","status":"completed","sequence":1}\n\n', + ]) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + detail = research_and_wait( + you, + timeout_s=5.0, + input="Compare NVIDIA, AMD, and Intel revenue over 5 years", + research_effort=ResearchEffort.DEEP, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + def test_research_and_wait_error_event_raises_runtime_error(self): + """research_and_wait raises RuntimeError when the stream emits an + error terminal event.""" + handler = _make_wait_handler( + stream_chunks=[ + _CONNECTED_CHUNK, + b'id: 1\nevent: error\ndata: {"type":"error","task_id":"abc","message":"internal error"}\n\n', + ], + final_status="running", + final_result=None, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(RuntimeError, match="non-completed state"): + research_and_wait( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + def test_research_and_wait_ok_event_but_get_non_completed_raises(self): + """When the stream emits a terminal OK event (response.done) but the + follow-up GET returns a non-completed status, research_and_wait raises + RuntimeError. This covers the defensive branch in _resolve_from_final_get.""" + handler = _make_wait_handler( + stream_chunks=[ + _CONNECTED_CHUNK, + b'id: 1\nevent: response.done\ndata: {"type":"response.done","task_id":"abc","status":"completed","sequence":1}\n\n', + ], + final_status="running", + final_result=None, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(RuntimeError, match="stream signalled completion but GET returned status=running after 3 attempts"): + research_and_wait( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + def test_research_and_wait_ok_event_but_get_failed_raises_immediately(self): + """When the stream emits a terminal OK event but the follow-up GET + returns a terminal non-completed status (failed), research_and_wait + raises RuntimeError immediately without exhausting re-poll attempts.""" + handler = _make_wait_handler( + stream_chunks=[ + _CONNECTED_CHUNK, + b'id: 1\nevent: response.done\ndata: {"type":"response.done","task_id":"abc","status":"completed","sequence":1}\n\n', + ], + final_status="failed", + final_result=None, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(RuntimeError, match="ended in non-completed state: failed"): + research_and_wait( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + def test_research_and_wait_ok_event_repoll_succeeds(self): + """When the stream emits a terminal OK event and the first GET returns + running (backend commit race), research_and_wait re-polls and returns + the completed detail once the status catches up.""" + call_count = {"get": 0} + + def handler(request): + url = str(request.url) + if url.endswith("/stream") or "/stream?" in url: + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, + content=[ + _CONNECTED_CHUNK, + b'id: 1\nevent: response.done\ndata: {"type":"response.done","task_id":"abc","status":"completed","sequence":1}\n\n', + ], + ) + if request.method == "POST" and "/v1/research" in url and "/stream" not in url: + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_TASK_RESPONSE_JSON, + ) + # GET: first call returns running, second returns completed + call_count["get"] += 1 + if call_count["get"] == 1: + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_make_task_detail_json(status="running", result=None), + ) + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_make_task_detail_json(status="completed", result=_DEFAULT_RESULT), + ) + + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + detail = research_and_wait( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + assert call_count["get"] == 2 # first running, second completed + + def test_research_and_wait_timeout_raises_timeout_error(self): + """research_and_wait raises TimeoutError when the stream never sends + a terminal event within timeout_s. The _BlockingStream simulates a + stalled server by raising httpx.ReadTimeout after the first event.""" + handler = _make_wait_handler( + stream_obj=_BlockingStream(), + final_status="running", + final_result=None, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(TimeoutError, match="did not complete within"): + research_and_wait( + you, + timeout_s=0.5, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + def test_research_and_wait_timeout_falls_back_to_get(self): + """When the stream times out (ReadTimeout) but the task has completed, + the final GET fallback returns the completed detail.""" + handler = _make_wait_handler( + stream_obj=_BlockingStream(), + final_status="completed", + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + detail = research_and_wait( + you, + timeout_s=0.5, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + def test_research_and_wait_stream_close_falls_back_to_get(self): + """When the stream closes without a terminal event, research_and_wait + does a final GET and returns the detail if completed.""" + handler = _make_wait_handler( + stream_chunks=[_CONNECTED_CHUNK], + final_status="completed", + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + detail = research_and_wait( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + def test_research_and_wait_stream_close_task_running_raises_timeout(self): + """When the stream closes without a terminal event and the final GET + shows the task is still running, research_and_wait raises TimeoutError.""" + handler = _make_wait_handler( + stream_chunks=[_CONNECTED_CHUNK], + final_status="running", + final_result=None, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(TimeoutError, match="still running"): + research_and_wait( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + def test_research_and_wait_total_deadline_not_stall_timeout(self): + """research_and_wait enforces a total wall-clock deadline, not just a + per-read stall timeout. If the server keeps sending non-terminal + events forever, the total deadline fires and raises TimeoutError. + This matches the async variant's asyncio.wait_for semantics.""" + class _NonTerminalStream(httpx.SyncByteStream): + """Yields non-terminal events fast enough to not trip the per-read + timeout, but never emits a terminal event.""" + def __iter__(self): + for _ in range(10000): + yield b'id: 0\nevent: ping\ndata: {"type":"ping"}\n\n' + time.sleep(0.01) + + handler = _make_wait_handler( + stream_obj=_NonTerminalStream(), + final_status="running", + final_result=None, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(TimeoutError, match="did not complete within"): + research_and_wait( + you, + timeout_s=0.5, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + def test_stream_open_transport_error_falls_back_to_poll(self): + """When _open_raw_stream raises a TransportError (can't reach server), + research_and_wait falls back to poll_research_task and returns the + completed detail.""" + def handler(request): + url = str(request.url) + if url.endswith("/stream") or "/stream?" in url: + raise httpx.ConnectError("connection refused") + if request.method == "POST" and "/v1/research" in url and "/stream" not in url: + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_TASK_RESPONSE_JSON, + ) + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_make_task_detail_json(status="completed", result=_DEFAULT_RESULT), + ) + + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + detail = research_and_wait( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + def test_stream_open_401_propagates_typed_error(self): + """When _open_raw_stream gets a 401, the typed + StreamResearchTaskUnauthorizedError propagates instead of falling + back to polling.""" + from youdotcom.errors import StreamResearchTaskUnauthorizedError + + def handler(request): + url = str(request.url) + if url.endswith("/stream") or "/stream?" in url: + return httpx.Response( + 401, + headers={"content-type": "application/json"}, + content='{"error": "unauthorized"}', + ) + if request.method == "POST" and "/v1/research" in url and "/stream" not in url: + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_TASK_RESPONSE_JSON, + ) + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_make_task_detail_json(status="completed", result=_DEFAULT_RESULT), + ) + + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="bad-key", + ) + + with pytest.raises(StreamResearchTaskUnauthorizedError): + research_and_wait( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + def test_mid_stream_transport_error_falls_back_to_poll(self): + """When a TransportError occurs mid-stream (dropped connection), + research_and_wait falls back to poll_research_task.""" + class _DroppedStream(httpx.SyncByteStream): + def __iter__(self): + yield _CONNECTED_CHUNK + raise httpx.RemoteProtocolError("connection dropped") + + def handler(request): + url = str(request.url) + if url.endswith("/stream") or "/stream?" in url: + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, + stream=_DroppedStream(), + ) + if request.method == "POST" and "/v1/research" in url and "/stream" not in url: + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_TASK_RESPONSE_JSON, + ) + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_make_task_detail_json(status="completed", result=_DEFAULT_RESULT), + ) + + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + detail = research_and_wait( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + +# --------------------------------------------------------------------------- +# Stream research tolerant decoder: verify that +# (a) helper accepts documented event types as before; +# (b) helper accepts unknown event types without raising +# pydantic.ValidationError, surfacing them as RawStreamEvent(event="..."). +# Uses an httpx.MockTransport to inject a fake SSE server response -- this +# path doesn't depend on the Go mockserver so unknown event names can be +# emitted safely without adding new mockserver fixtures. +# --------------------------------------------------------------------------- + +class TestStreamResearchEventsTolerant: + def test_tolerant_stream_yields_all_events_with_unknown_name(self): + # Inject a fake SSE stream with one workflow-internal event type + # (research.searching) that's NOT in the documented enum. The strict + # speakeasy decoder would raise ValidationError on it; our tolerant + # helper must surface it as RawStreamEvent(event="research.searching"). + recorded_ua: dict = {} + + def record_send(request): + recorded_ua["value"] = request.headers.get("User-Agent") + chunks = [ + b"id: 0\nevent: connected\ndata: " + b'{"type":"connected","task_id":"abc","status":"running"}\n\n', + b"id: 1\nevent: research.searching\ndata: " + b'{"query":"markets","phase":"searching"}\n\n', + b"id: 2\nevent: response.done\ndata: " + b'{"type":"response.done","task_id":"abc","status":"completed","sequence":2}\n\n', + ] + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=chunks, + ) + + transport = httpx.MockTransport(record_send) + sdk_client = httpx.Client( + transport=transport, + headers={ + "x-speakeasy-test-name": "get_/v1/research/{task_id}/stream", + "x-speakeasy-test-instance-id": str(uuid.uuid4()), + }, + ) + you = You( + server_url="http://mock.local", + client=sdk_client, + api_key_auth="test-api-key", + ) + + events = list( + stream_research( + you, "00000000-0000-0000-0000-000000000001", + ) + ) + + assert [e.event for e in events] == [ + "connected", "research.searching", "response.done", + ] + # Validate that the unknown event came through without raising. + assert isinstance(events[1], RawStreamEvent) + assert events[1].data == {"query": "markets", "phase": "searching"} + # And confirm the SDK still set User-Agent on the underlying request + # (the YDCUserAgentOverrideHook ran before send). + assert recorded_ua["value"] == f"youdotcom-python-sdk/{you.sdk_configuration.sdk_version}" +# --------------------------------------------------------------------------- +# _resolve_default_timeout: auto-adjust timeout based on research_effort. +# --------------------------------------------------------------------------- + +class TestResolveDefaultTimeout: + def test_frontier_returns_4_hour_timeout(self): + assert _resolve_default_timeout({"research_effort": ResearchEffort.FRONTIER}) == _FRONTIER_TIMEOUT_S + + def test_frontier_string_returns_4_hour_timeout(self): + assert _resolve_default_timeout({"research_effort": "frontier"}) == _FRONTIER_TIMEOUT_S + + def test_standard_returns_default_timeout(self): + assert _resolve_default_timeout({"research_effort": ResearchEffort.STANDARD}) == _DEFAULT_POLL_TIMEOUT_S + + def test_deep_returns_default_timeout(self): + assert _resolve_default_timeout({"research_effort": ResearchEffort.DEEP}) == _DEFAULT_POLL_TIMEOUT_S + + def test_exhaustive_returns_default_timeout(self): + assert _resolve_default_timeout({"research_effort": ResearchEffort.EXHAUSTIVE}) == _DEFAULT_POLL_TIMEOUT_S + + def test_no_effort_returns_default_timeout(self): + assert _resolve_default_timeout({}) == _DEFAULT_POLL_TIMEOUT_S + + +# --------------------------------------------------------------------------- +# research_and_wait frontier auto-timeout: when timeout_s is omitted and +# research_effort=frontier, the helper should use 14400s (4 hours), not the +# 600s default. We verify by checking that a short stream timeout is NOT +# applied — instead the 4-hour deadline is used, so the stream consumes +# all events without a premature TimeoutError. +# --------------------------------------------------------------------------- + +class TestResearchAndWaitFrontierAutoTimeout: + def test_frontier_auto_timeout_completes_without_premature_timeout(self): + """When research_effort=frontier and timeout_s is omitted, the + auto-adjusted 4-hour timeout should not trip on a normal event + sequence that would complete within seconds.""" + handler = _make_wait_handler(stream_chunks=[ + _CONNECTED_CHUNK, + b'id: 1\nevent: response.done\ndata: {"type":"response.done","task_id":"abc","status":"completed","sequence":1}\n\n', + ]) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + detail = research_and_wait( + you, + input="Evaluate the Gates Foundation's global-health impact", + research_effort=ResearchEffort.FRONTIER, + # timeout_s intentionally omitted — should auto-adjust to 14400 + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + def test_explicit_timeout_overrides_auto_adjust(self): + """When the user passes an explicit timeout_s, it takes precedence + over the frontier auto-adjustment.""" + handler = _make_wait_handler( + stream_obj=_BlockingStream(), + final_status="running", + final_result=None, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + client=httpx.Client(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(TimeoutError, match="did not complete within 0.5"): + research_and_wait( + you, + timeout_s=0.5, + input="test query", + research_effort=ResearchEffort.FRONTIER, + ) + + +# --------------------------------------------------------------------------- +# _decode_raw_event sanity: known and unknown shapes. +# --------------------------------------------------------------------------- + +class TestDecodeRawEvent: + def test_known_event(self): + import json + ev = _decode_raw_event(json.dumps({"id": "1", "event": "response.done", "data": {"status": "completed"}})) + assert isinstance(ev, RawStreamEvent) + assert ev.id == "1" + assert ev.event == "response.done" + assert ev.data == {"status": "completed"} + + def test_unknown_event(self): + import json + ev = _decode_raw_event(json.dumps({"id": "2", "event": "some.workflow.step", "data": {"k": "v"}})) + assert ev.event == "some.workflow.step" + assert ev.data == {"k": "v"} + + +# --------------------------------------------------------------------------- +# stream_research typed error mapping: non-200 responses must raise the +# same structured errors as the generated stream_research_task() method. +# --------------------------------------------------------------------------- + +class TestStreamResearchTypedErrors: + """Verify stream_research[_async] raise the same typed errors as the + generated stream_research_task method for each status-code branch.""" + + @staticmethod + def _make_error_handler(status_code: int, body: dict | None = None): + """Create a MockTransport handler that always returns an error response.""" + content = json.dumps(body or {"detail": "error"}) + def handler(request): + return httpx.Response( + status_code, + headers={"content-type": "application/json"}, + content=content, + ) + return handler + + @staticmethod + def _sync_you(handler): + return You( + server_url="http://mock.local", + client=httpx.Client(transport=httpx.MockTransport(handler)), + api_key_auth="test-api-key", + ) + + @staticmethod + def _async_you(handler): + return You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + api_key_auth="test-api-key", + ) + + _TASK = "00000000-0000-0000-0000-000000000001" + + def test_401_raises_unauthorized_error(self): + from youdotcom.errors import StreamResearchTaskUnauthorizedError + with pytest.raises(StreamResearchTaskUnauthorizedError): + list(stream_research(self._sync_you(self._make_error_handler(401)), self._TASK)) + + def test_403_raises_forbidden_error(self): + from youdotcom.errors import StreamResearchTaskForbiddenError + with pytest.raises(StreamResearchTaskForbiddenError): + list(stream_research(self._sync_you(self._make_error_handler(403)), self._TASK)) + + def test_404_raises_not_found_error(self): + from youdotcom.errors import StreamResearchTaskNotFoundError + with pytest.raises(StreamResearchTaskNotFoundError): + list(stream_research(self._sync_you(self._make_error_handler(404)), self._TASK)) + + def test_500_raises_internal_server_error(self): + from youdotcom.errors import StreamResearchTaskInternalServerError + with pytest.raises(StreamResearchTaskInternalServerError): + list(stream_research(self._sync_you(self._make_error_handler(500)), self._TASK)) + + def test_4xx_fallback_raises_default_error(self): + from youdotcom.errors import YouDefaultError + with pytest.raises(YouDefaultError): + list(stream_research(self._sync_you(self._make_error_handler(400)), self._TASK)) + + def test_5xx_fallback_raises_default_error(self): + from youdotcom.errors import YouDefaultError + with pytest.raises(YouDefaultError): + list(stream_research(self._sync_you(self._make_error_handler(502)), self._TASK)) + + @pytest.mark.asyncio + async def test_async_401_raises_unauthorized_error(self): + from youdotcom.errors import StreamResearchTaskUnauthorizedError + with pytest.raises(StreamResearchTaskUnauthorizedError): + async for _ in stream_research_async(self._async_you(self._make_error_handler(401)), self._TASK): + pass + + @pytest.mark.asyncio + async def test_async_403_raises_forbidden_error(self): + from youdotcom.errors import StreamResearchTaskForbiddenError + with pytest.raises(StreamResearchTaskForbiddenError): + async for _ in stream_research_async(self._async_you(self._make_error_handler(403)), self._TASK): + pass + + @pytest.mark.asyncio + async def test_async_404_raises_not_found_error(self): + from youdotcom.errors import StreamResearchTaskNotFoundError + with pytest.raises(StreamResearchTaskNotFoundError): + async for _ in stream_research_async(self._async_you(self._make_error_handler(404)), self._TASK): + pass + + @pytest.mark.asyncio + async def test_async_500_raises_internal_server_error(self): + from youdotcom.errors import StreamResearchTaskInternalServerError + with pytest.raises(StreamResearchTaskInternalServerError): + async for _ in stream_research_async(self._async_you(self._make_error_handler(500)), self._TASK): + pass + + +# --------------------------------------------------------------------------- +# Error path tests: poll timeout, poll failed status, and stream mode. +# --------------------------------------------------------------------------- + +class TestPollResearchTaskErrorPaths: + def test_poll_timeout_raises_timeout_error(self): + """poll_research_task must raise TimeoutError when the task never + reaches a terminal state within timeout_s.""" + import json + + def handler(request): + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps({ + "id": "00000000-0000-0000-0000-000000000001", + "task_type": "research", + "status": "running", + "created_at": "2026-07-09T00:00:00Z", + "updated_at": "2026-07-09T00:00:01Z", + }), + ) + + transport = httpx.MockTransport(handler) + sdk_client = httpx.Client(transport=transport) + you = You( + server_url="http://mock.local", + client=sdk_client, + api_key_auth="test-api-key", + ) + + with pytest.raises(TimeoutError, match="did not complete"): + poll_research_task( + you, + "00000000-0000-0000-0000-000000000001", + interval_s=0.01, + timeout_s=0.05, + ) + + def test_poll_failed_status_raises_runtime_error(self): + """poll_research_task must raise RuntimeError when the task ends + in a non-completed terminal state (failed).""" + import json + + def handler(request): + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps({ + "id": "00000000-0000-0000-0000-000000000002", + "task_type": "research", + "status": "failed", + "created_at": "2026-07-09T00:00:00Z", + "updated_at": "2026-07-09T00:00:05Z", + "error": "upstream search timeout", + }), + ) + + transport = httpx.MockTransport(handler) + sdk_client = httpx.Client(transport=transport) + you = You( + server_url="http://mock.local", + client=sdk_client, + api_key_auth="test-api-key", + ) + + with pytest.raises(RuntimeError, match="non-completed state: failed"): + poll_research_task( + you, + "00000000-0000-0000-0000-000000000002", + interval_s=0.01, + timeout_s=2.0, + ) + +class TestPollResearchTaskAsyncErrorPaths: + @pytest.mark.asyncio + async def test_poll_async_timeout_raises_timeout_error(self): + """Async mirror of the sync timeout test.""" + import json + + def handler(request): + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps({ + "id": "00000000-0000-0000-0000-000000000003", + "task_type": "research", + "status": "running", + "created_at": "2026-07-09T00:00:00Z", + "updated_at": "2026-07-09T00:00:01Z", + }), + ) + + transport = httpx.MockTransport(handler) + sdk_async_client = httpx.AsyncClient(transport=transport) + you = You( + server_url="http://mock.local", + async_client=sdk_async_client, + api_key_auth="test-api-key", + ) + + with pytest.raises(TimeoutError, match="did not complete"): + await poll_research_task_async( + you, + "00000000-0000-0000-0000-000000000003", + interval_s=0.01, + timeout_s=0.05, + ) + + @pytest.mark.asyncio + async def test_poll_async_failed_status_raises_runtime_error(self): + """Async mirror of the sync failed-status test.""" + import json + + def handler(request): + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps({ + "id": "00000000-0000-0000-0000-000000000004", + "task_type": "research", + "status": "failed", + "created_at": "2026-07-09T00:00:00Z", + "updated_at": "2026-07-09T00:00:05Z", + "error": "upstream search timeout", + }), + ) + + transport = httpx.MockTransport(handler) + sdk_async_client = httpx.AsyncClient(transport=transport) + you = You( + server_url="http://mock.local", + async_client=sdk_async_client, + api_key_auth="test-api-key", + ) + + with pytest.raises(RuntimeError, match="non-completed state: failed"): + await poll_research_task_async( + you, + "00000000-0000-0000-0000-000000000004", + interval_s=0.01, + timeout_s=2.0, + ) + + +# --------------------------------------------------------------------------- +# Async streaming: mirror the sync TestStreamResearchEventsTolerant and +# TestResearchAndWaitStreamMode. These also exercise the try/finally cleanup +# path (replacing the broken contextlib.aclosing that called aclose()). + + +class TestStreamResearchEventsTolerantAsync: + @pytest.mark.asyncio + async def test_async_tolerant_stream_yields_all_events_with_unknown_name(self): + """Async mirror of TestStreamResearchEventsTolerant — injects a + fake SSE stream with an unknown event type and verifies it surfaces + as RawStreamEvent without raising.""" + recorded_ua: dict = {} + + def record_send(request): + recorded_ua["value"] = request.headers.get("User-Agent") + chunks = [ + b"id: 0\nevent: connected\ndata: " + b'{"type":"connected","task_id":"abc","status":"running"}\n\n', + b"id: 1\nevent: research.searching\ndata: " + b'{"query":"markets","phase":"searching"}\n\n', + b"id: 2\nevent: response.done\ndata: " + b'{"type":"response.done","task_id":"abc","status":"completed","sequence":2}\n\n', + ] + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + stream=_AsyncChunks(chunks), + ) + + transport = httpx.MockTransport(record_send) + sdk_async_client = httpx.AsyncClient(transport=transport) + you = You( + server_url="http://mock.local", + async_client=sdk_async_client, + api_key_auth="test-api-key", + ) + + events = [ + evt + async for evt in stream_research_async( + you, "00000000-0000-0000-0000-000000000001", + ) + ] + + assert [e.event for e in events] == [ + "connected", "research.searching", "response.done", + ] + assert isinstance(events[1], RawStreamEvent) + assert events[1].data == {"query": "markets", "phase": "searching"} + assert recorded_ua["value"] == f"youdotcom-python-sdk/{you.sdk_configuration.sdk_version}" + + +class TestResearchAndWaitAsync: + @pytest.mark.asyncio + async def test_async_research_and_wait_returns_completed_detail(self): + """Async research_and_wait: submit, stream until terminal, fetch detail.""" + handler = _make_wait_handler( + stream_chunks=[ + _CONNECTED_CHUNK, + b'id: 1\nevent: response.done\ndata: {"type":"response.done","task_id":"abc","status":"completed","sequence":1}\n\n', + ], + is_async=True, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + detail = await research_and_wait_async( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + @pytest.mark.asyncio + async def test_async_frontier_auto_timeout_completes_without_premature_timeout(self): + """Async mirror: when research_effort=frontier and timeout_s is omitted, + the auto-adjusted 4-hour timeout should not trip on a normal event + sequence that completes within seconds.""" + handler = _make_wait_handler( + stream_chunks=[ + _CONNECTED_CHUNK, + b'id: 1\nevent: response.done\ndata: {"type":"response.done","task_id":"abc","status":"completed","sequence":1}\n\n', + ], + is_async=True, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + detail = await research_and_wait_async( + you, + input="Evaluate the Gates Foundation's global-health impact", + research_effort=ResearchEffort.FRONTIER, + # timeout_s intentionally omitted — should auto-adjust to 14400 + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + @pytest.mark.asyncio + async def test_async_research_and_wait_error_event_raises_runtime_error(self): + """Async research_and_wait raises RuntimeError on error terminal event.""" + handler = _make_wait_handler( + stream_chunks=[ + _CONNECTED_CHUNK, + b'id: 1\nevent: error\ndata: {"type":"error","task_id":"abc","message":"internal error"}\n\n', + ], + final_status="running", + final_result=None, + is_async=True, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(RuntimeError, match="non-completed state"): + await research_and_wait_async( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + @pytest.mark.asyncio + async def test_async_research_and_wait_ok_event_repoll_succeeds(self): + """Async mirror: stream OK + first GET running, second GET completed.""" + call_count = {"get": 0} + + def handler(request): + url = str(request.url) + if url.endswith("/stream") or "/stream?" in url: + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, + stream=_AsyncChunks([ + _CONNECTED_CHUNK, + b'id: 1\nevent: response.done\ndata: {"type":"response.done","task_id":"abc","status":"completed","sequence":1}\n\n', + ]), + ) + if request.method == "POST" and "/v1/research" in url and "/stream" not in url: + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_TASK_RESPONSE_JSON, + ) + call_count["get"] += 1 + if call_count["get"] == 1: + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_make_task_detail_json(status="running", result=None), + ) + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_make_task_detail_json(status="completed", result=_DEFAULT_RESULT), + ) + + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + detail = await research_and_wait_async( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + assert call_count["get"] == 2 + + @pytest.mark.asyncio + async def test_async_research_and_wait_ok_event_but_get_failed_raises_immediately(self): + """Async mirror: stream OK + GET returns terminal failed raises + immediately without exhausting re-poll attempts.""" + handler = _make_wait_handler( + stream_chunks=[ + _CONNECTED_CHUNK, + b'id: 1\nevent: response.done\ndata: {"type":"response.done","task_id":"abc","status":"completed","sequence":1}\n\n', + ], + final_status="failed", + final_result=None, + is_async=True, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(RuntimeError, match="ended in non-completed state: failed"): + await research_and_wait_async( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + @pytest.mark.asyncio + async def test_async_research_and_wait_timeout_raises_timeout_error(self): + """Async research_and_wait raises TimeoutError when the stream never + sends a terminal event and the task is still running. asyncio.wait_for + cancels the blocked _consume() coroutine.""" + handler = _make_wait_handler( + stream_obj=_BlockingAsyncStream(), + final_status="running", + final_result=None, + is_async=True, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(TimeoutError, match="did not complete within"): + await research_and_wait_async( + you, + timeout_s=0.5, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + @pytest.mark.asyncio + async def test_async_research_and_wait_httpx_readtimeout_falls_back_to_get(self): + """Async research_and_wait catches httpx.ReadTimeout (not just + asyncio.TimeoutError) and falls back to a final GET. Mirrors the + sync _BlockingStream test. Without catching httpx.TimeoutException, + the raw ReadTimeout would propagate uncaught.""" + class _ReadTimeoutAsyncStream(httpx.AsyncByteStream): + """Yields one event then raises httpx.ReadTimeout to simulate + a stalled server with an explicit read timeout.""" + async def __aiter__(self): + yield _CONNECTED_CHUNK + raise httpx.ReadTimeout("read timeout") + + handler = _make_wait_handler( + stream_obj=_ReadTimeoutAsyncStream(), + final_status="completed", + is_async=True, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + detail = await research_and_wait_async( + you, + timeout_s=0.5, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + @pytest.mark.asyncio + async def test_async_research_and_wait_timeout_falls_back_to_get(self): + """When the async stream times out but the task completed, the final + GET fallback returns the completed detail.""" + handler = _make_wait_handler( + stream_obj=_BlockingAsyncStream(), + final_status="completed", + is_async=True, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + detail = await research_and_wait_async( + you, + timeout_s=0.5, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + @pytest.mark.asyncio + async def test_async_research_and_wait_stream_close_falls_back_to_get(self): + """When the stream closes without a terminal event, async + research_and_wait does a final GET and returns the detail if completed.""" + handler = _make_wait_handler( + stream_chunks=[_CONNECTED_CHUNK], + final_status="completed", + is_async=True, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + detail = await research_and_wait_async( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + @pytest.mark.asyncio + async def test_async_research_and_wait_stream_close_task_running_raises_timeout(self): + """When the stream closes without a terminal event and the final GET + shows the task is still running, async research_and_wait raises TimeoutError.""" + handler = _make_wait_handler( + stream_chunks=[_CONNECTED_CHUNK], + final_status="running", + final_result=None, + is_async=True, + ) + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + with pytest.raises(TimeoutError, match="still running"): + await research_and_wait_async( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + @pytest.mark.asyncio + async def test_async_stream_open_transport_error_falls_back_to_poll(self): + """Async mirror: TransportError on stream-open falls back to polling.""" + def handler(request): + url = str(request.url) + if url.endswith("/stream") or "/stream?" in url: + raise httpx.ConnectError("connection refused") + if request.method == "POST" and "/v1/research" in url and "/stream" not in url: + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_TASK_RESPONSE_JSON, + ) + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_make_task_detail_json(status="completed", result=_DEFAULT_RESULT), + ) + + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="test-api-key", + ) + + detail = await research_and_wait_async( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) + + assert isinstance(detail, TaskDetail) + assert detail.status.value == "completed" + + @pytest.mark.asyncio + async def test_async_stream_open_401_propagates_typed_error(self): + """Async mirror: 401 on stream-open propagates typed error.""" + from youdotcom.errors import StreamResearchTaskUnauthorizedError + + def handler(request): + url = str(request.url) + if url.endswith("/stream") or "/stream?" in url: + return httpx.Response( + 401, + headers={"content-type": "application/json"}, + content='{"error": "unauthorized"}', + ) + if request.method == "POST" and "/v1/research" in url and "/stream" not in url: + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_TASK_RESPONSE_JSON, + ) + return httpx.Response( + 200, headers={"content-type": "application/json"}, + content=_make_task_detail_json(status="completed", result=_DEFAULT_RESULT), + ) + + transport = httpx.MockTransport(handler) + you = You( + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=transport), + api_key_auth="bad-key", + ) + + with pytest.raises(StreamResearchTaskUnauthorizedError): + await research_and_wait_async( + you, + timeout_s=5.0, + input="test query", + research_effort=ResearchEffort.STANDARD, + ) diff --git a/uv.lock b/uv.lock index a3a09df..4307a77 100644 --- a/uv.lock +++ b/uv.lock @@ -533,7 +533,7 @@ wheels = [ [[package]] name = "youdotcom" -version = "2.4.0" +version = "2.5.0" source = { editable = "." } dependencies = [ { name = "httpcore" },