diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 09effbe906..e671b346f4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,17 +3,12 @@ sdks/community/java @pascalwilbrink docs/sdk/java @pascalwilbrink -sdks/community/kotlin @contextablemark -docs/sdk/kotlin @contextablemark - sdks/community/go @mattsp1290 docs/sdk/go @mattsp1290 sdks/community/dart @mattsp1290 docs/sdk/dart @mattsp1290 -integrations/adk-middleware @contextablemark - integrations/agent-spec @sonleoracle .github/config-allowlist.txt @AlemTuzlak @atai diff --git a/.github/config-allowlist.txt b/.github/config-allowlist.txt index 9b776f3265..bd01a4ebf1 100644 --- a/.github/config-allowlist.txt +++ b/.github/config-allowlist.txt @@ -21,8 +21,10 @@ middlewares/a2a-middleware/tsdown.config.ts middlewares/a2ui-middleware/tsup.config.ts middlewares/event-throttle-middleware/tsdown.config.ts middlewares/mcp-apps-middleware/tsdown.config.ts +middlewares/mcp-middleware/tsdown.config.ts middlewares/middleware-starter/tsdown.config.ts sdks/community/java/examples/copilot-app/next.config.ts +sdks/typescript/packages/a2ui-toolkit/tsdown.config.ts sdks/typescript/packages/cli/tsdown.config.ts sdks/typescript/packages/client/tsdown.config.ts sdks/typescript/packages/core/tsdown.config.ts diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml new file mode 100644 index 0000000000..195a77b602 --- /dev/null +++ b/.github/workflows/canary.yml @@ -0,0 +1,261 @@ +name: canary / publish + +# Discoverable, one-click canary publisher. Surfaces in the Actions tab so any +# maintainer can publish a prerelease of the branch they're working on without +# learning the manual `canary/*` branch + dispatch dance. +# +# IMPORTANT: this workflow does NOT publish to npm itself. It ORCHESTRATES +# publish-release.yml, which holds the SINGLE npm OIDC trusted-publisher binding +# (see that file's header). Adding a second npm-publishing entry point would +# break OIDC for every @ag-ui/* package. +# +# Why a separate orchestrator instead of a flag inside publish-release.yml: +# The `npm` GitHub Environment's deployment-branch policy is evaluated against +# the ref a run is TRIGGERED on — NOT against branches created mid-run. So +# publish-release.yml can only publish a canary when its run's ref already +# matches the policy (`canary/*`). Creating a branch inside a run triggered on +# `feature/*` does not change that run's ref, so it would still be rejected. +# This workflow therefore runs on any non-main branch, mirrors it to a +# short-lived `canary/` ref, dispatches publish-release.yml ON that ref +# (clearing the env gate), waits for it, then deletes the ref. +# +# Token: the branch create/delete and the cross-workflow dispatch use the +# devops-bot GitHub App token, NOT the default GITHUB_TOKEN. Events authenticated +# with GITHUB_TOKEN do not start new workflow runs (recursion prevention), so the +# delegated publish-release run would silently never fire. + +on: + workflow_dispatch: + inputs: + scope: + description: "Package scope to publish a canary for. Regenerated from scripts/release/release.config.json — do NOT hand-edit (the release-scope-dropdown-sync CI guard enforces parity)." + required: true + type: choice + options: + - integration-a2a + - integration-adk-py + - integration-adk-ts + - integration-ag2 + - integration-agent-spec + - integration-agno + - integration-aws-strands-py + - integration-aws-strands-ts + - integration-claude-agent-sdk-py + - integration-claude-agent-sdk-ts + - integration-cloudflare-agents + - integration-crewai-py + - integration-crewai-ts + - integration-langchain + - integration-langgraph-py + - integration-langgraph-ts + - integration-langroid + - integration-llama-index + - integration-mastra + - integration-pydantic-ai + - integration-spring-ai + - integration-watsonx-py + - integration-watsonx-ts + - middleware-a2a + - middleware-a2ui + - middleware-mcp + - middleware-mcp-apps + - sdk-py + - sdk-py-a2ui-toolkit + - sdk-ts + - sdk-ts-a2ui-toolkit + - create-ag-ui-app + suffix: + description: "Prerelease suffix (e.g. 'fix-user-issue'); blank = unix timestamp. Allowed: [a-zA-Z0-9._-]+. Reuse a suffix only if the base version moved, else the publish collides." + required: false + type: string + dry_run: + description: "Dry run: build + detect but do NOT publish to npm. Useful for previewing what would ship." + required: false + default: false + type: boolean + +concurrency: + # Serialize repeated dispatches on the same source branch. Cross-branch ref + # races are independently prevented by making the canary ref unique per run + # (slug + github.run_id, see the slug step below). + group: canary-publish-${{ github.ref }} + cancel-in-progress: false + +permissions: + # The job's own GITHUB_TOKEN does nothing privileged — every write goes through + # the App token minted below. + contents: read + +jobs: + canary: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Guard ref + # Canary publishes are for non-main BRANCHES only. Block main (use the + # stable release flow) and block non-branch refs such as tags (a tag + # dispatch would otherwise canary-publish from the tagged commit). + if: github.ref == 'refs/heads/main' || !startsWith(github.ref, 'refs/heads/') + run: | + echo "::error::Canary publishes are for non-main branches only (got '${{ github.ref }}'). To release from main, use the 'release / publish' workflow with mode=stable." + exit 1 + + - name: Validate suffix + if: inputs.suffix != '' + env: + SUFFIX: ${{ inputs.suffix }} + run: | + set -euo pipefail + # Validate BEFORE any side effect (token mint, ref creation) so a bad + # suffix can't leave an orphaned canary ref behind. Bash regex matches + # the WHOLE string (grep matches per line and would accept a multi-line + # value whose first line is valid). + if ! [[ "$SUFFIX" =~ ^[a-zA-Z0-9._-]+$ ]]; then + echo "::error::Invalid suffix '$SUFFIX'. Allowed: [a-zA-Z0-9._-]+ (blank = unix timestamp)." + exit 1 + fi + + - name: Mint devops-bot token + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: "3877599" + private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }} + + - name: Compute canary branch name + id: slug + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + # Byte-deterministic (LC_ALL=C) transform collapsing the source ref to a + # single path segment under canary/ so it matches the `canary/*` + # deployment-branch policy. tr -s collapses same-char runs (so no `..`), + # and the sed fully strips any leading/trailing `.`/`-` runs. + export LC_ALL=C + SLUG=$(printf '%s' "$REF_NAME" | tr '/' '-' | tr -c 'a-zA-Z0-9._-' '-' | tr -s '.-') + SLUG=$(printf '%s' "$SLUG" | sed -E 's/^[.-]+//; s/[.-]+$//') + if [ -z "$SLUG" ]; then + echo "::error::Could not derive a canary slug from ref '$REF_NAME'" + exit 1 + fi + # Append run id AND attempt so every dispatch — including a re-run of + # this same orchestration — owns a UNIQUE canary ref. This prevents two + # dispatches whose source branches slugify to the same value (or a + # re-run reusing the run id) from racing one shared ref, and keeps run + # discovery below unambiguous (exactly one publish run per ref). + REF_SUFFIX="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "branch=canary/${SLUG}-${REF_SUFFIX}" >> "$GITHUB_OUTPUT" + echo "Canary branch: canary/${SLUG}-${REF_SUFFIX}" + + - name: Create or update canary ref + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BRANCH: ${{ steps.slug.outputs.branch }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + # Point canary/ at the dispatched ref's HEAD. The ref is unique + # per run, so it should not pre-exist; only force-update on the specific + # "already exists" case (e.g. a re-run reusing the run id). Any OTHER + # failure (auth, rate limit, 5xx) must surface, not be silently retried. + ERR=$(mktemp) + if gh api --silent -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/heads/${BRANCH}" -f sha="$SHA" 2>"$ERR"; then + echo "Created ${BRANCH} at ${SHA}" + elif grep -qi "already exists" "$ERR"; then + echo "Ref ${BRANCH} already exists; force-updating to ${SHA}" + gh api --silent -X PATCH "repos/${GITHUB_REPOSITORY}/git/refs/heads/${BRANCH}" \ + -f sha="$SHA" -F force=true + else + echo "::error::Failed to create canary ref ${BRANCH}:" + cat "$ERR" >&2 + exit 1 + fi + + - name: Dispatch publish-release.yml on the canary ref and wait + id: dispatch + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BRANCH: ${{ steps.slug.outputs.branch }} + SCOPE: ${{ inputs.scope }} + SUFFIX: ${{ inputs.suffix }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + # (suffix already validated in the "Validate suffix" step above, before + # the canary ref was created) + gh workflow run publish-release.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$BRANCH" \ + -f mode=prerelease \ + -f scope="$SCOPE" \ + -f suffix="$SUFFIX" \ + -f dry_run="$DRY_RUN" + + # The canary ref is unique to this run+attempt, so there is exactly ONE + # publish-release dispatch on it — no timestamp watermark needed (which + # also sidesteps runner/server clock-skew). Poll until it indexes + # (30 x 6s = 3 min tolerance for Actions indexing lag). --limit is + # defensive headroom; the branch/workflow/event filters are applied + # server-side so the matching run is never crowded out. + RUN_ID="" + for _ in $(seq 1 30); do + sleep 6 + RUN_ID=$(gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow=publish-release.yml \ + --branch "$BRANCH" \ + --event workflow_dispatch \ + --limit 100 \ + --json databaseId \ + --jq 'sort_by(.databaseId) | last | .databaseId // empty') || RUN_ID="" + if [ -n "$RUN_ID" ]; then + break + fi + done + if [ -z "$RUN_ID" ]; then + echo "::error::Dispatched publish-release run never appeared on ${BRANCH}. Leaving the ref in place for debugging; delete it manually once resolved." + exit 1 + fi + + # Mark located BEFORE the watch so cleanup runs even if the publish + # fails — but is skipped entirely if we never tracked a run (so we + # never delete a ref a still-pending run may need). + echo "located=true" >> "$GITHUB_OUTPUT" + + RUN_URL=$(gh run view "$RUN_ID" --repo "$GITHUB_REPOSITORY" --json url --jq .url) + echo "Delegated publish run: ${RUN_URL}" + { + echo "## Canary publish" + echo "" + echo "- **Scope:** \`${SCOPE}\`" + echo "- **Source branch:** \`${GITHUB_REF_NAME}\`" + echo "- **Delegated run:** ${RUN_URL}" + } >> "$GITHUB_STEP_SUMMARY" + + # --exit-status propagates the publish run's failure to this job. + gh run watch "$RUN_ID" --repo "$GITHUB_REPOSITORY" --exit-status + + - name: Delete canary ref + # Clean up only when we actually tracked a dispatched run (located=true), + # even if that run then failed. If the run was never located, the ref is + # deliberately left in place — deleting it could yank the ref out from + # under a publish run that is still about to start. + if: always() && steps.dispatch.outputs.located == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BRANCH: ${{ steps.slug.outputs.branch }} + run: | + # Best-effort cleanup: never fail the job on a delete hiccup, but do + # surface a real error instead of masking every failure as "gone". + set -uo pipefail + ERR=$(mktemp) + if gh api --silent -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/heads/${BRANCH}" 2>"$ERR"; then + echo "Deleted ${BRANCH}" + elif grep -qiE "not found|does not exist" "$ERR"; then + echo "Branch ${BRANCH} already gone" + else + echo "::warning::Failed to delete canary ref ${BRANCH} (manual cleanup may be needed):" + cat "$ERR" >&2 + fi diff --git a/.github/workflows/dojo-e2e.yml b/.github/workflows/dojo-e2e.yml index 58b5ab02d8..f6c441bbaf 100644 --- a/.github/workflows/dojo-e2e.yml +++ b/.github/workflows/dojo-e2e.yml @@ -311,6 +311,11 @@ jobs: - name: Run dojo+agents uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # v1.0.7 if: ${{ join(matrix.services, ',') != '' && contains(join(matrix.services, ','), 'dojo') }} + env: + # Backend tool-rendering demos call the live open-meteo API, which + # rate-limits CI's shared egress IPs and hangs the e2e tests. Return + # canned weather data instead so these suites are deterministic. + AG_UI_MOCK_WEATHER: "1" with: run: | node ../scripts/run-dojo-everything.js --only ${{ join(matrix.services, ',') }} diff --git a/.github/workflows/lint-release-workflows.yml b/.github/workflows/lint-release-workflows.yml index 6c0a095445..c79fd67167 100644 --- a/.github/workflows/lint-release-workflows.yml +++ b/.github/workflows/lint-release-workflows.yml @@ -1,10 +1,11 @@ name: Lint Release Workflows -# Runs actionlint + shellcheck against the release / create-pr, release / publish, -# and release / pre pipelines and the scripts they call. Keeps these critical, -# retry-sensitive files from silently regressing on shell or action-syntax bugs. +# Runs actionlint + shellcheck against the release / create-pr, release / +# publish, and canary / publish pipelines and the scripts they call. Keeps +# these critical, retry-sensitive files from silently regressing on shell or +# action-syntax bugs. # -# Scope is intentionally narrow: only the three release workflows and +# Scope is intentionally narrow: only the release workflows and # scripts/release/*. Expanding later is cheap; starting narrow avoids # drowning unrelated changes in pre-existing lint noise. @@ -14,7 +15,7 @@ on: paths: - ".github/workflows/prepare-release.yml" - ".github/workflows/publish-release.yml" - - ".github/workflows/prerelease.yml" + - ".github/workflows/canary.yml" - ".github/workflows/lint-release-workflows.yml" - "scripts/release/**" - "nx.json" @@ -22,7 +23,7 @@ on: paths: - ".github/workflows/prepare-release.yml" - ".github/workflows/publish-release.yml" - - ".github/workflows/prerelease.yml" + - ".github/workflows/canary.yml" - ".github/workflows/lint-release-workflows.yml" - "scripts/release/**" - "nx.json" @@ -46,7 +47,7 @@ jobs: actionlint_flags: >- .github/workflows/prepare-release.yml .github/workflows/publish-release.yml - .github/workflows/prerelease.yml + .github/workflows/canary.yml .github/workflows/lint-release-workflows.yml shellcheck: @@ -80,3 +81,35 @@ jobs: persist-credentials: false - name: Verify nx.json and release.config.json are in sync run: bash scripts/release/verify-nx-release-allowlist.sh + + release-scope-dropdown-sync: + # Verifies the workflow_dispatch `scope` choice dropdowns in + # prepare-release.yml and publish-release.yml match release.config.json's + # `.scopes` keys. These option lists are hand-maintained and drifted from + # the config (newly-enrolled packages weren't canary-selectable; stale + # scopes lingered), so this guard fails CI whenever they diverge again. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Verify release scope dropdowns match release.config.json + run: bash scripts/release/verify-release-scope-dropdowns.sh + + release-config-manifest-names: + # Verifies each release.config.json package `name` matches the actual name + # in its on-disk manifest (package.json / pyproject.toml). Catches drift + # like langroid's config name being the underscore form `ag_ui_langroid` + # while its pyproject (and PyPI distribution) is `ag-ui-langroid` — harmless + # for resolution but wrong in PR bodies, release notes and human summaries. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Verify config package names match manifests + run: bash scripts/release/verify-config-manifest-names.sh diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index f233993085..7236e89468 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -15,13 +15,16 @@ on: type: choice options: - integration-a2a - - integration-adk + - integration-adk-py + - integration-adk-ts - integration-ag2 - integration-agent-spec - integration-agno - - integration-aws-strands + - integration-aws-strands-py + - integration-aws-strands-ts - integration-claude-agent-sdk-py - integration-claude-agent-sdk-ts + - integration-cloudflare-agents - integration-crewai-py - integration-crewai-ts - integration-langchain @@ -32,11 +35,17 @@ on: - integration-mastra - integration-pydantic-ai - integration-spring-ai + - integration-watsonx-py + - integration-watsonx-ts - middleware-a2a - middleware-a2ui + - middleware-mcp - middleware-mcp-apps - sdk-py + - sdk-py-a2ui-toolkit - sdk-ts + - sdk-ts-a2ui-toolkit + - create-ag-ui-app bump: description: "Version bump level" required: true diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml deleted file mode 100644 index e61d7c7fe5..0000000000 --- a/.github/workflows/prerelease.yml +++ /dev/null @@ -1,323 +0,0 @@ -name: release / pre - -# Mirrors CopilotKit's `release / pre` DX. Publishes a canary build directly -# (no PR needed) with a timestamp or custom suffix. Run it multiple times -# with the same suffix to canary a coherent cross-scope set together. -# -# SECURITY: Build and publish are split into separate jobs. Publishing -# secrets (NPM_TOKEN, PYPI_API_TOKEN) are only available in the publish -# job, never in the same process tree as build-time code execution. - -on: - workflow_dispatch: - inputs: - scope: - description: "What to release" - required: true - type: choice - options: - - integration-a2a - - integration-adk - - integration-ag2 - - integration-agent-spec - - integration-agno - - integration-aws-strands - - integration-claude-agent-sdk-py - - integration-claude-agent-sdk-ts - - integration-crewai-py - - integration-crewai-ts - - integration-langchain - - integration-langgraph-py - - integration-langgraph-ts - - integration-langroid - - integration-llama-index - - integration-mastra - - integration-pydantic-ai - - integration-spring-ai - - middleware-a2a - - middleware-a2ui - - middleware-mcp-apps - - sdk-py - - sdk-ts - suffix: - description: "Version suffix (e.g. 'fix-user-issue'). Leave blank for timestamp." - required: false - type: string - dry_run: - description: "Dry run (don't actually publish)" - required: false - default: false - type: boolean - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -env: - NX_VERBOSE_LOGGING: true - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - outputs: - ts_projects: ${{ steps.ts.outputs.projects }} - ts_count: ${{ steps.ts.outputs.count }} - has_py_packages: ${{ steps.py.outputs.has_packages }} - steps: - - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Setup pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - with: - version: "10.33.4" - - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "22" - - - name: Install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - version: "25.x" - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - version: ">=0.8.0" - - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Install Poetry - run: pip install poetry - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Compute prerelease version and bump - id: bump - env: - INPUT_SUFFIX: ${{ inputs.suffix }} - INPUT_SCOPE: ${{ inputs.scope }} - run: | - SUFFIX="$INPUT_SUFFIX" - if [ -z "$SUFFIX" ]; then - SUFFIX=$(date +%s) - fi - - RESULT=$(pnpm tsx scripts/release/prepare-release.ts \ - --scope "$INPUT_SCOPE" \ - --bump prerelease \ - --preid "canary.${SUFFIX}") - - echo "$RESULT" > /tmp/bump-result.json - - { - echo "## Prerelease packages" - jq -r '.packages[] | "- **\(.name)**: \(.oldVersion) → \(.newVersion)"' /tmp/bump-result.json - } >> "$GITHUB_STEP_SUMMARY" - - - name: Extract TypeScript projects in scope - id: ts - run: | - PROJECTS=$(jq -r '[.packages[] | select(.ecosystem == "typescript") | .name] | join(",")' /tmp/bump-result.json) - COUNT=$(jq '[.packages[] | select(.ecosystem == "typescript")] | length' /tmp/bump-result.json) - echo "projects=$PROJECTS" >> "$GITHUB_OUTPUT" - echo "count=$COUNT" >> "$GITHUB_OUTPUT" - if [ "$COUNT" -gt 0 ]; then - echo "TypeScript projects in scope: $PROJECTS" - else - echo "No TypeScript projects in scope — build/test/publish will be skipped" - fi - - - name: Check for Python packages - id: py - run: | - PY_COUNT=$(jq '[.packages[] | select(.ecosystem == "python")] | length' /tmp/bump-result.json) - if [ "$PY_COUNT" -gt 0 ]; then - echo "has_packages=true" >> "$GITHUB_OUTPUT" - else - echo "has_packages=false" >> "$GITHUB_OUTPUT" - fi - - - name: Build TypeScript packages in scope - if: steps.ts.outputs.count != '0' - run: npx nx run-many -t build --projects="${STEPS_TS_OUTPUTS_PROJECTS}" - env: - STEPS_TS_OUTPUTS_PROJECTS: ${{ steps.ts.outputs.projects }} - - - name: Test TypeScript packages in scope - if: steps.ts.outputs.count != '0' - run: npx nx run-many -t test --projects="${STEPS_TS_OUTPUTS_PROJECTS}" - env: - STEPS_TS_OUTPUTS_PROJECTS: ${{ steps.ts.outputs.projects }} - - - name: Upload TypeScript build artifacts - if: steps.ts.outputs.count != '0' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ts-canary-artifacts - path: | - sdks/typescript/packages/*/dist/ - integrations/*/typescript/dist/ - middlewares/*/dist/ - retention-days: 1 - - - name: Build Python packages - if: steps.py.outputs.has_packages == 'true' - run: | - PY_PACKAGES=$(jq -c '[.packages[] | select(.ecosystem == "python")]' /tmp/bump-result.json) - while read -r pkg; do - FILE=$(echo "$pkg" | jq -r '.file') - DIR=$(dirname "$FILE") - BUILD_SYSTEM=$(echo "$pkg" | jq -r '.buildSystem // "uv"') - echo "Building Python canary from $DIR" - cd "$DIR" - if [ "$BUILD_SYSTEM" = "poetry" ]; then - poetry build - else - uv build - fi - cd "$GITHUB_WORKSPACE" - done < <(echo "$PY_PACKAGES" | jq -c '.[]') - - - name: Upload Python build artifacts - if: steps.py.outputs.has_packages == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: py-canary-artifacts - path: | - sdks/python/dist/ - integrations/*/python/dist/ - retention-days: 1 - - - name: Upload bump result - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: bump-result - path: /tmp/bump-result.json - retention-days: 1 - - publish: - needs: build - if: inputs.dry_run != true - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: npm - permissions: - contents: read - id-token: write - steps: - - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Download bump result - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: bump-result - path: /tmp/ - - # --- TypeScript publish --- - - name: Setup pnpm - if: needs.build.outputs.ts_count != '0' - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - with: - version: "10.33.4" - - - name: Setup Node - if: needs.build.outputs.ts_count != '0' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "22" - registry-url: https://registry.npmjs.org - - - name: Download TypeScript build artifacts - if: needs.build.outputs.ts_count != '0' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ts-canary-artifacts - - - name: Publish TypeScript packages - if: needs.build.outputs.ts_count != '0' - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NEEDS_BUILD_OUTPUTS_TS_PROJECTS: ${{ needs.build.outputs.ts_projects }} - run: | - echo "Publishing TypeScript canary: ${NEEDS_BUILD_OUTPUTS_TS_PROJECTS}" - npx nx release publish --projects="${NEEDS_BUILD_OUTPUTS_TS_PROJECTS}" --tag canary - - # --- Python publish --- - - name: Install uv - if: needs.build.outputs.has_py_packages == 'true' - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - version: ">=0.8.0" - - - name: Download Python build artifacts - if: needs.build.outputs.has_py_packages == 'true' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: py-canary-artifacts - - - name: Publish Python packages - if: needs.build.outputs.has_py_packages == 'true' - env: - UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} - run: | - PY_PACKAGES=$(jq -c '[.packages[] | select(.ecosystem == "python")]' /tmp/bump-result.json) - PY_COUNT=$(echo "$PY_PACKAGES" | jq 'length') - if [ "$PY_COUNT" -gt 0 ]; then - FAILED=0 - while read -r pkg; do - FILE=$(echo "$pkg" | jq -r '.file') - DIR=$(dirname "$FILE") - echo "Publishing Python canary from $DIR" - if [ -d "${DIR}/dist" ]; then - uv publish "${DIR}/dist/*" - else - echo "WARNING: No dist/ found at ${DIR} — skipping" - FAILED=1 - fi - done < <(echo "$PY_PACKAGES" | jq -c '.[]') - if [ "$FAILED" -ne 0 ]; then - echo "ERROR: One or more Python canary publishes failed" >&2 - exit 1 - fi - fi - - - name: Publish summary - run: | - { - echo "" - echo "Published canary versions." - echo "" - jq -r '.packages[] | select(.ecosystem == "typescript") | "```\nnpm install \(.name)@canary\n```"' /tmp/bump-result.json - jq -r '.packages[] | select(.ecosystem == "python") | "```\npip install \(.name)==\(.newVersion)\n```"' /tmp/bump-result.json - } >> "$GITHUB_STEP_SUMMARY" - - dry-run-summary: - needs: build - if: inputs.dry_run == true - runs-on: ubuntu-latest - steps: - - name: Dry-run summary - run: | - { - echo "" - echo "**DRY RUN** — no packages were published" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index d8c6399ec8..f99c7bb657 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,38 +1,120 @@ name: release / publish -# Mirrors CopilotKit's `release / publish` DX. Fires automatically on any -# merged PR to main, and also supports manual dispatch (useful for retries -# after a partial failure, or for forcing a publish of a version that's -# already on main but not yet on the registries). +# Mirrors CopilotKit's `release / publish` DX. This is the SINGLE npm OIDC +# entry point for ag-ui — it handles BOTH stable releases (merged release PRs) +# AND prerelease canary publishes (manual workflow_dispatch with mode=prerelease). # -# We differ from CopilotKit in one way: we detect version changes by +# We differ from CopilotKit in one way: stable mode detects version changes by # diffing against the npm/PyPI registries (rather than parsing the merged # branch name), which lets BOTH the automated release-PR flow and plain -# version-bump PRs from external maintainers trigger a publish. +# version-bump PRs from external maintainers trigger a publish. Prerelease +# mode takes an explicit `scope` input and runs prepare-release.ts in-place +# (no commit) to bump versions before pack+publish. # # Handles stable AND prerelease versions: # "1.2.3" → publishes to npm `latest` / PyPI normal # "1.2.3-alpha.0" → publishes to npm `alpha` / PyPI (pip needs --pre) +# "1.2.3-canary." → publishes to npm `canary` (prerelease mode) # "1.2.3a0" / "1.2.3b0" / "1.2.3rc0" → same, with PEP 440 prerelease tags # # SECURITY: Build and publish are split into separate jobs. Publishing -# secrets (NPM_TOKEN, PYPI_API_TOKEN) are only available in the publish -# job, never in the same process tree as build-time code execution. +# secrets (PYPI_API_TOKEN) are only available in the publish job, never in +# the same process tree as build-time code execution. npm publishing uses +# OIDC trusted publishing (no token at all) — see below. +# +# OIDC ENTRY POINT: This workflow is the ONLY workflow that publishes to npm +# under OIDC. The npm trusted publisher records for every @ag-ui/* package +# are bound to THIS workflow file path. npm matches the OIDC token's +# `workflow_ref` claim against the CALLER workflow path (NOT +# `job_workflow_ref` of a reusable workflow). That is why both stable and +# prerelease publishing physically run from this file as `mode`-gated steps, +# NOT via `workflow_call` indirection from a separate prerelease workflow. +# +# WARNING: npm trusted-publisher binding pins to: +# repository: ag-ui-protocol/ag-ui +# workflow_file_path: .github/workflows/publish-release.yml +# environment_name: npm +# Renaming this file, this `environment:` value, or the workflow path +# breaks npm publishing for every @ag-ui/* package silently until the +# trusted-publisher config on npmjs.org is updated to match. Do NOT add +# NPM_TOKEN-based publishing to any other workflow either — there can be +# only ONE trusted-publisher record per package on npm, and it is bound here. +# +# Dispatch examples: +# gh workflow run publish-release.yml -R ag-ui-protocol/ag-ui \ +# -f mode=stable -f dry_run=true +# +# gh workflow run publish-release.yml -R ag-ui-protocol/ag-ui \ +# -f mode=prerelease -f scope=integration-langgraph-py -f suffix=fix-user-issue on: # Version bumps can only live in package.json or pyproject.toml, so we # gate the trigger on those paths. Combined with the release.config.json # allowlist in detect-ts-version-changes.sh this gives defense in depth: - # unrelated PRs don't even start the workflow, and any workflow run that + # unrelated pushes don't even start the workflow, and any workflow run that # does fire still filters to enrolled packages before touching a registry. - pull_request: - types: [closed] + # + # We trigger stable releases on push-to-main (NOT pull_request: closed) so + # the workflow run's ref is refs/heads/main. Prerelease dispatches are + # intentionally allowed from any selected branch; the npm GitHub Environment + # must therefore allow those deployment branches while stable branch safety is + # enforced by the build job's mode-aware guard below. + push: branches: [main] paths: - "**/package.json" - "**/pyproject.toml" workflow_dispatch: inputs: + mode: + description: "Publish mode: stable (full release with tag + GH Release) or prerelease (canary, --tag canary, no tag/release)" + required: true + default: stable + type: choice + options: + - stable + - prerelease + scope: + description: "Prerelease scope (ignored when mode=stable). Regenerated from scripts/release/release.config.json — do NOT hand-edit." + required: false + type: choice + options: + - integration-a2a + - integration-adk-py + - integration-adk-ts + - integration-ag2 + - integration-agent-spec + - integration-agno + - integration-aws-strands-py + - integration-aws-strands-ts + - integration-claude-agent-sdk-py + - integration-claude-agent-sdk-ts + - integration-cloudflare-agents + - integration-crewai-py + - integration-crewai-ts + - integration-langchain + - integration-langgraph-py + - integration-langgraph-ts + - integration-langroid + - integration-llama-index + - integration-mastra + - integration-pydantic-ai + - integration-spring-ai + - integration-watsonx-py + - integration-watsonx-ts + - middleware-a2a + - middleware-a2ui + - middleware-mcp + - middleware-mcp-apps + - sdk-py + - sdk-py-a2ui-toolkit + - sdk-ts + - sdk-ts-a2ui-toolkit + - create-ag-ui-app + suffix: + description: "Prerelease suffix (e.g. 'fix-user-issue'); blank = unix timestamp. Allowed: [a-zA-Z0-9._-]+. Ignored when mode=stable." + required: false + type: string dry_run: description: "Dry run (detect but don't publish). Useful for previewing." required: false @@ -51,26 +133,57 @@ permissions: jobs: build: - # Fires on merged release PRs OR on manual dispatch (retry / forced publish). + # Fires on push-to-main (stable; a merged release PR lands a commit here), + # on stable manual workflow_dispatch from main (retry escape hatch), or on + # prerelease manual workflow_dispatch from any selected branch. Canary + # publishes are intentionally branch-scoped so maintainers can push a + # button on feature work without merging first. if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && github.event.pull_request.merged == true) + (github.event_name == 'workflow_dispatch' && + (inputs.mode == 'prerelease' || github.ref == 'refs/heads/main')) || + github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read outputs: + mode: ${{ steps.meta.outputs.mode }} + scope: ${{ steps.meta.outputs.scope }} + # Stable-only outputs (populated when mode=stable). ts_packages: ${{ steps.detect_ts.outputs.packages }} ts_count: ${{ steps.detect_ts.outputs.count }} py_packages: ${{ steps.detect_py.outputs.packages }} py_count: ${{ steps.detect_py.outputs.count }} ts_groups_json: ${{ steps.save_groups.outputs.groups }} + # Prerelease-only outputs (populated when mode=prerelease). + pre_ts_packages_json: ${{ steps.pre_ts.outputs.packages_json }} + pre_ts_projects: ${{ steps.pre_ts.outputs.projects }} + pre_ts_count: ${{ steps.pre_ts.outputs.count }} + pre_has_py_packages: ${{ steps.pre_py.outputs.has_packages }} steps: - - name: Checkout merged main + - name: Determine mode and scope + id: meta + env: + INPUT_MODE: ${{ inputs.mode }} + INPUT_SCOPE: ${{ inputs.scope }} + run: | + set -euo pipefail + # Default mode to 'stable' when the workflow is invoked from a + # push event (where inputs.mode is the empty string). Every + # downstream conditional MUST read steps.meta.outputs.mode rather + # than raw inputs.mode — referencing inputs.mode directly on a + # push run yields "" and silently misroutes the build. + MODE="${INPUT_MODE:-stable}" + SCOPE="${INPUT_SCOPE:-}" + echo "mode=$MODE" >> "$GITHUB_OUTPUT" + echo "scope=$SCOPE" >> "$GITHUB_OUTPUT" + echo "Detected mode: $MODE, scope: $SCOPE" + + - name: Checkout release ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - ref: main + ref: ${{ steps.meta.outputs.mode == 'prerelease' && github.ref || 'main' }} persist-credentials: false - name: Setup pnpm @@ -112,30 +225,94 @@ jobs: # regardless of job separation. run: pnpm install --frozen-lockfile + # ===== Stable mode: detect version changes vs registries ===== - name: Detect TypeScript version changes vs npm id: detect_ts + if: steps.meta.outputs.mode == 'stable' run: | - CHANGED=$(bash scripts/release/detect-ts-version-changes.sh 2>detect-ts.log || echo "[]") + set -euo pipefail + # Fail loud if the detect script itself errors. Previously this used + # `|| echo "[]"` which converted a SCRIPT failure (registry down, + # parser bug, jq missing, etc.) into "nothing to publish" — a release + # could silently no-op. Capture the script's exit code explicitly and + # surface its log on failure. An empty array from a SUCCESSFUL run is + # still valid and routes through the "nothing to publish" branch. + set +e + CHANGED=$(bash scripts/release/detect-ts-version-changes.sh 2>detect-ts.log) + RC=$? + set -e + if [ "$RC" -ne 0 ]; then + echo "::error::detect-ts-version-changes.sh exited with status $RC" + echo "::group::detect-ts.log" + cat detect-ts.log || true + echo "::endgroup::" + exit 1 + fi cat detect-ts.log || true [ -z "$CHANGED" ] && CHANGED="[]" [ "$CHANGED" = "null" ] && CHANGED="[]" + # Validate that the script's stdout parses as JSON. Without this, a + # script that prints diagnostic text to stdout would flow into + # downstream `jq` calls and fail with confusing parse errors far from + # the source. + if ! echo "$CHANGED" | jq -e . >/dev/null 2>&1; then + echo "::error::detect-ts-version-changes.sh produced non-JSON stdout" + echo "::group::stdout (first 200 chars)" + printf '%s\n' "$CHANGED" | head -c 200 + echo + echo "::endgroup::" + exit 1 + fi echo "packages=$CHANGED" >> "$GITHUB_OUTPUT" COUNT=$(echo "$CHANGED" | jq 'length') echo "count=$COUNT" >> "$GITHUB_OUTPUT" - name: Detect Python version changes vs PyPI id: detect_py + if: steps.meta.outputs.mode == 'stable' run: | - CHANGED=$(bash scripts/release/detect-py-version-changes.sh 2>detect-py.log || echo "[]") + set -euo pipefail + # Fail loud if the detect script itself errors. Previously this used + # `|| echo "[]"` which converted a SCRIPT failure (registry down, + # parser bug, jq missing, etc.) into "nothing to publish" — a release + # could silently no-op. Capture the script's exit code explicitly and + # surface its log on failure. An empty array from a SUCCESSFUL run is + # still valid and routes through the "nothing to publish" branch. + set +e + CHANGED=$(bash scripts/release/detect-py-version-changes.sh 2>detect-py.log) + RC=$? + set -e + if [ "$RC" -ne 0 ]; then + echo "::error::detect-py-version-changes.sh exited with status $RC" + echo "::group::detect-py.log" + cat detect-py.log || true + echo "::endgroup::" + exit 1 + fi cat detect-py.log || true [ -z "$CHANGED" ] && CHANGED="[]" [ "$CHANGED" = "null" ] && CHANGED="[]" + # Validate that the script's stdout parses as JSON. Without this, a + # script that prints diagnostic text to stdout would flow into + # downstream `jq` calls and fail with confusing parse errors far from + # the source. + if ! echo "$CHANGED" | jq -e . >/dev/null 2>&1; then + echo "::error::detect-py-version-changes.sh produced non-JSON stdout" + echo "::group::stdout (first 200 chars)" + printf '%s\n' "$CHANGED" | head -c 200 + echo + echo "::endgroup::" + exit 1 + fi echo "packages=$CHANGED" >> "$GITHUB_OUTPUT" COUNT=$(echo "$CHANGED" | jq 'length') echo "count=$COUNT" >> "$GITHUB_OUTPUT" - - name: Nothing to publish - if: steps.detect_ts.outputs.count == '0' && steps.detect_py.outputs.count == '0' + - name: Nothing to publish (stable) + if: > + steps.meta.outputs.mode == 'stable' && + steps.detect_ts.outputs.count == '0' && + steps.detect_py.outputs.count == '0' run: | { echo "## release / publish" @@ -143,8 +320,8 @@ jobs: echo "No version changes detected — nothing to publish." } >> "$GITHUB_STEP_SUMMARY" - - name: Extract TypeScript project names - if: steps.detect_ts.outputs.count != '0' + - name: Extract TypeScript project names (stable) + if: steps.meta.outputs.mode == 'stable' && steps.detect_ts.outputs.count != '0' id: ts_projects env: TS_PACKAGES: ${{ steps.detect_ts.outputs.packages }} @@ -153,20 +330,20 @@ jobs: echo "projects=$PROJECTS" >> "$GITHUB_OUTPUT" echo "Scoped build/test to TypeScript projects: $PROJECTS" - - name: Build TypeScript packages in scope - if: steps.detect_ts.outputs.count != '0' + - name: Build TypeScript packages in scope (stable) + if: steps.meta.outputs.mode == 'stable' && steps.detect_ts.outputs.count != '0' run: npx nx run-many -t build --projects="${STEPS_TS_PROJECTS_OUTPUTS_PROJECTS}" env: STEPS_TS_PROJECTS_OUTPUTS_PROJECTS: ${{ steps.ts_projects.outputs.projects }} - - name: Test TypeScript packages in scope - if: steps.detect_ts.outputs.count != '0' + - name: Test TypeScript packages in scope (stable) + if: steps.meta.outputs.mode == 'stable' && steps.detect_ts.outputs.count != '0' run: npx nx run-many -t test --projects="${STEPS_TS_PROJECTS_OUTPUTS_PROJECTS}" env: STEPS_TS_PROJECTS_OUTPUTS_PROJECTS: ${{ steps.ts_projects.outputs.projects }} - - name: Group TypeScript packages by dist-tag - if: steps.detect_ts.outputs.count != '0' + - name: Group TypeScript packages by dist-tag (stable) + if: steps.meta.outputs.mode == 'stable' && steps.detect_ts.outputs.count != '0' id: ts_groups env: TS_PACKAGES: ${{ steps.detect_ts.outputs.packages }} @@ -186,8 +363,8 @@ jobs: PYEOF cat /tmp/ts-groups.json - - name: Save groups to output - if: steps.detect_ts.outputs.count != '0' + - name: Save groups to output (stable) + if: steps.meta.outputs.mode == 'stable' && steps.detect_ts.outputs.count != '0' id: save_groups run: | { @@ -196,19 +373,20 @@ jobs: echo "EOF" } >> "$GITHUB_OUTPUT" - - name: Upload TypeScript build artifacts - if: steps.detect_ts.outputs.count != '0' + - name: Upload TypeScript build artifacts (stable) + if: steps.meta.outputs.mode == 'stable' && steps.detect_ts.outputs.count != '0' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ts-build-artifacts path: | sdks/typescript/packages/*/dist/ integrations/*/typescript/dist/ + integrations/**/typescript/dist/ middlewares/*/dist/ retention-days: 1 - - name: Build Python packages - if: steps.detect_py.outputs.count != '0' + - name: Build Python packages (stable) + if: steps.meta.outputs.mode == 'stable' && steps.detect_py.outputs.count != '0' env: PY_PACKAGES: ${{ steps.detect_py.outputs.packages }} run: | @@ -226,21 +404,177 @@ jobs: cd "$GITHUB_WORKSPACE" done < <(echo "$PY_PACKAGES" | jq -c '.[]') - - name: Upload Python build artifacts - if: steps.detect_py.outputs.count != '0' + - name: Upload Python build artifacts (stable) + if: steps.meta.outputs.mode == 'stable' && steps.detect_py.outputs.count != '0' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: py-build-artifacts path: | sdks/python/dist/ + sdks/python/*/dist/ integrations/*/python/dist/ + if-no-files-found: error + retention-days: 1 + + # ===== Prerelease mode: validate suffix, bump in place, build/test, upload ===== + # Validate user-supplied suffix against npm-safe charset BEFORE invoking + # prepare-release.ts. Empty suffix → fall back to unix timestamp. + # Mirrors CPK's bump-prerelease.ts gating; prevents shell-injection and + # registry-name-validation failures downstream. + - name: Compute prerelease version and bump + if: steps.meta.outputs.mode == 'prerelease' + id: pre_bump + env: + INPUT_SUFFIX: ${{ inputs.suffix }} + INPUT_SCOPE: ${{ inputs.scope }} + run: | + set -euo pipefail + SUFFIX="$INPUT_SUFFIX" + if [ -n "$SUFFIX" ]; then + if ! [[ "$SUFFIX" =~ ^[a-zA-Z0-9._-]+$ ]]; then + echo "::error::Invalid suffix '$SUFFIX'. Allowed: [a-zA-Z0-9._-]+" + exit 1 + fi + else + SUFFIX=$(date +%s) + fi + if [ -z "$INPUT_SCOPE" ]; then + echo "::error::mode=prerelease requires a scope input" + exit 1 + fi + + RESULT=$(pnpm tsx scripts/release/prepare-release.ts \ + --scope "$INPUT_SCOPE" \ + --bump prerelease \ + --preid "canary.${SUFFIX}") + + echo "$RESULT" > /tmp/bump-result.json + + { + echo "## Prerelease packages" + jq -r '.packages[] | "- **\(.name)**: \(.oldVersion) → \(.newVersion)"' /tmp/bump-result.json + } >> "$GITHUB_STEP_SUMMARY" + + - name: Extract TypeScript projects in scope (prerelease) + if: steps.meta.outputs.mode == 'prerelease' + id: pre_ts + run: | + PROJECTS=$(jq -r '[.packages[] | select(.ecosystem == "typescript") | .name] | join(",")' /tmp/bump-result.json) + COUNT=$(jq '[.packages[] | select(.ecosystem == "typescript")] | length' /tmp/bump-result.json) + echo "projects=$PROJECTS" >> "$GITHUB_OUTPUT" + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + # Per-package JSON for the unified publish job. Shape mirrors what + # the stable publish loop consumes (name/version/path). `path` is + # the workspace-relative dir containing package.json, derived from + # the `file` field that prepare-release.ts already emits — no + # script change required. + PACKAGES_JSON=$(jq -c '[.packages[] | select(.ecosystem == "typescript") | {name: .name, version: .newVersion, path: (.file | sub("/package\\.json$"; ""))}]' /tmp/bump-result.json) + echo "packages_json=$PACKAGES_JSON" >> "$GITHUB_OUTPUT" + if [ "$COUNT" -gt 0 ]; then + echo "TypeScript projects in scope: $PROJECTS" + echo "TypeScript packages JSON: $PACKAGES_JSON" + else + echo "No TypeScript projects in scope — build/test/publish will be skipped" + fi + + - name: Check for Python packages (prerelease) + if: steps.meta.outputs.mode == 'prerelease' + id: pre_py + run: | + PY_COUNT=$(jq '[.packages[] | select(.ecosystem == "python")] | length' /tmp/bump-result.json) + if [ "$PY_COUNT" -gt 0 ]; then + echo "has_packages=true" >> "$GITHUB_OUTPUT" + else + echo "has_packages=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build TypeScript packages in scope (prerelease) + if: steps.meta.outputs.mode == 'prerelease' && steps.pre_ts.outputs.count != '0' + run: npx nx run-many -t build --projects="${STEPS_PRE_TS_OUTPUTS_PROJECTS}" + env: + STEPS_PRE_TS_OUTPUTS_PROJECTS: ${{ steps.pre_ts.outputs.projects }} + + - name: Test TypeScript packages in scope (prerelease) + if: steps.meta.outputs.mode == 'prerelease' && steps.pre_ts.outputs.count != '0' + run: npx nx run-many -t test --projects="${STEPS_PRE_TS_OUTPUTS_PROJECTS}" + env: + STEPS_PRE_TS_OUTPUTS_PROJECTS: ${{ steps.pre_ts.outputs.projects }} + + # Upload BOTH dist/ AND the canary-bumped package.json files. The bumps + # applied by prepare-release.ts (above) live only on this runner's disk + # — they are NOT committed. The unified publish job does a fresh + # `actions/checkout` and would otherwise see the ORIGINAL versions, + # causing `pnpm pack` to produce a tarball with the wrong version that + # no longer matches the TARBALL_NAME computed from packages_json. + # Including the bumped package.json files in this artifact lets the + # download step overlay them into the freshly checked-out workspace + # before `pnpm pack` runs. CRITICAL — do not remove without re-thinking + # the canary version-restore mechanism. + - name: Upload TypeScript build artifacts (prerelease) + if: steps.meta.outputs.mode == 'prerelease' && steps.pre_ts.outputs.count != '0' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ts-canary-artifacts + path: | + sdks/typescript/packages/*/dist/ + integrations/*/typescript/dist/ + integrations/**/typescript/dist/ + middlewares/*/dist/ + sdks/typescript/packages/*/package.json + integrations/*/typescript/package.json + integrations/**/typescript/package.json + middlewares/*/package.json + retention-days: 1 + + - name: Build Python packages (prerelease) + if: steps.meta.outputs.mode == 'prerelease' && steps.pre_py.outputs.has_packages == 'true' + run: | + PY_PACKAGES=$(jq -c '[.packages[] | select(.ecosystem == "python")]' /tmp/bump-result.json) + while read -r pkg; do + FILE=$(echo "$pkg" | jq -r '.file') + DIR=$(dirname "$FILE") + BUILD_SYSTEM=$(echo "$pkg" | jq -r '.buildSystem // "uv"') + echo "Building Python canary from $DIR" + cd "$DIR" + if [ "$BUILD_SYSTEM" = "poetry" ]; then + poetry build + else + uv build + fi + cd "$GITHUB_WORKSPACE" + done < <(echo "$PY_PACKAGES" | jq -c '.[]') + + - name: Upload Python build artifacts (prerelease) + if: steps.meta.outputs.mode == 'prerelease' && steps.pre_py.outputs.has_packages == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: py-canary-artifacts + path: | + sdks/python/dist/ + sdks/python/*/dist/ + integrations/*/python/dist/ + if-no-files-found: error + retention-days: 1 + + - name: Upload bump result (prerelease) + if: steps.meta.outputs.mode == 'prerelease' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bump-result + path: /tmp/bump-result.json retention-days: 1 publish: needs: build + # Single publish job for BOTH modes. Runs whenever the build job has + # something to publish (stable: detected packages; prerelease: in-scope + # packages from prepare-release.ts) AND we are not in dry_run mode. if: > - (needs.build.outputs.ts_count != '0' || needs.build.outputs.py_count != '0') && - (github.event_name == 'workflow_dispatch' && inputs.dry_run != true || github.event_name != 'workflow_dispatch') + ((needs.build.outputs.mode == 'stable' && + (needs.build.outputs.ts_count != '0' || needs.build.outputs.py_count != '0')) || + (needs.build.outputs.mode == 'prerelease' && + (needs.build.outputs.pre_ts_count != '0' || needs.build.outputs.pre_has_py_packages == 'true'))) && + (github.event_name != 'workflow_dispatch' || inputs.dry_run != true) runs-on: ubuntu-latest # 30min covers worst-case 12-package release with 60s/package CDN verification budget timeout-minutes: 30 @@ -251,39 +585,50 @@ jobs: # Renaming this file, this `environment:` value, or the workflow path # breaks npm publishing for every @ag-ui/* package silently until the # trusted-publisher config on npmjs.org is updated to match. + # The GitHub Environment's deployment branch policy must allow prerelease + # workflow_dispatch refs; stable releases remain main-only via the build + # job guard. environment: npm permissions: contents: write id-token: write steps: - - name: Checkout merged main + - name: Checkout release ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - ref: main + ref: ${{ needs.build.outputs.mode == 'prerelease' && github.ref || 'main' }} token: ${{ secrets.GITHUB_TOKEN }} persist-credentials: false # --- TypeScript publish --- - name: Setup pnpm - if: needs.build.outputs.ts_count != '0' + if: needs.build.outputs.ts_count != '0' || needs.build.outputs.pre_ts_count != '0' uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 with: version: "10.33.4" - name: Setup Node - if: needs.build.outputs.ts_count != '0' + if: needs.build.outputs.ts_count != '0' || needs.build.outputs.pre_ts_count != '0' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" registry-url: https://registry.npmjs.org - - name: Download TypeScript build artifacts - if: needs.build.outputs.ts_count != '0' + # Stable: download stable ts artifacts. + - name: Download TypeScript build artifacts (stable) + if: needs.build.outputs.mode == 'stable' && needs.build.outputs.ts_count != '0' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ts-build-artifacts + # Prerelease: download canary artifacts (dist/ + bumped package.json files). + - name: Download TypeScript build artifacts (prerelease) + if: needs.build.outputs.mode == 'prerelease' && needs.build.outputs.pre_ts_count != '0' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ts-canary-artifacts + # pnpm pack needs the full workspace installed so that workspace:* protocol # deps get rewritten to real versions in the published tarball. # --ignore-scripts preserves the security boundary: no install-time lifecycle @@ -293,10 +638,48 @@ jobs: # artifacts are downloaded from the build job, so prepack/prepublishOnly # scripts have no work to do anyway.) - name: Install dependencies (no lifecycle scripts) - if: needs.build.outputs.ts_count != '0' + if: needs.build.outputs.ts_count != '0' || needs.build.outputs.pre_ts_count != '0' run: pnpm install --frozen-lockfile --ignore-scripts - - name: Publish TypeScript packages - if: needs.build.outputs.ts_count != '0' + + # Verify the canary-bumped package.json files were actually restored from + # the artifact. prepare-release.ts bumps versions in place without + # committing, so the freshly checked-out workspace had ORIGINAL versions. + # The artifact MUST overlay the bumped package.json onto each package + # directory before `pnpm pack` reads the on-disk version. If this check + # fails, the build job's upload glob is no longer covering the package's + # path — fail loud rather than silently publishing the wrong version + # under the `canary` dist-tag. + - name: Verify canary versions restored from artifact (prerelease) + if: needs.build.outputs.mode == 'prerelease' && needs.build.outputs.pre_ts_count != '0' + env: + TS_PACKAGES: ${{ needs.build.outputs.pre_ts_packages_json }} + run: | + echo "$TS_PACKAGES" > /tmp/ts-packages.json + MISMATCH="" + for PROJECT in $(jq -r '.[].name' /tmp/ts-packages.json); do + PKG_PATH=$(jq -r --arg n "$PROJECT" '.[] | select(.name == $n) | .path' /tmp/ts-packages.json) + EXPECTED=$(jq -r --arg n "$PROJECT" '.[] | select(.name == $n) | .version' /tmp/ts-packages.json) + if [ ! -f "${PKG_PATH}/package.json" ]; then + echo "::error::Missing package.json at ${PKG_PATH} for ${PROJECT}" + MISMATCH="${MISMATCH} ${PROJECT}" + continue + fi + ACTUAL=$(jq -r '.version' "${PKG_PATH}/package.json") + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "::error::Canary version not restored for ${PROJECT}: on-disk=${ACTUAL}, expected=${EXPECTED}" + echo "::error::The build job's upload-artifact step must include ${PKG_PATH}/package.json in its path list." + MISMATCH="${MISMATCH} ${PROJECT}" + else + echo "OK: ${PROJECT}@${EXPECTED} restored at ${PKG_PATH}" + fi + done + if [ -n "$MISMATCH" ]; then + echo "::error::Canary version restore check failed for:${MISMATCH}" + exit 1 + fi + + - name: Publish TypeScript packages (stable) + if: needs.build.outputs.mode == 'stable' && needs.build.outputs.ts_count != '0' env: # Empty string is required — any value here (even an expired token) # makes npm fall back to token auth and silently skip OIDC. The @@ -361,7 +744,13 @@ jobs: VIEW_STDERR=$(mktemp) VIEW_STDOUT=$(mktemp) for ATTEMPT in 1 2 3 4 5 6; do - if npm view "${PROJECT}@${PKG_VERSION}" version --registry=https://registry.npmjs.org/ >"$VIEW_STDOUT" 2>"$VIEW_STDERR"; then + # exit 0 alone is not sufficient — npm view can return 0 with + # empty stdout (e.g. when the registry serves a stale doc that + # lacks the new version under a dist-tag) or with a different + # version string (cache inversion across CDN edges). Require + # an exact line match against the version we just published. + if npm view "${PROJECT}@${PKG_VERSION}" version --registry=https://registry.npmjs.org/ >"$VIEW_STDOUT" 2>"$VIEW_STDERR" \ + && grep -qxF "$PKG_VERSION" "$VIEW_STDOUT"; then PUBLISH_VERIFIED=1 break fi @@ -382,7 +771,17 @@ jobs: rm -f "$VIEW_STDERR" "$VIEW_STDOUT" else cat "$PUBLISH_LOG" - if grep -qiE "EPUBLISHCONFLICT|cannot publish over|previously published" "$PUBLISH_LOG"; then + # Benign "already published" detection. npm 11 surfaces this + # in several shapes depending on the registry edge: the legacy + # `EPUBLISHCONFLICT` error code, the human-readable "cannot + # publish over the previously published versions" message, an + # HTTP `E409` conflict, or a `403 Forbidden` paired with the + # cannot-publish-over wording. Accept all of them as benign + # here — the stable loop is allowed to be a no-op on retry + # because the version is immutable on npm. Do NOT widen this + # in the CANARY publish loop: canary versions are unique per + # run, so a conflict there is a real bug. + if grep -qiE "EPUBLISHCONFLICT|E409|cannot publish over|previously published|you cannot publish over the previously published versions|403 Forbidden.*cannot publish over" "$PUBLISH_LOG"; then echo "::notice::SKIPPED (already published): ${PROJECT}@${PKG_VERSION}" else echo "FAILED (publish): ${PROJECT}" @@ -398,21 +797,135 @@ jobs: echo "TS_PUBLISH_FAILED=true" >> "$GITHUB_ENV" fi + - name: Publish TypeScript canary (prerelease) + if: needs.build.outputs.mode == 'prerelease' && needs.build.outputs.pre_ts_count != '0' + env: + # Empty string is required — any value here (even an expired token) + # makes npm fall back to token auth and silently skip OIDC. The + # workflow's `id-token: write` + the `environment: npm` trusted + # publisher config on npmjs.org are what authenticate the upload. + NODE_AUTH_TOKEN: "" + INPUT_TS_PROJECTS: ${{ needs.build.outputs.pre_ts_projects }} + TS_PACKAGES: ${{ needs.build.outputs.pre_ts_packages_json }} + run: | + echo "Publishing TypeScript canary: ${INPUT_TS_PROJECTS}" + echo "$TS_PACKAGES" > /tmp/ts-packages.json + TS_FAILED="" + # Iterate per package — mirror the stable publish loop's pnpm pack + + # npx npm@11.15.0 publish mechanism so canary uses an OIDC-capable + # npm client (npm 10, bundled with Node 22, does NOT speak OIDC). + for PROJECT in $(jq -r '.[].name' /tmp/ts-packages.json); do + PKG_PATH=$(jq -r --arg n "$PROJECT" '.[] | select(.name == $n) | .path' /tmp/ts-packages.json) + PKG_VERSION=$(jq -r --arg n "$PROJECT" '.[] | select(.name == $n) | .version' /tmp/ts-packages.json) + if [ -z "$PKG_PATH" ] || [ "$PKG_PATH" = "null" ]; then + echo "::error::Could not resolve path for ${PROJECT} from pre_ts_packages_json output" + TS_FAILED="${TS_FAILED} ${PROJECT}" + continue + fi + if [ -z "$PKG_VERSION" ] || [ "$PKG_VERSION" = "null" ]; then + echo "::error::Could not resolve version for ${PROJECT} from pre_ts_packages_json output" + TS_FAILED="${TS_FAILED} ${PROJECT}" + continue + fi + echo "=== Publishing ${PROJECT}@${PKG_VERSION} from ${PKG_PATH} with --tag canary ===" + # Use pnpm pack + npx npm@11 publish for OIDC trusted publisher flow. + # pnpm v10's `publish` does not speak OIDC; npm 11.5.1+ does. We + # stay on Node 22 and pull npm 11 via npx for this command only. + # pnpm pack still runs from the workspace so workspace:* protocol + # deps get rewritten to real versions in the tarball. + # Clean stale tarballs from prior runs/retries to prevent pnpm pack + # from bundling a previous tarball into the new one. + rm -f "$PKG_PATH"/*.tgz + if ! (cd "$PKG_PATH" && pnpm pack); then + echo "FAILED (pack): ${PROJECT}" + TS_FAILED="${TS_FAILED} ${PROJECT}" + continue + fi + TARBALL_NAME=$(echo "$PROJECT" | sed -e 's/^@//' -e 's|/|-|g')-${PKG_VERSION}.tgz + PUBLISH_LOG=$(mktemp) + echo "Publishing tarball ${TARBALL_NAME}... (output buffered until command completes)" + if (cd "$PKG_PATH" && npx --yes npm@11.15.0 publish "$TARBALL_NAME" --tag canary --access public --provenance) >"$PUBLISH_LOG" 2>&1; then + cat "$PUBLISH_LOG" + # Verify publish actually landed on the registry. Mirrors the + # stable publish loop's verification. Catches the silent-OIDC- + # fallback failure mode (npm returns 0 but the version never + # propagated) and transient registry/CDN issues. Without this, a + # green workflow run can hide a publish that never actually + # reached the canary dist-tag. + # 6 attempts × 10s = up to 60s of CDN propagation tolerance. + PUBLISH_VERIFIED=0 + VIEW_STDERR=$(mktemp) + VIEW_STDOUT=$(mktemp) + for ATTEMPT in 1 2 3 4 5 6; do + # exit 0 alone is not sufficient — npm view can return 0 with + # empty stdout (e.g. when the registry serves a stale doc that + # lacks the new version under a dist-tag) or with a different + # version string (cache inversion across CDN edges). Require + # an exact line match against the version we just published. + if npm view "${PROJECT}@${PKG_VERSION}" version --registry=https://registry.npmjs.org/ >"$VIEW_STDOUT" 2>"$VIEW_STDERR" \ + && grep -qxF "$PKG_VERSION" "$VIEW_STDOUT"; then + PUBLISH_VERIFIED=1 + break + fi + if [ "$ATTEMPT" -lt 6 ]; then + sleep 10 + fi + done + if [ "$PUBLISH_VERIFIED" -eq 1 ]; then + echo "::notice::PUBLISHED: ${PROJECT}@${PKG_VERSION} (canary)" + else + echo "::error::Canary publish returned 0 but ${PROJECT}@${PKG_VERSION} not visible on registry after 6 attempts (60s)" + echo "::error::Last npm view stderr (may be empty if npm routed error to stdout):" + if [ -s "$VIEW_STDERR" ]; then cat "$VIEW_STDERR"; else echo " (empty)"; fi + echo "::error::Last npm view stdout:" + if [ -s "$VIEW_STDOUT" ]; then cat "$VIEW_STDOUT"; else echo " (empty)"; fi + TS_FAILED="${TS_FAILED} ${PROJECT}" + fi + rm -f "$VIEW_STDERR" "$VIEW_STDOUT" + else + cat "$PUBLISH_LOG" + # Canary publishes use unique per-run version suffixes (timestamp + # or user-provided), so EPUBLISHCONFLICT here is a real bug, not + # a benign retry — surface it as a failure. + echo "FAILED (publish): ${PROJECT}" + TS_FAILED="${TS_FAILED} ${PROJECT}" + fi + rm -f "$PUBLISH_LOG" + done + if [ -n "$TS_FAILED" ]; then + echo "" + echo "::error::The following TypeScript canary packages failed to publish:${TS_FAILED}" + echo "TS_PUBLISH_FAILED=true" >> "$GITHUB_ENV" + fi + # --- Python publish --- - name: Install uv - if: needs.build.outputs.py_count != '0' + if: needs.build.outputs.py_count != '0' || needs.build.outputs.pre_has_py_packages == 'true' uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: ">=0.8.0" - - name: Download Python build artifacts - if: needs.build.outputs.py_count != '0' + - name: Download Python build artifacts (stable) + if: needs.build.outputs.mode == 'stable' && needs.build.outputs.py_count != '0' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: py-build-artifacts - - name: Publish Python packages - if: needs.build.outputs.py_count != '0' + - name: Download Python build artifacts (prerelease) + if: needs.build.outputs.mode == 'prerelease' && needs.build.outputs.pre_has_py_packages == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: py-canary-artifacts + + - name: Download bump result (prerelease) + if: needs.build.outputs.mode == 'prerelease' && needs.build.outputs.pre_has_py_packages == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bump-result + path: /tmp/ + + - name: Publish Python packages (stable) + if: needs.build.outputs.mode == 'stable' && needs.build.outputs.py_count != '0' env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} PY_PACKAGES: ${{ needs.build.outputs.py_packages }} @@ -441,6 +954,37 @@ jobs: echo "PY_PUBLISH_FAILED=true" >> "$GITHUB_ENV" fi + - name: Publish Python canary (prerelease) + if: needs.build.outputs.mode == 'prerelease' && needs.build.outputs.pre_has_py_packages == 'true' + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + PY_PACKAGES=$(jq -c '[.packages[] | select(.ecosystem == "python")]' /tmp/bump-result.json) + PY_COUNT=$(echo "$PY_PACKAGES" | jq 'length') + if [ "$PY_COUNT" -gt 0 ]; then + PY_FAILED="" + while read -r pkg; do + NAME=$(echo "$pkg" | jq -r '.name') + FILE=$(echo "$pkg" | jq -r '.file') + DIR=$(dirname "$FILE") + echo "=== Publishing Python canary ${NAME} from ${DIR} ===" + if [ -d "${DIR}/dist" ]; then + if ! uv publish "${DIR}/dist/*"; then + echo "FAILED: ${NAME}" + PY_FAILED="${PY_FAILED} ${NAME}" + fi + else + echo "WARNING: No dist/ found at ${DIR} — skipping" + PY_FAILED="${PY_FAILED} ${NAME}" + fi + done < <(echo "$PY_PACKAGES" | jq -c '.[]') + if [ -n "$PY_FAILED" ]; then + echo "" + echo "::error::The following Python canary packages failed to publish:${PY_FAILED}" + echo "PY_PUBLISH_FAILED=true" >> "$GITHUB_ENV" + fi + fi + # Fail loud BEFORE creating git tags / GitHub Releases. If publish failed # partway through, we must NOT produce tags or releases for packages that # never made it to the registry — that's the orphan-tag bug (e.g. an @@ -451,65 +995,77 @@ jobs: echo "One or more packages failed to publish. See errors above." exit 1 - # --- Git tags and GitHub Releases --- + # --- Git tags and GitHub Releases (stable only) --- + # Canary publishes intentionally do NOT produce git tags or GitHub + # Releases — they are throwaway versions identified solely by their + # canary. npm dist-tag. - name: Configure git + if: needs.build.outputs.mode != 'prerelease' run: | git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" - name: Configure git credentials for push + if: needs.build.outputs.mode != 'prerelease' run: git config --local url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "https://github.com/" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create and push per-package git tags (TypeScript) - if: needs.build.outputs.ts_count != '0' + if: needs.build.outputs.mode != 'prerelease' && needs.build.outputs.ts_count != '0' env: TS_PACKAGES: ${{ needs.build.outputs.ts_packages }} run: bash scripts/release/create-tags.sh "$TS_PACKAGES" - name: Create and push per-package git tags (Python) - if: needs.build.outputs.py_count != '0' + if: needs.build.outputs.mode != 'prerelease' && needs.build.outputs.py_count != '0' env: PY_PACKAGES: ${{ needs.build.outputs.py_packages }} run: bash scripts/release/create-tags.sh "$PY_PACKAGES" - name: Create GitHub Release (TypeScript) - if: needs.build.outputs.ts_count != '0' + if: needs.build.outputs.mode != 'prerelease' && needs.build.outputs.ts_count != '0' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TS_PACKAGES: ${{ needs.build.outputs.ts_packages }} run: bash scripts/release/create-or-update-release.sh typescript "$TS_PACKAGES" - name: Create GitHub Release (Python) - if: needs.build.outputs.py_count != '0' + if: needs.build.outputs.mode != 'prerelease' && needs.build.outputs.py_count != '0' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PY_PACKAGES: ${{ needs.build.outputs.py_packages }} run: bash scripts/release/create-or-update-release.sh python "$PY_PACKAGES" - name: Reconcile GitHub Release (TypeScript) — safety net for partial failures - if: needs.build.outputs.ts_count != '0' + if: needs.build.outputs.mode != 'prerelease' && needs.build.outputs.ts_count != '0' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TS_PACKAGES: ${{ needs.build.outputs.ts_packages }} run: bash scripts/release/reconcile-release.sh typescript "$TS_PACKAGES" - name: Reconcile GitHub Release (Python) — safety net for partial failures - if: needs.build.outputs.py_count != '0' + if: needs.build.outputs.mode != 'prerelease' && needs.build.outputs.py_count != '0' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PY_PACKAGES: ${{ needs.build.outputs.py_packages }} run: bash scripts/release/reconcile-release.sh python "$PY_PACKAGES" - - name: Delete release/next if that's what was merged - if: github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'release/next' + # On push-to-main we no longer have the merged PR's head ref on the event + # payload, so we can't tell whether release/next is what merged. Instead, + # clean up release/next whenever it still exists on origin after a stable + # publish — the delete is idempotent (no-op if the branch is already + # gone). Skipped on prerelease (canary) runs, which never touch + # release/next. + - name: Delete release/next if present + if: needs.build.outputs.mode != 'prerelease' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git push origin --delete release/next 2>/dev/null || echo "Branch already gone" - - name: Release summary + - name: Release summary (stable) + if: needs.build.outputs.mode == 'stable' env: TS_PACKAGES: ${{ needs.build.outputs.ts_packages }} PY_PACKAGES: ${{ needs.build.outputs.py_packages }} @@ -518,16 +1074,291 @@ jobs: echo "## release / publish" echo "" - if [ "$(echo "$TS_PACKAGES" | jq 'length')" -gt 0 ]; then + if [ -n "$TS_PACKAGES" ] && [ "$(echo "$TS_PACKAGES" | jq 'length')" -gt 0 ]; then echo "### npm" echo "" echo "$TS_PACKAGES" | jq -r '.[] | "- `\(.name)@\(.version)`"' echo "" fi - if [ "$(echo "$PY_PACKAGES" | jq 'length')" -gt 0 ]; then + if [ -n "$PY_PACKAGES" ] && [ "$(echo "$PY_PACKAGES" | jq 'length')" -gt 0 ]; then echo "### PyPI" echo "" echo "$PY_PACKAGES" | jq -r '.[] | "- `\(.name)@\(.version)`"' fi } >> "$GITHUB_STEP_SUMMARY" + + - name: Canary publish summary (prerelease) + if: needs.build.outputs.mode == 'prerelease' + env: + TS_PACKAGES: ${{ needs.build.outputs.pre_ts_packages_json }} + run: | + { + echo "" + echo "## Canary publish summary" + echo "" + if [ -n "$TS_PACKAGES" ] && [ "$(echo "$TS_PACKAGES" | jq 'length')" -gt 0 ]; then + echo "### npm — published" + echo "" + echo "$TS_PACKAGES" | jq -r '.[] | "```\nnpm install \(.name)@canary\n```"' + echo "" + fi + if [ -f /tmp/bump-result.json ]; then + PY_COUNT=$(jq '[.packages[] | select(.ecosystem == "python")] | length' /tmp/bump-result.json) + if [ "$PY_COUNT" -gt 0 ]; then + echo "### PyPI — published" + echo "" + jq -r '.packages[] | select(.ecosystem == "python") | "```\npip install \(.name)==\(.newVersion)\n```"' /tmp/bump-result.json + fi + fi + } >> "$GITHUB_STEP_SUMMARY" + + # Post-release #engr Slack notification. Ported from CopilotKit's `notify` + # job. Runs after build AND publish via always() so it fires on success, + # failure, OR a skipped publish — the pure builder + # (scripts/release/build-release-notification.ts) decides what (if anything) + # to post from the job results + the event-derived release intent. + # + # DIVERGENCE FROM CPK: ag-ui publishes BOTH npm and PyPI from the SINGLE + # `publish` job (CPK split them into `publish` + `publish-python`). So the + # npm-lane and PyPI-lane RESULT signals both read the shared + # needs.publish.result / needs.build.result; the two lanes are distinguished + # downstream by their published-package SETS (ts_packages vs py_packages), + # which the build job emits per-ecosystem. A publish-job failure pages + # whichever lane the event intended (npm and/or PyPI) — acceptable because + # both lanes share one publish process here. + notify: + needs: [build, publish] + # Run on every real-release context: a push-to-main (merged release PR / + # version-bump) or a non-dry-run workflow_dispatch. Skipped on dry-run + # dispatches (the builder also suppresses dry-run, but gating the job off + # avoids a needless runner). always() ensures we still notify when build + # or publish FAILED (the whole point of the failure arms). + if: > + always() && + (github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.dry_run != true)) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # SLACK_WEBHOOK MUST live at JOB level: GitHub Actions does NOT expose a + # step's own `env:` to that step's `if:` expression — only workflow- and + # job-level env is visible there. The `Post to #engr` and `Notifier + # self-watchdog` steps gate on `env.SLACK_WEBHOOK != ''`, so a step-level + # definition would make that guard ALWAYS false and the steps would never + # run, even with the secret set. + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_ENGR }} + steps: + # Compute the event-derived release intent FIRST, before checkout/install, + # so its outputs survive an infra failure in a later step (this ordering + # was a fix on the CPK side: a checkout/install failure must not blind the + # failure arms). For a push we diff the compare-range; for a dispatch we + # derive intent from the mode/scope inputs. + - name: Compute release intent + id: intent + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + SHA_BEFORE: ${{ github.event.before }} + SHA_AFTER: ${{ github.event.after }} + INPUT_SCOPE: ${{ inputs.scope }} + run: | + set -uo pipefail + NPM_INTENDED=false + PY_INTENDED=false + + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + # A stable dispatch intends the scope's lane. With no scope (full + # stable run) intend BOTH lanes. A prerelease dispatch is canary — + # canary (prerelease) is FULLY suppressed by the builder (mode == + # "prerelease" → no post on EITHER lane). Intent is computed + # uniformly here regardless of mode, but a prerelease produces no + # notification on either lane. (INPUT_MODE is not read here: the + # builder applies the canary/stable suppression from MODE; this step + # only computes per-lane FAILURE intent.) + SCOPE="${INPUT_SCOPE:-}" + if [ -z "$SCOPE" ]; then + NPM_INTENDED=true + PY_INTENDED=true + else + # Map the dispatch scope to its ecosystem. The *-py glob catches + # the sdk-py / integration-*-py PyPI scopes; the explicit names + # cover the PyPI scopes that do NOT end in -py. Everything else is + # npm. This only affects FAILURE paging — success arms use the + # real published-package sets, not this heuristic. + # + # This step runs BEFORE checkout (see the job comment above), so + # release.config.json is NOT on disk here and cannot be consulted + # at runtime — the python scopes are projected statically below. + # verify-release-scope-dropdowns.sh asserts this list maps every + # config scope to the correct ecosystem, so it cannot drift. + case "$SCOPE" in + integration-agent-spec|integration-langroid|sdk-py-a2ui-toolkit) + PY_INTENDED=true ;; + *-py) + PY_INTENDED=true ;; + *) + NPM_INTENDED=true ;; + esac + fi + else + # push event: diff the compare-range for changed manifest files. + # Fail TOWARD paging (intended=true + ::warning::) on any API error + # so an outage never silently swallows a real release failure. + FILES="" + if [ -n "$SHA_BEFORE" ] && [ -n "$SHA_AFTER" ]; then + if ! FILES=$(gh api "repos/${REPO}/compare/${SHA_BEFORE}...${SHA_AFTER}" --jq '.files[].filename' 2>/dev/null); then + echo "::warning::Could not resolve changed files for ${SHA_BEFORE}...${SHA_AFTER} — failing toward paging (both lanes intended)." + NPM_INTENDED=true + PY_INTENDED=true + FILES="" + fi + else + echo "::warning::Missing before/after SHAs on push event — failing toward paging (both lanes intended)." + NPM_INTENDED=true + PY_INTENDED=true + fi + # Intent here means "the compare-range TOUCHED a package.json / + # pyproject.toml", NOT "a version was actually bumped". This signal + # is now the BUILD-FAILURE FALLBACK for the builder's failure arms, + # NOT the primary failure gate: the builder keys each failure arm off + # the DETECTED PACKAGE SET (ts_packages / py_packages) — the + # authoritative "this lane actually attempted a release" signal — and + # consults this intent ONLY when the build failed before detection + # could populate that set (so an early build failure on an intended + # release still pages, never toward silence). Because the success + # path and the primary failure path both key off the detected set, + # the old "manifest touched but version not bumped" false-positive + # (e.g. a dependabot dependency bump) no longer pages on a SUCCESSFUL + # build that detected no packages; it can only contribute via this + # fallback when the build itself failed. + if [ -n "$FILES" ]; then + # The GitHub compare endpoint returns AT MOST 300 files in .files + # and does NOT paginate them. On a large merge where a bumped + # package.json / pyproject.toml falls beyond file #300 it would be + # absent from this list, the grep would find nothing, and intent + # would compute false — which (combined with an early build failure + # before detection) could swallow a real release-failure page. So + # when the list is truncated (>= 300 filenames) fail TOWARD paging: + # intend BOTH lanes and warn, rather than trust an incomplete list. + FILE_COUNT=$(printf '%s\n' "$FILES" | grep -c .) + if [ "$FILE_COUNT" -ge 300 ]; then + echo "::warning::Compare response for ${SHA_BEFORE}...${SHA_AFTER} returned ${FILE_COUNT} files (>= the 300-file compare-API cap, which does not paginate) — failing toward paging (both lanes intended) since a manifest bump may be truncated out of the list." + NPM_INTENDED=true + PY_INTENDED=true + else + if echo "$FILES" | grep -qE '(^|/)package\.json$'; then + NPM_INTENDED=true + fi + if echo "$FILES" | grep -qE '(^|/)pyproject\.toml$'; then + PY_INTENDED=true + fi + fi + fi + fi + + echo "npm_intended=$NPM_INTENDED" >> "$GITHUB_OUTPUT" + echo "py_intended=$PY_INTENDED" >> "$GITHUB_OUTPUT" + echo "npm_intended=$NPM_INTENDED py_intended=$PY_INTENDED" + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: "10.33.4" + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + + - name: Install dependencies (no lifecycle scripts) + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Build release notification message + id: build_message + env: + MODE: ${{ needs.build.outputs.mode }} + NPM_RESULT: ${{ needs.publish.result }} + BUILD_RESULT: ${{ needs.build.result }} + NPM_INTENDED: ${{ steps.intent.outputs.npm_intended }} + TS_PACKAGES: ${{ needs.build.outputs.ts_packages }} + TS_GROUPS: ${{ needs.build.outputs.ts_groups_json }} + PY_INTENDED: ${{ steps.intent.outputs.py_intended }} + PY_RESULT: ${{ needs.publish.result }} + PY_BUILD_RESULT: ${{ needs.build.result }} + PY_PACKAGES: ${{ needs.build.outputs.py_packages }} + SCOPE: ${{ needs.build.outputs.scope }} + DRY_RUN: ${{ inputs.dry_run }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + NPM_ORG_URL: https://www.npmjs.com/org/ag-ui + PY_BASE_URL: https://pypi.org/project + run: pnpm tsx scripts/release/build-release-notification.ts + + - name: Post to #engr + # Guard so an unset webhook is a clean no-op rather than a failure: the + # secret is unset until the #engr incoming webhook is provisioned, and a + # release must not go red merely because alerting isn't wired yet. + if: steps.build_message.outputs.should_post == 'true' && env.SLACK_WEBHOOK != '' + uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_ENGR }} + webhook-type: incoming-webhook + payload: | + { + "text": ${{ toJSON(steps.build_message.outputs.message) }} + } + + # Self-watchdog: if the notify job ITSELF failed (e.g. the message build + # or install step errored) on a real release attempt, best-effort ping + # #engr so a broken notifier doesn't fail silently. Gated off dry-run and + # only fires when the event intended a release on either lane. + - name: Notifier self-watchdog (best-effort) + # Fires unless intent is explicitly false on BOTH lanes, so an + # intent-step crash (empty outputs) still pages — never toward silence. + # Canary (prerelease) runs are FULLY suppressed on BOTH lanes — including + # this watchdog — so a notify-job failure during a prerelease dispatch + # never pings "notifier failed" on a canary run. + if: > + failure() && + inputs.dry_run != true && + needs.build.outputs.mode != 'prerelease' && + (steps.intent.outputs.npm_intended != 'false' || + steps.intent.outputs.py_intended != 'false') && + env.SLACK_WEBHOOK != '' + continue-on-error: true + uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_ENGR }} + webhook-type: incoming-webhook + payload: | + { + "text": "⚠️ *ag-ui release notifier failed* — could not determine release status. Check the run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } + + # Dry-run summary — surfaces what WOULD have been published. Runs only + # when dry_run=true on workflow_dispatch (stable detection still runs, but + # the publish job is gated off; prerelease bump still runs in the build + # job, so we can summarize the planned canary set here). + dry_run_summary: + needs: build + if: github.event_name == 'workflow_dispatch' && inputs.dry_run == true + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Dry-run summary + env: + MODE: ${{ needs.build.outputs.mode }} + run: | + { + echo "" + echo "**DRY RUN** (mode=${MODE}) — no packages were published" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/unit-python-sdk.yml b/.github/workflows/unit-python-sdk.yml index 1e18cf29a6..c7ce39373f 100644 --- a/.github/workflows/unit-python-sdk.yml +++ b/.github/workflows/unit-python-sdk.yml @@ -192,6 +192,68 @@ jobs: working-directory: integrations/adk-middleware/python run: uv run python -m pytest tests/ -v + # Exercises the suite against google-adk 2.x. pyproject advertises + # google-adk>=1.16.0,<3.0.0 ("compatible with 1.x and 2.x"), but the lockfile + # resolves 1.x (see #1946), so CI never sees 2.x. The suite is currently red + # under 2.x (#1947). This leg is INFORMATIONAL: the test step is + # continue-on-error, so the job stays green and never blocks a merge, while a + # failing 2.x run is surfaced as a warning annotation and a job summary. Once + # the 2.x failures are burned down, drop the step's continue-on-error to make + # this a required, blocking check. + adk-middleware-python-adk-2x: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Detect fork PR + id: fork-check + run: | + if [[ "${{ github.event_name }}" == "pull_request" && \ + "${GITHUB_EVENT_PULL_REQUEST_HEAD_REPO_FULL_NAME}" != "${{ github.repository }}" ]]; then + echo "prefix=fork-" >> "$GITHUB_OUTPUT" + else + echo "prefix=" >> "$GITHUB_OUTPUT" + fi + env: + GITHUB_EVENT_PULL_REQUEST_HEAD_REPO_FULL_NAME: ${{ github.event.pull_request.head.repo.full_name }} + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: ">=0.8.0" + + - name: Install dependencies + working-directory: integrations/adk-middleware/python + run: uv sync + + - name: Force google-adk 2.x + working-directory: integrations/adk-middleware/python + run: | + uv pip install "google-adk>=2,<3" + uv run --no-sync python -c "import importlib.metadata as m; print('google-adk', m.version('google-adk'))" + + - name: Run tests (google-adk 2.x) + id: adk2x-tests + continue-on-error: true + working-directory: integrations/adk-middleware/python + run: uv run --no-sync python -m pytest tests/ -v + + - name: Report google-adk 2.x result + if: always() + run: | + if [ "${{ steps.adk2x-tests.outcome }}" = "success" ]; then + echo "### ✅ google-adk 2.x: suite passed" >> "$GITHUB_STEP_SUMMARY" + echo "The suite now passes under \`google-adk>=2,<3\`. Consider dropping the step's \`continue-on-error\` to make this a required check (#1947)." >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning title=google-adk 2.x suite is red (#1947)::Informational leg — does not block merges. See the job summary." + echo "### ⚠️ google-adk 2.x: suite is currently red (#1947)" >> "$GITHUB_STEP_SUMMARY" + echo "This leg runs the suite against \`google-adk>=2,<3\` and is allowed to fail until the 2.x failures are burned down. It does not block merges." >> "$GITHUB_STEP_SUMMARY" + fi + aws-strands-python: runs-on: ubuntu-latest diff --git a/.npmrc b/.npmrc index 14c0d398a3..4c85927521 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,7 @@ minimum-release-age=1440 +minimum-release-age-exclude[]=@ag-ui/langgraph +minimum-release-age-exclude[]=@ag-ui/a2ui-middleware +minimum-release-age-exclude[]=@ag-ui/a2ui-toolkit +minimum-release-age-exclude[]=@copilotkit/* +minimum-release-age-exclude[]=@ag-ui/* block-exotic-subdeps=true diff --git a/apps/dojo/e2e/a2ui-adk-fixtures.ts b/apps/dojo/e2e/a2ui-adk-fixtures.ts new file mode 100644 index 0000000000..b16248f357 --- /dev/null +++ b/apps/dojo/e2e/a2ui-adk-fixtures.ts @@ -0,0 +1,169 @@ +/** + * aimock fixtures for the Google ADK A2UI demos (OSS-158). + * + * These emulate what the ADK adapter sees from a REAL Gemini sub-agent under the + * free-form tool schema: `render_a2ui` returns `components`/`data` as JSON + * *strings* (not structured arrays/objects), because Gemini's function-calling + * fills typed `array` args strictly (empty `{}`), so the ADK adapter + * declares them as STRING and parses them back via `_coerce_freeform_args`. + * Encoding them as strings here drives that real code path — in contrast to the + * LangGraph/gpt-4o fixtures (a2ui-recovery-fixtures.ts), which use structured + * arrays the way OpenAI fills loose schemas. + * + * Scoped to Gemini requests (`req.model` ~ "gemini-*") so they never intercept + * the OpenAI LangGraph demos. Register BEFORE registerA2UIRecoveryFixtures so a + * Gemini request matches here first; gpt-4o requests fall through. + * + * Covers: a2ui_fixed_schema (backend search_flights / search_hotels tools that + * return a fixed-layout surface), a2ui_dynamic_schema (valid hotel surface) and + * a2ui_recovery (recover: invalid→valid; exhaust: always invalid). + */ +import type { LLMock, ChatMessage } from "@copilotkit/aimock"; + +const textOf = (content: ChatMessage["content"] | undefined): string => { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text!).join(""); + } + return ""; +}; +const allText = (messages: ChatMessage[] = []): string => messages.map((m) => textOf(m.content)).join("\n"); +const userText = (messages: ChatMessage[] = []): string => + textOf(messages.filter((m) => m.role === "user").pop()?.content); + +// Toolkit appends this on a retry (augment_prompt_with_validation_errors). +const RETRY_MARKER = "Previous attempt was invalid"; + +const isGemini = (req: any) => /gemini/i.test(String(req?.model ?? "")); +const isRecover = (text: string) => /luxury/i.test(text) && !/different cities/i.test(text); +const isExhaust = (text: string) => /broken/i.test(text); +// dynamic_schema hotel prompt ("...comparison of 3 hotels...") — not luxury/broken. +const isHotelCreate = (text: string) => /comparison of 3 hotels/i.test(text); + +const ROOT = { id: "root", component: "Row", children: { componentId: "card", path: "/items" }, gap: 16 }; +const CARD = { + id: "card", + component: "HotelCard", + name: { path: "name" }, + location: { path: "location" }, + rating: { path: "rating" }, + pricePerNight: { path: "price" }, + action: { event: { name: "book_hotel", context: { hotelName: { path: "name" } } } }, +}; +const HOTELS = [ + { name: "The Ritz", location: "Paris", rating: 4.8, price: "$450/night" }, + { name: "Holiday Inn", location: "Austin", rating: 4.1, price: "$180/night" }, + { name: "Boutique Loft", location: "Lisbon", rating: 4.6, price: "$320/night" }, +]; + +// Gemini free-form shape: components/data are JSON STRINGS within the args. +// valid → [root, card]; invalid → [root] only (root's child ref `card` is missing). +const renderArgsGemini = (valid: boolean) => + JSON.stringify({ + surfaceId: "hotel-comparison", + components: JSON.stringify(valid ? [ROOT, CARD] : [ROOT]), + data: JSON.stringify({ items: HOTELS }), + }); + +// --- fixed_schema (backend tools) --------------------------------------- +// The main agent calls search_flights / search_hotels directly (no sub-agent). +// These are plain backend tools: the LLM supplies the row data, the ADK tool +// loads the fixed component layout and returns the a2ui_operations envelope. +// Args are structured here (flat arrays of flat objects) — Gemini fills these +// fine, unlike the nested array of the dynamic render_a2ui schema. +const FLIGHTS = [ + { + id: "1", + airline: "United Airlines", + airlineLogo: "https://www.google.com/s2/favicons?domain=united.com&sz=128", + flightNumber: "UA 123", + origin: "SFO", + destination: "JFK", + date: "Tue, Apr 8", + departureTime: "8:00 AM", + arrivalTime: "4:30 PM", + duration: "5h 30m", + status: "On Time", + statusIcon: "https://placehold.co/12/22c55e/22c55e.png", + price: "$289", + }, + { + id: "2", + airline: "Delta", + airlineLogo: "https://www.google.com/s2/favicons?domain=delta.com&sz=128", + flightNumber: "DL 456", + origin: "SFO", + destination: "JFK", + date: "Tue, Apr 8", + departureTime: "10:00 AM", + arrivalTime: "6:45 PM", + duration: "5h 45m", + status: "On Time", + statusIcon: "https://placehold.co/12/22c55e/22c55e.png", + price: "$315", + }, +]; +const HOTELS_FIXED = [ + { id: "1", name: "The Manhattan Grand", location: "Downtown Manhattan", rating: 4.5, price: "$350" }, + { id: "2", name: "Downtown Boutique Hotel", location: "SoHo", rating: 4.0, price: "$280" }, +]; + +export function registerA2UIADKFixtures(mockServer: LLMock): void { + const hasTool = (req: any, name: string) => req.tools?.some((t: any) => t.function.name === name); + const wantsA2UI = (req: any) => + isHotelCreate(userText(req.messages)) || isRecover(userText(req.messages)) || isExhaust(userText(req.messages)); + + // 0) fixed_schema — backend search_flights tool (user asks about flights). + mockServer.addFixture({ + match: { + predicate: (req: any) => + isGemini(req) && hasTool(req, "search_flights") && /flights/i.test(userText(req.messages)), + }, + response: { toolCalls: [{ name: "search_flights", arguments: JSON.stringify({ flights: FLIGHTS }) }] }, + }); + + // 0b) fixed_schema — backend search_hotels tool (user asks about hotels). + mockServer.addFixture({ + match: { + predicate: (req: any) => + isGemini(req) && hasTool(req, "search_hotels") && /hotels/i.test(userText(req.messages)), + }, + response: { toolCalls: [{ name: "search_hotels", arguments: JSON.stringify({ hotels: HOTELS_FIXED }) }] }, + }); + + // 1) Main ADK agent: A2UI prompt → call the generate_a2ui sub-agent tool. + mockServer.addFixture({ + match: { predicate: (req: any) => isGemini(req) && hasTool(req, "generate_a2ui") && wantsA2UI(req) }, + response: { toolCalls: [{ name: "generate_a2ui", arguments: JSON.stringify({ intent: "create" }) }] }, + }); + + // 2) Sub-agent — dynamic_schema create → valid surface (Gemini-shaped args). + mockServer.addFixture({ + match: { predicate: (req: any) => isGemini(req) && hasTool(req, "render_a2ui") && isHotelCreate(allText(req.messages)) }, + response: { toolCalls: [{ name: "render_a2ui", arguments: renderArgsGemini(true) }] }, + }); + + // 3) Sub-agent — EXHAUST ("broken"): always the dangling-ref surface (invalid). + mockServer.addFixture({ + match: { predicate: (req: any) => isGemini(req) && hasTool(req, "render_a2ui") && isExhaust(allText(req.messages)) }, + response: { toolCalls: [{ name: "render_a2ui", arguments: renderArgsGemini(false) }] }, + }); + + // 4) Sub-agent — RECOVER ("luxury"), RETRY (errors fed back) → valid. + mockServer.addFixture({ + match: { + predicate: (req: any) => + isGemini(req) && hasTool(req, "render_a2ui") && isRecover(allText(req.messages)) && allText(req.messages).includes(RETRY_MARKER), + }, + response: { toolCalls: [{ name: "render_a2ui", arguments: renderArgsGemini(true) }] }, + }); + + // 5) Sub-agent — RECOVER ("luxury"), FIRST attempt (no marker) → invalid. + mockServer.addFixture({ + match: { + predicate: (req: any) => + isGemini(req) && hasTool(req, "render_a2ui") && isRecover(allText(req.messages)) && !allText(req.messages).includes(RETRY_MARKER), + }, + response: { toolCalls: [{ name: "render_a2ui", arguments: renderArgsGemini(false) }] }, + }); +} diff --git a/apps/dojo/e2e/a2ui-recovery-fixtures.ts b/apps/dojo/e2e/a2ui-recovery-fixtures.ts new file mode 100644 index 0000000000..0874e12e87 --- /dev/null +++ b/apps/dojo/e2e/a2ui-recovery-fixtures.ts @@ -0,0 +1,110 @@ +/** + * aimock fixtures for the A2UI recovery showcase (OSS-162). + * + * Forces a STRUCTURAL error (no catalog needed — caught by structural validation + * in both the adapter loop and the middleware gate), so it rides the existing + * runtime A2UI wiring with no schema: + * - "luxury hotels" demo → FIRST render_a2ui is a Row whose repeated child + * references a `card` component the model forgot to include ("unresolved + * child"); once the error is fed back, it emits a valid surface (recovery + * succeeds → no wipe, brief "Retrying…", final surface). + * - "broken hotels" demo → ALWAYS the dangling-reference surface → recovery + * exhausts → tasteful hard-failure (conversation stays usable). + * + * IMPORTANT: every predicate is scoped to the recovery demo's own prompts + * ("luxury" / "broken"). The other A2UI demos (dynamic/fixed/advanced, incl. + * fixed_schema's "Find hotels") must fall through to their generic fixtures — + * an over-broad render_a2ui matcher here would hijack them and return THIS + * surface, breaking every other A2UI test. + * + * Wire by calling `registerA2UIRecoveryFixtures(mockServer)` from aimock-setup.ts + * BEFORE the generic fixture loader (predicate fixtures must come first). + */ +import type { LLMock, ChatMessage } from "@copilotkit/aimock"; + +const textOf = (content: ChatMessage["content"] | undefined): string => { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text!).join(""); + } + return ""; +}; +const allText = (messages: ChatMessage[] = []): string => messages.map((m) => textOf(m.content)).join("\n"); +const userText = (messages: ChatMessage[] = []): string => + textOf(messages.filter((m) => m.role === "user").pop()?.content); + +// Marker the toolkit appends to the sub-agent prompt on retry +// (augmentPromptWithValidationErrors). Presence ⇒ this is a retry. +const RETRY_MARKER = "Previous attempt was invalid"; + +// Only THIS demo's prompts. Keep these distinct from the other A2UI demos so the +// fixtures below never intercept them. +// +// The dynamic_schema "Hotel comparison" prompt — "Compare 3 luxury hotels IN +// DIFFERENT CITIES with ratings and prices." — must SUCCEED with no retries, so +// `isRecover` requires "luxury" but EXCLUDES that "different cities" variant. The +// recovery demo's own prompt ("Compare 3 luxury hotels with ratings and prices.") +// has no "different cities", so only it triggers the recover-then-succeed flow; +// the dynamic_schema prompt falls through to its generic (valid) hotel fixture. +const isRecover = (text: string) => /luxury/i.test(text) && !/different cities/i.test(text); +const isExhaust = (text: string) => /broken/i.test(text); // "Compare 3 broken hotels…" → always invalid → exhaust + +// A Row that repeats a "card" template over /items. +const ROOT = { id: "root", component: "Row", children: { componentId: "card", path: "/items" }, gap: 16 }; +// The card template the root references. Omitting it from the components array is +// the structural error (dangling child reference → "unresolved child"). +const CARD = { + id: "card", + component: "HotelCard", + name: { path: "name" }, + location: { path: "location" }, + rating: { path: "rating" }, + pricePerNight: { path: "price" }, + action: { event: { name: "book_hotel", context: { hotelName: { path: "name" } } } }, +}; +const HOTELS = [ + { name: "The Ritz", location: "Paris", rating: 4.8, price: "$450/night" }, + { name: "Holiday Inn", location: "Austin", rating: 4.1, price: "$180/night" }, + { name: "Boutique Loft", location: "Lisbon", rating: 4.6, price: "$320/night" }, +]; +// valid → [root, card]; invalid → [root] only (root's child ref `card` is missing). +const renderArgs = (valid: boolean) => + JSON.stringify({ surfaceId: "hotel-comparison", components: valid ? [ROOT, CARD] : [ROOT], data: { items: HOTELS } }); + +export function registerA2UIRecoveryFixtures(mockServer: LLMock): void { + const hasTool = (req: any, name: string) => req.tools?.some((t: any) => t.function.name === name); + + // 1) Main agent: recovery prompt → call the generate_a2ui sub-agent tool. + mockServer.addFixture({ + match: { + predicate: (req: any) => + hasTool(req, "generate_a2ui") && (isRecover(userText(req.messages)) || isExhaust(userText(req.messages))), + }, + response: { toolCalls: [{ name: "generate_a2ui", arguments: JSON.stringify({ intent: "create" }) }] }, + }); + + // 2) Sub-agent — EXHAUSTION demo ("broken hotels"): always the dangling-ref surface. + // Checked before the recover fixtures so a "broken" retry stays invalid. + mockServer.addFixture({ + match: { predicate: (req: any) => hasTool(req, "render_a2ui") && isExhaust(allText(req.messages)) }, + response: { toolCalls: [{ name: "render_a2ui", arguments: renderArgs(false) }] }, + }); + + // 3) Sub-agent — RECOVER demo ("luxury hotels"), RETRY (errors fed back) → valid. + mockServer.addFixture({ + match: { + predicate: (req: any) => + hasTool(req, "render_a2ui") && isRecover(allText(req.messages)) && allText(req.messages).includes(RETRY_MARKER), + }, + response: { toolCalls: [{ name: "render_a2ui", arguments: renderArgs(true) }] }, + }); + + // 4) Sub-agent — RECOVER demo ("luxury hotels"), FIRST attempt (no marker) → invalid. + mockServer.addFixture({ + match: { + predicate: (req: any) => + hasTool(req, "render_a2ui") && isRecover(allText(req.messages)) && !allText(req.messages).includes(RETRY_MARKER), + }, + response: { toolCalls: [{ name: "render_a2ui", arguments: renderArgs(false) }] }, + }); +} diff --git a/apps/dojo/e2e/aimock-setup.ts b/apps/dojo/e2e/aimock-setup.ts index bf4945f607..4812433017 100644 --- a/apps/dojo/e2e/aimock-setup.ts +++ b/apps/dojo/e2e/aimock-setup.ts @@ -1,5 +1,7 @@ import { LLMock, type ChatMessage } from "@copilotkit/aimock"; import * as path from "node:path"; +import { registerA2UIRecoveryFixtures } from "./a2ui-recovery-fixtures"; +import { registerA2UIADKFixtures } from "./a2ui-adk-fixtures"; const MOCK_PORT = 5555; const FIXTURES_DIR = path.join(import.meta.dirname, "fixtures", "openai"); @@ -12,7 +14,22 @@ export async function setupLLMock(): Promise { // Small per-chunk latency prevents crew-ai's asyncio event loop from // getting congested by zero-latency streaming (real OpenAI has natural // network delays between chunks; LLMock needs to simulate this). - mockServer = new LLMock({ port: MOCK_PORT, latency: 5 }); + // Default 5ms keeps crew-ai's asyncio loop healthy. Bump via AIMOCK_LATENCY (e.g. 1500) + // when running the standalone mock (aimock-standalone.ts) for an interactive recording, + // so the retrying→hard-failure sequence is watchable. + mockServer = new LLMock({ + port: MOCK_PORT, + latency: Number(process.env.AIMOCK_LATENCY) || 5, + }); + + // OSS-158 ADK A2UI fixtures (Gemini-shaped, scoped to gemini models). MUST + // precede the OpenAI LangGraph recovery fixtures so a Gemini request matches + // here first; gpt-4o requests fall through to the LangGraph fixtures. + registerA2UIADKFixtures(mockServer); + + // OSS-162 A2UI recovery showcase fixtures (predicate fixtures, must precede + // the generic loadFixtureFile below). + registerA2UIRecoveryFixtures(mockServer); // Extract text from message content — handles both string and array-of-parts // (Strands SDK sends content as [{type: "text", text: "..."}]) diff --git a/apps/dojo/e2e/aimock-standalone.ts b/apps/dojo/e2e/aimock-standalone.ts new file mode 100644 index 0000000000..97a49fa6eb --- /dev/null +++ b/apps/dojo/e2e/aimock-standalone.ts @@ -0,0 +1,48 @@ +// Standalone aimock runner (OSS-162) — boots the SAME LLMock + fixtures the e2e uses +// (apps/dojo/e2e/aimock-setup.ts), so you can INTERACTIVELY demo / record the A2UI +// recovery + hard-failure flow in the browser instead of only via Playwright. +// +// Usage (from apps/dojo/e2e): +// npx tsx aimock-standalone.ts # default 5ms/attempt (fast) +// AIMOCK_LATENCY=1500 npx tsx aimock-standalone.ts # slow, watchable for recording +// +// Then, in two more terminals: +// (agent) cd integrations/langgraph/typescript/examples +// OPENAI_BASE_URL=http://localhost:5555/v1 pnpm dev +// (dojo) cd apps/dojo && PORT=3002 npm run dev +// +// Open the dojo → A2UI Error Recovery feature. The suggestion pills map to fixtures: +// "Compare 3 luxury hotels…" -> invalid first attempt, recovers to a valid surface +// "Compare 3 broken hotels…" -> every attempt invalid -> exhaustion -> hard-failure panel +import { setupLLMock, teardownLLMock } from "./aimock-setup"; + +async function main() { + await setupLLMock(); + const url = process.env.LLMOCK_URL ?? "http://localhost:5555/v1"; + const latency = Number(process.env.AIMOCK_LATENCY) || 5; + console.log( + `\n✅ aimock is running (interactive mode).\n` + + ` Point the agent at: OPENAI_BASE_URL=${url}\n` + + ` Latency/attempt: ${latency}ms (set AIMOCK_LATENCY=1500 to slow it for recording)\n` + + ` Stop with Ctrl-C.\n`, + ); +} + +const shutdown = async () => { + try { + await teardownLLMock(); + } catch { + // ignore + } + process.exit(0); +}; +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +main().catch((err) => { + console.error("Failed to start aimock:", err); + process.exit(1); +}); + +// Keep the process alive until Ctrl-C. +setInterval(() => {}, 1 << 30); diff --git a/apps/dojo/e2e/featurePages/AgenticChatPage.ts b/apps/dojo/e2e/featurePages/AgenticChatPage.ts index 6d7a4c0625..65e49d9828 100644 --- a/apps/dojo/e2e/featurePages/AgenticChatPage.ts +++ b/apps/dojo/e2e/featurePages/AgenticChatPage.ts @@ -1,6 +1,6 @@ import { Page, Locator, expect } from "@playwright/test"; import { CopilotSelectors } from "../utils/copilot-selectors"; -import { sendChatMessage, awaitLLMResponseDone } from "../utils/copilot-actions"; +import { sendAndAwaitResponse } from "../utils/copilot-actions"; import { DEFAULT_WELCOME_MESSAGE } from "../lib/constants"; export class AgenticChatPage { @@ -32,8 +32,16 @@ export class AgenticChatPage { } async sendMessage(message: string) { - await sendChatMessage(this.page, message); - await awaitLLMResponseDone(this.page); + // Use the multi-turn-safe send. The previous `awaitLLMResponseDone` + // returned as soon as it saw `data-copilot-running="false"`, but on a + // multi-turn conversation that attribute still holds the PREVIOUS turn's + // finished state — so the wait could return before the new run started. + // The next send would then fire while the prior run was still active, the + // agent dropped it, and the user message never rendered (flaky timeout). + // `sendAndAwaitResponse` snapshots the assistant-message count and waits + // for a NEW response before treating the run as done, so each turn fully + // completes before the next send. + await sendAndAwaitResponse(this.page, message); } async getGradientButtonByName(name: string | RegExp) { diff --git a/apps/dojo/e2e/tests/adkMiddlewareTests/a2uiDynamicSchema.spec.ts b/apps/dojo/e2e/tests/adkMiddlewareTests/a2uiDynamicSchema.spec.ts new file mode 100644 index 0000000000..60d0a162d9 --- /dev/null +++ b/apps/dojo/e2e/tests/adkMiddlewareTests/a2uiDynamicSchema.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// OSS-158 — Google ADK A2UI dynamic schema. The aimock fixtures +// (apps/dojo/e2e/a2ui-adk-fixtures.ts) emulate a real Gemini sub-agent under the +// free-form tool schema: render_a2ui returns components/data as JSON strings, +// which the ADK adapter parses back before validation/emission. + +test("[Google ADK] A2UI Dynamic Schema renders hotel comparison surface", async ({ + page, +}) => { + await page.goto("/adk-middleware/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and a star rating.", + ); + + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll([ + "The Ritz", + "Holiday Inn", + "Boutique Loft", + "$450/night", + "$180/night", + "$320/night", + ]); + + // HotelCard renders the numeric rating value. + const surface = a2ui.surface("hotel-comparison"); + await expect(surface.getByText("4.8").first()).toBeVisible(); +}); diff --git a/apps/dojo/e2e/tests/adkMiddlewareTests/a2uiFixedSchema.spec.ts b/apps/dojo/e2e/tests/adkMiddlewareTests/a2uiFixedSchema.spec.ts new file mode 100644 index 0000000000..177031d271 --- /dev/null +++ b/apps/dojo/e2e/tests/adkMiddlewareTests/a2uiFixedSchema.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// OSS-158 — Google ADK A2UI fixed schema. The agent exposes two plain backend +// tools (search_flights / search_hotels); the LLM supplies the row data and the +// tool returns a fixed-layout a2ui_operations envelope. The aimock fixtures +// (apps/dojo/e2e/a2ui-adk-fixtures.ts) are Gemini-scoped so they never collide +// with the LangGraph (gpt-4o) fixed-schema fixtures. + +test("[Google ADK] A2UI Fixed Schema renders flight search surface", async ({ + page, +}) => { + await page.goto("/adk-middleware/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Find flights from SFO to JFK for next Tuesday."); + + await a2ui.assertUserMessageVisible("Find flights from SFO to JFK"); + await a2ui.assertSurfaceWithIdVisible("flight-search-results"); + // Flight data is bound via the fixed schema template — assert key data fields. + await a2ui.assertSurfaceContainsAll(["UA 123", "DL 456", "$289", "$315"]); +}); + +test("[Google ADK] A2UI Fixed Schema renders hotel search with StarRating", async ({ + page, +}) => { + await page.goto("/adk-middleware/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Find hotels in downtown Manhattan for next weekend."); + + await a2ui.assertUserMessageVisible("Find hotels in downtown Manhattan"); + await a2ui.assertSurfaceWithIdVisible("hotel-search-results"); + await a2ui.assertSurfaceContainsAll([ + "The Manhattan Grand", + "Downtown Boutique Hotel", + ]); + + // Verify StarRating custom component rendered (numeric rating value). + const surface = a2ui.surface("hotel-search-results"); + await expect(surface.getByText("4.5").first()).toBeVisible(); +}); + +test("[Google ADK] A2UI Fixed Schema renders multiple surfaces in sequence", async ({ + page, +}) => { + await page.goto("/adk-middleware/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + + // First surface: flights + await a2ui.sendMessage("Find flights from SFO to JFK."); + await a2ui.assertSurfaceWithIdVisible("flight-search-results"); + + // Second surface: hotels + await a2ui.sendMessage("Find hotels in downtown Manhattan."); + await a2ui.assertSurfaceWithIdVisible("hotel-search-results"); + + // Both surfaces should be present + const count = await a2ui.getSurfaceCount(); + expect(count).toBeGreaterThanOrEqual(2); +}); diff --git a/apps/dojo/e2e/tests/adkMiddlewareTests/a2uiRecovery.spec.ts b/apps/dojo/e2e/tests/adkMiddlewareTests/a2uiRecovery.spec.ts new file mode 100644 index 0000000000..8e5ce964ce --- /dev/null +++ b/apps/dojo/e2e/tests/adkMiddlewareTests/a2uiRecovery.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// OSS-158 — Google ADK A2UI error-recovery. The aimock fixtures +// (apps/dojo/e2e/a2ui-adk-fixtures.ts) drive the Gemini sub-agent's render_a2ui +// (Gemini-shaped, free-form JSON-string args): the first "luxury" attempt is a +// Row whose repeated child references a `card` template the model "forgot" +// (structural "unresolved child"); the loop feeds the error back and the second +// attempt is valid. "broken" always fails → exhaustion. + +test("[Google ADK] A2UI recovery — invalid render recovers to a valid surface", async ({ + page, +}) => { + await page.goto("/adk-middleware/feature/a2ui_recovery"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Compare 3 luxury hotels with ratings and prices."); + + // Faulty first attempt is suppressed (no wipe); the regenerated valid surface paints. + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll(["The Ritz", "Holiday Inn", "Boutique Loft"]); +}); + +test("[Google ADK] A2UI recovery — exhaustion never paints a faulty surface, chat stays usable", async ({ + page, +}) => { + await page.goto("/adk-middleware/feature/a2ui_recovery"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Compare 3 broken hotels with ratings and prices."); + + // Every attempt invalid → no faulty surface ever paints (server-side no-wipe + // guarantee: middleware gate + adapter recovery loop). + await expect(a2ui.surface("hotel-comparison")).toHaveCount(0); + + // Conversation remains usable after the hard failure. + await a2ui.sendMessage("Thanks anyway."); +}); + +test("[Google ADK] A2UI recovery — exhaustion shows the hard-failure UI", async ({ + page, +}) => { + await page.goto("/adk-middleware/feature/a2ui_recovery"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Compare 3 broken hotels with ratings and prices."); + + await expect(a2ui.surface("hotel-comparison")).toHaveCount(0); + await expect( + page.getByText("Couldn't generate the UI").first(), + ).toBeVisible({ timeout: 30_000 }); + + await a2ui.sendMessage("Thanks anyway."); +}); diff --git a/apps/dojo/e2e/tests/awsStrandsTests/a2uiDynamicSchema.spec.ts b/apps/dojo/e2e/tests/awsStrandsTests/a2uiDynamicSchema.spec.ts new file mode 100644 index 0000000000..2c74e634a4 --- /dev/null +++ b/apps/dojo/e2e/tests/awsStrandsTests/a2uiDynamicSchema.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// A2UI dynamic-schema showcase — AWS Strands (Python) port. +// +// Rides the SAME framework-agnostic aimock dynamic-schema fixtures as the +// LangGraph spec (apps/dojo/e2e/aimock-setup.ts) — they match on the +// generate_a2ui / render_a2ui tools + hotel/product/team keywords, not on the +// integration. The Strands demo agent is a plain Strands agent +// with NO a2ui wiring; the runtime sends `injectA2UITool` and the +// ag_ui_strands adapter auto-injects `generate_a2ui`. + +test("[AWS Strands] A2UI Dynamic Schema renders hotel comparison surface", async ({ + page, +}) => { + await page.goto("/aws-strands/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and star rating using the StarRating component.", + ); + + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll([ + "The Ritz", + "Holiday Inn", + "Boutique Loft", + "$450/night", + "$180/night", + "$320/night", + ]); + + const surface = a2ui.surface("hotel-comparison"); + await expect(surface.getByText("4.8").first()).toBeVisible(); +}); + +test("[AWS Strands] A2UI Dynamic Schema renders product comparison surface", async ({ + page, +}) => { + await page.goto("/aws-strands/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a product comparison of 3 headphones with name, price, rating, a short description, and a Select button on each card.", + ); + + await a2ui.assertSurfaceWithIdVisible("product-comparison"); + await a2ui.assertSurfaceContainsAll([ + "Sony WH-1000XM5", + "AirPods Max", + "Bose QC Ultra", + "$349", + "$549", + "$429", + ]); +}); + +test("[AWS Strands] A2UI Dynamic Schema renders team roster surface", async ({ + page, +}) => { + await page.goto("/aws-strands/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a team roster with 4 people showing name, role, avatar, and email.", + ); + + await a2ui.assertSurfaceWithIdVisible("team-roster"); + await a2ui.assertSurfaceContainsAll([ + "Alice Chen", + "Bob Martinez", + "Carol Davis", + "Dan Wilson", + "Engineering Lead", + "Product Designer", + ]); +}); diff --git a/apps/dojo/e2e/tests/awsStrandsTests/a2uiRecovery.spec.ts b/apps/dojo/e2e/tests/awsStrandsTests/a2uiRecovery.spec.ts new file mode 100644 index 0000000000..a2a7a3dbbc --- /dev/null +++ b/apps/dojo/e2e/tests/awsStrandsTests/a2uiRecovery.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// A2UI error-recovery showcase — AWS Strands (Python) port. +// +// Same behavior bar as the LangGraph TS recovery spec, driven by the SAME +// framework-agnostic aimock fixtures (apps/dojo/e2e/a2ui-recovery-fixtures.ts): +// the sub-agent's first render_a2ui is a Row whose repeated child references a +// `card` template the model "forgot" to include (structural "unresolved +// child"); the toolkit feeds the error back and the second attempt is valid. +// +// DevEx under test: the Strands dojo agent is a plain Strands agent with +// NO a2ui tool wiring. The CopilotKit runtime sends +// `injectA2UITool`, and the ag_ui_strands adapter infers the model from +// the wrapped agent and auto-injects `generate_a2ui` — no get_a2ui_tools() +// call in the example server. + +test("[AWS Strands] A2UI recovery — invalid render recovers to a valid surface", async ({ + page, +}) => { + await page.goto("/aws-strands/feature/a2ui_recovery"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Compare 3 luxury hotels with ratings and prices."); + + // The faulty first attempt is suppressed (no wipe); the regenerated valid + // surface paints. + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll(["The Ritz", "Holiday Inn", "Boutique Loft"]); +}); + +test("[AWS Strands] A2UI recovery — exhaustion: hard-failure UI, no faulty paint, chat stays usable", async ({ + page, +}) => { + await page.goto("/aws-strands/feature/a2ui_recovery"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Compare 3 broken hotels with ratings and prices."); + + // Anchor on the run's terminal signal FIRST — asserting count-0 right after + // send would pass trivially before the agent produced anything. The + // tasteful hard-failure message rides the same renderer path as the + // LangGraph recovery demo (the recovery activity is produced by the shared + // @ag-ui/a2ui-middleware, regardless of backend framework). Target the + // title specifically to avoid Playwright strict-mode matching the + // "Something went wrong…" subtitle as well. + await expect( + page.getByText("Couldn't generate the UI").first(), + ).toBeVisible({ timeout: 30_000 }); + + // Every attempt is invalid → no faulty surface ever paints. The no-wipe + // invariant holds even under total exhaustion. This is the server-side + // guarantee (middleware gate + adapter loop) and is independent of the + // client renderer. + await expect(a2ui.surface("hotel-comparison")).toHaveCount(0); + + // Conversation remains usable after the hard failure: the follow-up turn is + // accepted and rendered (not swallowed by a stuck/broken stream). + await a2ui.sendMessage("Thanks anyway."); + await a2ui.assertUserMessageVisible("Thanks anyway."); +}); diff --git a/apps/dojo/e2e/tests/awsStrandsTests/a2uiStreaming.spec.ts b/apps/dojo/e2e/tests/awsStrandsTests/a2uiStreaming.spec.ts new file mode 100644 index 0000000000..7503c0184a --- /dev/null +++ b/apps/dojo/e2e/tests/awsStrandsTests/a2uiStreaming.spec.ts @@ -0,0 +1,95 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// A2UI progressive-streaming regression net (AWS Strands Python). +// +// The visible symptom this guards: surfaces must paint progressively (cards +// appearing one by one) instead of in one bulk paint after a long wait. The +// load-bearing mechanism is on the wire — the sub-agent's render_a2ui call +// must stream MANY incremental TOOL_CALL_ARGS deltas (aimock chunks tool-call +// arguments, mirroring the OpenAI chat-completions API), and the middleware +// must emit its "building" lifecycle before the surface paints. +// +// Two historical regressions this catches (both shipped green through the +// surface-only specs): +// 1. Sub-agent ran hidden inside the tool (`invoke()`), no inner events on +// the wire at all → 0 render_a2ui frames. +// 2. Demo model used the OpenAI Responses API, whose Strands adapter buffers +// `function_call_arguments.delta` and emits one blob at the end → exactly +// 1 ARGS frame. +// Healthy streaming = many small ARGS frames. Asserting on the COMPLETED +// response body keeps this flake-free (no live timing involved). + +// Shared between the sent message and the SSE-capture predicate so they can't +// silently drift apart (a predicate miss = opaque test-timeout hang). +const HOTEL_PROMPT = + "Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and star rating using the StarRating component."; + +test("[AWS Strands] A2UI streams render_a2ui args incrementally (no bulk paint)", async ({ + page, +}) => { + // Capture the runtime's SSE body for the chat run. + const ssePromise = new Promise((resolve, reject) => { + page.on("response", async (response) => { + if ( + // Boundary match (not includes): "/api/copilotkit/aws-strands" is a + // prefix of the TS integration's ".../aws-strands-typescript" path, + // while a future trailing slash / sub-path must still match. + /\/api\/copilotkit\/aws-strands(\/|$)/.test( + new URL(response.url()).pathname, + ) && + response.request().method() === "POST" && + (response.headers()["content-type"] ?? "").includes("text/event-stream") && + // Scope to THIS test's chat run — other SSE runs (e.g. suggestion + // generation) can hit the same endpoint first in batch runs. + (response.request().postData() ?? "").includes(HOTEL_PROMPT) + ) { + try { + resolve(await response.text()); + } catch (e) { + reject(e); + } + } + }); + }); + + await page.goto("/aws-strands/feature/a2ui_dynamic_schema"); + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage(HOTEL_PROMPT); + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + + const sse = await ssePromise; + + // The inner render_a2ui call started on the wire… + const startMatches = sse.match( + /"type":"TOOL_CALL_START"[^\n]*"toolCallName":"render_a2ui"[^\n]*/g, + ); + expect( + startMatches, + "inner render_a2ui TOOL_CALL_START must reach the wire (sub-agent streaming)", + ).not.toBeNull(); + + // …and its args arrived as MANY incremental deltas, not one blob. The + // hotel-comparison envelope is ~700 chars; aimock chunks it into well over + // 3 frames. 1 frame = provider buffering; 0 = sub-agent not streamed. + const renderStart = startMatches![0]; + const renderCallId = renderStart.match(/"toolCallId":"([^"]+)"/)?.[1]; + expect(renderCallId).toBeTruthy(); + // The id comes off the wire — escape it before regex interpolation. + const renderCallIdRe = renderCallId!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const argFrames = sse.match( + new RegExp(`"type":"TOOL_CALL_ARGS"[^\\n]*"toolCallId":"${renderCallIdRe}"`, "g"), + ); + expect( + argFrames?.length ?? 0, + "render_a2ui args must stream as multiple incremental deltas", + ).toBeGreaterThanOrEqual(3); + + // The middleware's pre-paint lifecycle fired (the "Building interface" + // skeleton's data source) before the surface painted. + expect( + sse.includes('"status":"building"') || sse.includes('\\"status\\":\\"building\\"'), + "middleware must emit the building lifecycle on the wire", + ).toBe(true); +}); diff --git a/apps/dojo/e2e/tests/awsStrandsTypescriptTests/a2uiDynamicSchema.spec.ts b/apps/dojo/e2e/tests/awsStrandsTypescriptTests/a2uiDynamicSchema.spec.ts new file mode 100644 index 0000000000..55368b0a5f --- /dev/null +++ b/apps/dojo/e2e/tests/awsStrandsTypescriptTests/a2uiDynamicSchema.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// A2UI dynamic-schema showcase — AWS Strands (TypeScript) port. +// +// Rides the SAME framework-agnostic aimock dynamic-schema fixtures as the +// LangGraph spec (apps/dojo/e2e/aimock-setup.ts) — they match on the +// generate_a2ui / render_a2ui tools + hotel/product/team keywords, not on the +// integration. The Strands demo agent is a plain Strands agent +// with NO a2ui wiring; the runtime sends `injectA2UITool` and the +// @ag-ui/aws-strands adapter auto-injects `generate_a2ui`. + +test("[AWS Strands TS] A2UI Dynamic Schema renders hotel comparison surface", async ({ + page, +}) => { + await page.goto("/aws-strands-typescript/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and star rating using the StarRating component.", + ); + + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll([ + "The Ritz", + "Holiday Inn", + "Boutique Loft", + "$450/night", + "$180/night", + "$320/night", + ]); + + const surface = a2ui.surface("hotel-comparison"); + await expect(surface.getByText("4.8").first()).toBeVisible(); +}); + +test("[AWS Strands TS] A2UI Dynamic Schema renders product comparison surface", async ({ + page, +}) => { + await page.goto("/aws-strands-typescript/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a product comparison of 3 headphones with name, price, rating, a short description, and a Select button on each card.", + ); + + await a2ui.assertSurfaceWithIdVisible("product-comparison"); + await a2ui.assertSurfaceContainsAll([ + "Sony WH-1000XM5", + "AirPods Max", + "Bose QC Ultra", + "$349", + "$549", + "$429", + ]); +}); + +test("[AWS Strands TS] A2UI Dynamic Schema renders team roster surface", async ({ + page, +}) => { + await page.goto("/aws-strands-typescript/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a team roster with 4 people showing name, role, avatar, and email.", + ); + + await a2ui.assertSurfaceWithIdVisible("team-roster"); + await a2ui.assertSurfaceContainsAll([ + "Alice Chen", + "Bob Martinez", + "Carol Davis", + "Dan Wilson", + "Engineering Lead", + "Product Designer", + ]); +}); diff --git a/apps/dojo/e2e/tests/awsStrandsTypescriptTests/a2uiRecovery.spec.ts b/apps/dojo/e2e/tests/awsStrandsTypescriptTests/a2uiRecovery.spec.ts new file mode 100644 index 0000000000..3556cab9da --- /dev/null +++ b/apps/dojo/e2e/tests/awsStrandsTypescriptTests/a2uiRecovery.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// A2UI error-recovery showcase — AWS Strands (TypeScript) port. +// +// Same behavior bar as the LangGraph TS recovery spec, driven by the SAME +// framework-agnostic aimock fixtures (apps/dojo/e2e/a2ui-recovery-fixtures.ts): +// the sub-agent's first render_a2ui is a Row whose repeated child references a +// `card` template the model "forgot" to include (structural "unresolved +// child"); the toolkit feeds the error back and the second attempt is valid. +// +// DevEx under test: the Strands dojo agent is a plain Strands agent with +// NO a2ui tool wiring. The CopilotKit runtime sends +// `injectA2UITool`, and the @ag-ui/aws-strands adapter infers the model from +// the wrapped agent and auto-injects `generate_a2ui` — no getA2UITools() call +// in the example server. + +test("[AWS Strands TS] A2UI recovery — invalid render recovers to a valid surface", async ({ + page, +}) => { + await page.goto("/aws-strands-typescript/feature/a2ui_recovery"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Compare 3 luxury hotels with ratings and prices."); + + // The faulty first attempt is suppressed (no wipe); the regenerated valid + // surface paints. + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll(["The Ritz", "Holiday Inn", "Boutique Loft"]); +}); + +test("[AWS Strands TS] A2UI recovery — exhaustion: hard-failure UI, no faulty paint, chat stays usable", async ({ + page, +}) => { + await page.goto("/aws-strands-typescript/feature/a2ui_recovery"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Compare 3 broken hotels with ratings and prices."); + + // Anchor on the run's terminal signal FIRST — asserting count-0 right after + // send would pass trivially before the agent produced anything. The + // tasteful hard-failure message rides the same renderer path as the + // LangGraph recovery demo (the recovery activity is produced by the shared + // @ag-ui/a2ui-middleware, regardless of backend framework). Target the + // title specifically to avoid Playwright strict-mode matching the + // "Something went wrong…" subtitle as well. + await expect( + page.getByText("Couldn't generate the UI").first(), + ).toBeVisible({ timeout: 30_000 }); + + // Every attempt is invalid → no faulty surface ever paints. The no-wipe + // invariant holds even under total exhaustion. This is the server-side + // guarantee (middleware gate + adapter loop) and is independent of the + // client renderer. + await expect(a2ui.surface("hotel-comparison")).toHaveCount(0); + + // Conversation remains usable after the hard failure: the follow-up turn is + // accepted and rendered (not swallowed by a stuck/broken stream). + await a2ui.sendMessage("Thanks anyway."); + await a2ui.assertUserMessageVisible("Thanks anyway."); +}); diff --git a/apps/dojo/e2e/tests/awsStrandsTypescriptTests/a2uiStreaming.spec.ts b/apps/dojo/e2e/tests/awsStrandsTypescriptTests/a2uiStreaming.spec.ts new file mode 100644 index 0000000000..e751da7d28 --- /dev/null +++ b/apps/dojo/e2e/tests/awsStrandsTypescriptTests/a2uiStreaming.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// A2UI progressive-streaming regression net (AWS Strands TS). +// +// The visible symptom this guards: surfaces must paint progressively (cards +// appearing one by one) instead of in one bulk paint after a long wait. The +// load-bearing mechanism is on the wire — the sub-agent's render_a2ui call +// must stream MANY incremental TOOL_CALL_ARGS deltas (aimock chunks tool-call +// arguments, mirroring the OpenAI chat-completions API), and the middleware +// must emit its "building" lifecycle before the surface paints. +// +// Two historical regressions this catches (both shipped green through the +// surface-only specs): +// 1. Sub-agent ran hidden inside the tool (`invoke()`), no inner events on +// the wire at all → 0 render_a2ui frames. +// 2. Demo model used the OpenAI Responses API, whose Strands adapter buffers +// `function_call_arguments.delta` and emits one blob at the end → exactly +// 1 ARGS frame. +// Healthy streaming = many small ARGS frames. Asserting on the COMPLETED +// response body keeps this flake-free (no live timing involved). + +// Shared between the sent message and the SSE-capture predicate so they can't +// silently drift apart (a predicate miss = opaque test-timeout hang). +const HOTEL_PROMPT = + "Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and star rating using the StarRating component."; + +test("[AWS Strands TS] A2UI streams render_a2ui args incrementally (no bulk paint)", async ({ + page, +}) => { + // Capture the runtime's SSE body for the chat run. + const ssePromise = new Promise((resolve, reject) => { + page.on("response", async (response) => { + if ( + // Boundary match, symmetric with the Python spec's predicate. + /\/api\/copilotkit\/aws-strands-typescript(\/|$)/.test( + new URL(response.url()).pathname, + ) && + response.request().method() === "POST" && + (response.headers()["content-type"] ?? "").includes("text/event-stream") && + // Scope to THIS test's chat run — other SSE runs (e.g. suggestion + // generation) can hit the same endpoint first in batch runs. + (response.request().postData() ?? "").includes(HOTEL_PROMPT) + ) { + try { + resolve(await response.text()); + } catch (e) { + reject(e); + } + } + }); + }); + + await page.goto("/aws-strands-typescript/feature/a2ui_dynamic_schema"); + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage(HOTEL_PROMPT); + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + + const sse = await ssePromise; + + // The inner render_a2ui call started on the wire… + const startMatches = sse.match( + /"type":"TOOL_CALL_START"[^\n]*"toolCallName":"render_a2ui"[^\n]*/g, + ); + expect( + startMatches, + "inner render_a2ui TOOL_CALL_START must reach the wire (sub-agent streaming)", + ).not.toBeNull(); + + // …and its args arrived as MANY incremental deltas, not one blob. The + // hotel-comparison envelope is ~700 chars; aimock chunks it into well over + // 3 frames. 1 frame = provider buffering; 0 = sub-agent not streamed. + const renderStart = startMatches![0]; + const renderCallId = renderStart.match(/"toolCallId":"([^"]+)"/)?.[1]; + expect(renderCallId).toBeTruthy(); + // The id comes off the wire — escape it before regex interpolation. + const renderCallIdRe = renderCallId!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const argFrames = sse.match( + new RegExp(`"type":"TOOL_CALL_ARGS"[^\\n]*"toolCallId":"${renderCallIdRe}"`, "g"), + ); + expect( + argFrames?.length ?? 0, + "render_a2ui args must stream as multiple incremental deltas", + ).toBeGreaterThanOrEqual(3); + + // The middleware's pre-paint lifecycle fired (the "Building interface" + // skeleton's data source) before the surface painted. + expect( + sse.includes('"status":"building"') || sse.includes('\\"status\\":\\"building\\"'), + "middleware must emit the building lifecycle on the wire", + ).toBe(true); +}); diff --git a/apps/dojo/e2e/tests/langgraphPythonTests/a2uiAdvanced.spec.ts b/apps/dojo/e2e/tests/langgraphPythonTests/a2uiAdvanced.spec.ts new file mode 100644 index 0000000000..8f461919a3 --- /dev/null +++ b/apps/dojo/e2e/tests/langgraphPythonTests/a2uiAdvanced.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +test("[LangGraph Python] A2UI Advanced renders surface with hotel comparison", async ({ + page, +}) => { + await page.goto("/langgraph/feature/a2ui_advanced"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and star rating using the StarRating component.", + ); + + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll([ + "The Ritz", + "Holiday Inn", + "Boutique Loft", + ]); +}); + +test("[LangGraph Python] A2UI Advanced renders team directory surface", async ({ + page, +}) => { + await page.goto("/langgraph/feature/a2ui_advanced"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a team directory with 4 people showing name, role, department, and a Contact button.", + ); + + await a2ui.assertSurfaceWithIdVisible("team-roster"); + await a2ui.assertSurfaceContainsAll([ + "Alice Chen", + "Bob Martinez", + "Carol Davis", + "Dan Wilson", + ]); +}); diff --git a/apps/dojo/e2e/tests/langgraphPythonTests/a2uiDynamicSchema.spec.ts b/apps/dojo/e2e/tests/langgraphPythonTests/a2uiDynamicSchema.spec.ts new file mode 100644 index 0000000000..e02f18e49a --- /dev/null +++ b/apps/dojo/e2e/tests/langgraphPythonTests/a2uiDynamicSchema.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +test("[LangGraph Python] A2UI Dynamic Schema renders hotel comparison surface", async ({ + page, +}) => { + await page.goto("/langgraph/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and star rating using the StarRating component.", + ); + + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll([ + "The Ritz", + "Holiday Inn", + "Boutique Loft", + "$450/night", + "$180/night", + "$320/night", + ]); + + // Verify star ratings rendered (HotelCard renders numeric rating values) + const surface = a2ui.surface("hotel-comparison"); + await expect(surface.getByText("4.8").first()).toBeVisible(); +}); + +test("[LangGraph Python] A2UI Dynamic Schema renders product comparison surface", async ({ + page, +}) => { + await page.goto("/langgraph/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a product comparison of 3 headphones with name, price, rating, a short description, and a Select button on each card.", + ); + + await a2ui.assertSurfaceWithIdVisible("product-comparison"); + await a2ui.assertSurfaceContainsAll([ + "Sony WH-1000XM5", + "AirPods Max", + "Bose QC Ultra", + "$349", + "$549", + "$429", + ]); +}); + +test("[LangGraph Python] A2UI Dynamic Schema renders team roster surface", async ({ + page, +}) => { + await page.goto("/langgraph/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a team roster with 4 people showing name, role, avatar, and email.", + ); + + await a2ui.assertSurfaceWithIdVisible("team-roster"); + await a2ui.assertSurfaceContainsAll([ + "Alice Chen", + "Bob Martinez", + "Carol Davis", + "Dan Wilson", + "Engineering Lead", + "Product Designer", + ]); +}); diff --git a/apps/dojo/e2e/tests/langgraphPythonTests/a2uiFixedSchema.spec.ts b/apps/dojo/e2e/tests/langgraphPythonTests/a2uiFixedSchema.spec.ts new file mode 100644 index 0000000000..75c640c365 --- /dev/null +++ b/apps/dojo/e2e/tests/langgraphPythonTests/a2uiFixedSchema.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +test("[LangGraph Python] A2UI Fixed Schema renders flight search surface", async ({ + page, +}) => { + await page.goto("/langgraph/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Find flights from SFO to JFK for next Tuesday."); + + await a2ui.assertUserMessageVisible("Find flights from SFO to JFK"); + await a2ui.assertSurfaceWithIdVisible("flight-search-results"); + // Flight data is bound via the schema template — assert key data fields + await a2ui.assertSurfaceContainsAll(["UA 123", "DL 456", "$289", "$315"]); +}); + +test("[LangGraph Python] A2UI Fixed Schema renders hotel search with StarRating", async ({ + page, +}) => { + await page.goto("/langgraph/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Find hotels in downtown Manhattan for next weekend."); + + await a2ui.assertUserMessageVisible("Find hotels in downtown Manhattan"); + await a2ui.assertSurfaceWithIdVisible("hotel-search-results"); + await a2ui.assertSurfaceContainsAll([ + "The Manhattan Grand", + "Downtown Boutique Hotel", + ]); + + // Verify StarRating custom component rendered (numeric rating value) + const surface = a2ui.surface("hotel-search-results"); + await expect(surface.getByText("4.5").first()).toBeVisible(); +}); + +test("[LangGraph Python] A2UI Fixed Schema renders multiple surfaces in sequence", async ({ + page, +}) => { + await page.goto("/langgraph/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + + // First surface: flights + await a2ui.sendMessage("Find flights from SFO to JFK."); + await a2ui.assertSurfaceWithIdVisible("flight-search-results"); + + // Second surface: hotels + await a2ui.sendMessage("Find hotels in downtown Manhattan."); + await a2ui.assertSurfaceWithIdVisible("hotel-search-results"); + + // Both surfaces should be present + const count = await a2ui.getSurfaceCount(); + expect(count).toBeGreaterThanOrEqual(2); +}); diff --git a/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiAdvanced.spec.ts b/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiAdvanced.spec.ts new file mode 100644 index 0000000000..0ad5721d0b --- /dev/null +++ b/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiAdvanced.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +test("[LangGraph TypeScript] A2UI Advanced renders surface with hotel comparison", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_advanced"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and star rating using the StarRating component.", + ); + + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll([ + "The Ritz", + "Holiday Inn", + "Boutique Loft", + ]); +}); + +test("[LangGraph TypeScript] A2UI Advanced renders team directory surface", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_advanced"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a team directory with 4 people showing name, role, department, and a Contact button.", + ); + + await a2ui.assertSurfaceWithIdVisible("team-roster"); + await a2ui.assertSurfaceContainsAll([ + "Alice Chen", + "Bob Martinez", + "Carol Davis", + "Dan Wilson", + ]); +}); diff --git a/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiDynamicSchema.spec.ts b/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiDynamicSchema.spec.ts new file mode 100644 index 0000000000..0951fc1887 --- /dev/null +++ b/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiDynamicSchema.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +test("[LangGraph TypeScript] A2UI Dynamic Schema renders hotel comparison surface", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and star rating using the StarRating component.", + ); + + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll([ + "The Ritz", + "Holiday Inn", + "Boutique Loft", + "$450/night", + "$180/night", + "$320/night", + ]); + + // Verify star ratings rendered (HotelCard renders numeric rating values) + const surface = a2ui.surface("hotel-comparison"); + await expect(surface.getByText("4.8").first()).toBeVisible(); +}); + +test("[LangGraph TypeScript] A2UI Dynamic Schema renders product comparison surface", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a product comparison of 3 headphones with name, price, rating, a short description, and a Select button on each card.", + ); + + await a2ui.assertSurfaceWithIdVisible("product-comparison"); + await a2ui.assertSurfaceContainsAll([ + "Sony WH-1000XM5", + "AirPods Max", + "Bose QC Ultra", + "$349", + "$549", + "$429", + ]); +}); + +test("[LangGraph TypeScript] A2UI Dynamic Schema renders team roster surface", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_dynamic_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage( + "Use the generate_a2ui tool to create a team roster with 4 people showing name, role, avatar, and email.", + ); + + await a2ui.assertSurfaceWithIdVisible("team-roster"); + await a2ui.assertSurfaceContainsAll([ + "Alice Chen", + "Bob Martinez", + "Carol Davis", + "Dan Wilson", + "Engineering Lead", + "Product Designer", + ]); +}); diff --git a/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiFixedSchema.spec.ts b/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiFixedSchema.spec.ts new file mode 100644 index 0000000000..57d088eb0f --- /dev/null +++ b/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiFixedSchema.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +test("[LangGraph TypeScript] A2UI Fixed Schema renders flight search surface", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Find flights from SFO to JFK for next Tuesday."); + + await a2ui.assertUserMessageVisible("Find flights from SFO to JFK"); + await a2ui.assertSurfaceWithIdVisible("flight-search-results"); + // Flight data is bound via the schema template — assert key data fields + await a2ui.assertSurfaceContainsAll(["UA 123", "DL 456", "$289", "$315"]); +}); + +test("[LangGraph TypeScript] A2UI Fixed Schema renders hotel search with StarRating", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Find hotels in downtown Manhattan for next weekend."); + + await a2ui.assertUserMessageVisible("Find hotels in downtown Manhattan"); + await a2ui.assertSurfaceWithIdVisible("hotel-search-results"); + await a2ui.assertSurfaceContainsAll([ + "The Manhattan Grand", + "Downtown Boutique Hotel", + ]); + + // Verify StarRating custom component rendered (numeric rating value) + const surface = a2ui.surface("hotel-search-results"); + await expect(surface.getByText("4.5").first()).toBeVisible(); +}); + +test("[LangGraph TypeScript] A2UI Fixed Schema renders multiple surfaces in sequence", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_fixed_schema"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + + // First surface: flights + await a2ui.sendMessage("Find flights from SFO to JFK."); + await a2ui.assertSurfaceWithIdVisible("flight-search-results"); + + // Second surface: hotels + await a2ui.sendMessage("Find hotels in downtown Manhattan."); + await a2ui.assertSurfaceWithIdVisible("hotel-search-results"); + + // Both surfaces should be present + const count = await a2ui.getSurfaceCount(); + expect(count).toBeGreaterThanOrEqual(2); +}); diff --git a/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiRecovery.spec.ts b/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiRecovery.spec.ts new file mode 100644 index 0000000000..99a7aac756 --- /dev/null +++ b/apps/dojo/e2e/tests/langgraphTypescriptTests/a2uiRecovery.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "../../test-isolation-helper"; +import { A2UIPage } from "../../featurePages/A2UIPage"; + +// OSS-162 A2UI error-recovery showcase. The aimock fixtures +// (apps/dojo/e2e/a2ui-recovery-fixtures.ts) drive the sub-agent's render_a2ui: +// the first attempt is a Row whose repeated child references a `card` template +// the model "forgot" to include (structural "unresolved child"); the loop feeds +// the error back and the second attempt is valid. + +test("[LangGraph TS] A2UI recovery — invalid render recovers to a valid surface", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_recovery"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Compare 3 luxury hotels with ratings and prices."); + + // The faulty first attempt is suppressed (no wipe); the regenerated valid + // surface paints. + await a2ui.assertSurfaceWithIdVisible("hotel-comparison"); + await a2ui.assertSurfaceContainsAll(["The Ritz", "Holiday Inn", "Boutique Loft"]); +}); + +test("[LangGraph TS] A2UI recovery — exhaustion never paints a faulty surface, chat stays usable", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_recovery"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Compare 3 broken hotels with ratings and prices."); + + // Every attempt is invalid → no faulty surface ever paints. The no-wipe invariant + // holds even under total exhaustion. This is the server-side guarantee (middleware + // gate + adapter loop) and is independent of the client renderer. + await expect(a2ui.surface("hotel-comparison")).toHaveCount(0); + + // Conversation remains usable after the hard failure. + await a2ui.sendMessage("Thanks anyway."); +}); + +test("[LangGraph TS] A2UI recovery — exhaustion shows the hard-failure UI", async ({ + page, +}) => { + await page.goto("/langgraph-typescript/feature/a2ui_recovery"); + + const a2ui = new A2UIPage(page); + await a2ui.openChat(); + await a2ui.sendMessage("Compare 3 broken hotels with ratings and prices."); + + // No faulty surface ever paints... + await expect(a2ui.surface("hotel-comparison")).toHaveCount(0); + // ...and the tasteful hard-failure message is shown to the user. The renderer is + // registered by the dojo's a2ui_recovery page via renderActivityMessages (TEMP — see + // recovery-renderer.tsx — until @copilotkit/react-core publishes the built-in). Target + // the title specifically: the panel also has a "Something went wrong…" subtitle, so a + // broad /went wrong/ regex would match two elements and trip Playwright strict mode. + await expect( + page.getByText("Couldn't generate the UI").first(), + ).toBeVisible({ timeout: 30_000 }); + + // Conversation remains usable after the hard failure. + await a2ui.sendMessage("Thanks anyway."); +}); diff --git a/apps/dojo/e2e/utils/copilot-actions.ts b/apps/dojo/e2e/utils/copilot-actions.ts index 27cabeb9aa..8d3f6713e7 100644 --- a/apps/dojo/e2e/utils/copilot-actions.ts +++ b/apps/dojo/e2e/utils/copilot-actions.ts @@ -5,32 +5,58 @@ import { CopilotSelectors } from "./copilot-selectors"; const LLM_RESPONSE_TIMEOUT = 60_000; /** Default timeout for finding a DOM element after response */ const ELEMENT_TIMEOUT = 10_000; +/** Brief window to observe a just-started run before treating it as already done. */ +const RUN_START_TIMEOUT = 2_000; -/** - * Wait for the LLM SSE stream to finish. - * Uses the `data-copilot-running` attribute on the chat container — - * no arbitrary timeouts or loading-indicator polling needed. - */ -export async function awaitLLMResponseDone( +async function waitForNoActiveCopilotRun( + page: Page, + timeout = LLM_RESPONSE_TIMEOUT, +) { + await page.waitForFunction( + () => document.querySelector('[data-copilot-running="true"]') === null, + null, + { timeout }, + ); +} + +async function waitForCurrentCopilotRunToFinish( page: Page, timeout = LLM_RESPONSE_TIMEOUT, ) { - // First wait briefly for the stream to start try { await page.waitForFunction( () => document.querySelector('[data-copilot-running="true"]') !== null, null, - { timeout: 3000 }, + { timeout: Math.min(RUN_START_TIMEOUT, timeout) }, ); } catch { - // May have already started and finished, continue + // The response may have completed before Playwright observed running=true. } - // Then wait for the stream to finish - await page.waitForFunction( - () => document.querySelector('[data-copilot-running="false"]') !== null, - null, - { timeout }, - ); + + await waitForNoActiveCopilotRun(page, timeout); +} + +async function expectSubmittedUserMessage( + page: Page, + userMessageIndex: number, + message: string, +) { + const submittedMessage = + CopilotSelectors.userMessages(page).nth(userMessageIndex); + await expect(submittedMessage).toContainText(message, { + timeout: ELEMENT_TIMEOUT, + }); +} + +/** + * Wait for the LLM SSE stream to finish. + * Uses the `data-copilot-running` attribute on the chat container. + */ +export async function awaitLLMResponseDone( + page: Page, + timeout = LLM_RESPONSE_TIMEOUT, +) { + await waitForCurrentCopilotRunToFinish(page, timeout); } /** @@ -39,12 +65,28 @@ export async function awaitLLMResponseDone( */ export async function sendChatMessage(page: Page, message: string) { const input = CopilotSelectors.chatTextarea(page); + const sendButton = CopilotSelectors.sendButton(page); + const userMessageCountBefore = + await CopilotSelectors.userMessages(page).count(); + await input.click(); await input.fill(message); - const sendButton = CopilotSelectors.sendButton(page); + await expect(input).toHaveValue(message); await expect(sendButton).toBeVisible(); await expect(sendButton).toBeEnabled(); await sendButton.click(); + + try { + await expectSubmittedUserMessage(page, userMessageCountBefore, message); + } catch { + // If the previous run is still closing, the click can be ignored while the + // input keeps the text. Wait for the UI to become idle and submit once. + await waitForNoActiveCopilotRun(page); + await expect(input).toHaveValue(message); + await expect(sendButton).toBeEnabled(); + await sendButton.click(); + await expectSubmittedUserMessage(page, userMessageCountBefore, message); + } } /** @@ -77,13 +119,9 @@ export async function sendAndAwaitResponse( { timeout }, ); - // Now wait for the stream to finish — at this point the running state - // belongs to the current response, not a stale one. - await page.waitForFunction( - () => document.querySelector('[data-copilot-running="false"]') !== null, - null, - { timeout }, - ); + // Now wait for the current run to finish. This helper first gives the UI a + // chance to report running=true, so a stale idle flag cannot end the wait. + await waitForCurrentCopilotRunToFinish(page, timeout); } /** diff --git a/apps/dojo/package.json b/apps/dojo/package.json index 22d4dc7101..3148f43b37 100644 --- a/apps/dojo/package.json +++ b/apps/dojo/package.json @@ -38,12 +38,12 @@ "@ag-ui/watsonx": "workspace:*", "@ai-sdk/openai": "^3.0.36", "@anthropic-ai/claude-agent-sdk": "^0.2.58", - "@copilotkit/a2ui-renderer": "1.55.1", - "@copilotkit/react-core": "1.55.1", - "@copilotkit/react-ui": "1.55.1", - "@copilotkit/runtime": "1.55.1", - "@copilotkit/runtime-client-gql": "1.55.1", - "@copilotkit/shared": "1.55.1", + "@copilotkit/a2ui-renderer": "1.60.1", + "@copilotkit/react-core": "1.60.1", + "@copilotkit/react-ui": "1.60.1", + "@copilotkit/runtime": "1.60.1", + "@copilotkit/runtime-client-gql": "1.60.1", + "@copilotkit/shared": "1.60.1", "@langchain/openai": "1.0.0", "@mastra/client-js": "^1.0.1", "@mastra/core": "^1.0.4", diff --git a/apps/dojo/scripts/local-install.sh b/apps/dojo/scripts/local-install.sh index 58b36e6080..197fbb8918 100755 --- a/apps/dojo/scripts/local-install.sh +++ b/apps/dojo/scripts/local-install.sh @@ -83,6 +83,29 @@ else echo " The middleware changes may not take effect in the CopilotKit runtime." fi +# 5b. Sync the LOCAL @ag-ui/a2ui-toolkit next to the synced middleware (OSS-162). +# The recovery middleware imports @ag-ui/a2ui-toolkit, but CopilotKit's tree has +# no copy of it, so the synced middleware above would fail to resolve it +# ("Module not found: Can't resolve '@ag-ui/a2ui-toolkit'"). The toolkit has zero +# runtime deps, so dropping its package.json + dist into the middleware's pnpm +# peer-dir (the @ag-ui namespace dir) is enough for resolution. +echo "" +echo "=== Syncing a2ui-toolkit into CopilotKit pnpm store (OSS-162) ===" +TOOLKIT_SOURCE="$AGUI_ROOT/sdks/typescript/packages/a2ui-toolkit" +if [ -n "$MIDDLEWARE_TARGET" ] && [ -d "$TOOLKIT_SOURCE/dist" ]; then + # MIDDLEWARE_TARGET = .../node_modules/@ag-ui/a2ui-middleware/dist + AGUI_NS="$(dirname "$(dirname "$MIDDLEWARE_TARGET")")" # -> .../node_modules/@ag-ui + TOOLKIT_TARGET="$AGUI_NS/a2ui-toolkit" + rm -rf "$TOOLKIT_TARGET" + mkdir -p "$TOOLKIT_TARGET" + cp "$TOOLKIT_SOURCE/package.json" "$TOOLKIT_TARGET/" + cp -R "$TOOLKIT_SOURCE/dist" "$TOOLKIT_TARGET/dist" + echo " Placed a2ui-toolkit at $TOOLKIT_TARGET" +else + echo " WARNING: could not place a2ui-toolkit (missing middleware target or toolkit dist)." + echo " Build it first: pnpm --filter @ag-ui/a2ui-toolkit build" +fi + # 6. Install local CopilotKit Python SDK for langgraph agent LANGGRAPH_EXAMPLES="$AGUI_ROOT/integrations/langgraph/python/examples" if [ -d "$LANGGRAPH_EXAMPLES" ] && [ -d "$COPILOTKIT_ROOT/sdk-python" ]; then diff --git a/apps/dojo/scripts/run-dojo-everything.js b/apps/dojo/scripts/run-dojo-everything.js index 92f2eadfab..3c2c660090 100755 --- a/apps/dojo/scripts/run-dojo-everything.js +++ b/apps/dojo/scripts/run-dojo-everything.js @@ -4,9 +4,11 @@ const { execSync } = require("child_process"); const path = require("path"); const concurrently = require("concurrently"); -// Pinned: @langchain/langgraph-api@1.1.14 regressed schema extraction, causing -// worker timeouts on CI runners. Re-evaluate when a newer version fixes the issue. -const LANGGRAPH_CLI_VERSION = "1.1.13"; +// 1.2.3: the in-memory dev server provisions persistence itself, so graphs no +// longer need to compile their own checkpointer for threads.getState (1.1.13 +// 500'd with "No checkpointer set" once the compiled MemorySaver was removed). +// Supersedes the old 1.1.13 pin that dodged the 1.1.14 schema-extraction regression. +const LANGGRAPH_CLI_VERSION = "1.2.3"; // Parse command line arguments const args = process.argv.slice(2); diff --git a/apps/dojo/src/agents.ts b/apps/dojo/src/agents.ts index 8374022335..63d31262e0 100644 --- a/apps/dojo/src/agents.ts +++ b/apps/dojo/src/agents.ts @@ -24,7 +24,8 @@ import { A2AMiddlewareAgent } from "@ag-ui/a2a-middleware"; import { AWSStrandsAgent } from "@ag-ui/aws-strands"; import { A2AAgent } from "@ag-ui/a2a"; import { A2AClient } from "@a2a-js/sdk/client"; -import { LangChainAgent } from "@ag-ui/langchain"; +// TODO: fix this — re-enable when langchain dojo agent is restored (see below) +// import { LangChainAgent } from "@ag-ui/langchain"; import { Ag2Agent } from "@ag-ui/ag2"; import { LangroidHttpAgent } from "@ag-ui/langroid"; import { WatsonxAgent } from "@ag-ui/watsonx"; @@ -32,6 +33,19 @@ import { A2UIMiddleware } from "@ag-ui/a2ui-middleware"; const envVars = getEnvVars(); +// Catalog the dojo's dynamic A2UI demos render against (HotelCard / ProductCard +// / TeamMemberCard / Row). +const A2UI_DOJO_CATALOG_ID = "https://a2ui.org/demos/dojo/dynamic_catalog.json"; + +// Per-agent A2UI inject whitelist for the adk-middleware integration. These +// subagent demos wire no a2ui tool themselves and rely on the adapter +// auto-injecting `generate_a2ui` when it sees `injectA2UITool`. Injection is +// applied per-agent (NOT integration-wide) so `a2ui_fixed_schema` — which uses +// direct tools — never gets `generate_a2ui` injected. These agents are excluded +// from the runtime-level a2ui config in route.ts to avoid double-applying the +// middleware (the per-request clone copies construction-time `.use()`). +export const ADK_A2UI_INJECT_AGENTS: string[] = ["a2ui_dynamic_schema"]; + export const agentsIntegrations = { "middleware-starter": async () => ({ agentic_chat: new MiddlewareStarterAgent(), @@ -57,8 +71,8 @@ export const agentsIntegrations = { agentic_chat: new ServerStarterAgent({ url: envVars.serverStarterUrl }), }), - "adk-middleware": async () => - mapAgents( + "adk-middleware": async () => { + const agents = mapAgents( (path) => new ADKAgent({ url: `${envVars.adkMiddlewareUrl}/${path}` }), { agentic_chat: "chat", @@ -68,8 +82,22 @@ export const agentsIntegrations = { backend_tool_rendering: "backend_tool_rendering", shared_state: "adk-shared-state-agent", predictive_state_updates: "adk-predictive-state-agent", + a2ui_fixed_schema: "adk-a2ui-fixed-schema", + a2ui_dynamic_schema: "adk-a2ui-dynamic-schema", + a2ui_recovery: "adk-a2ui-recovery", }, - ), + ); + // Whitelist-driven per-agent A2UI injection (see ADK_A2UI_INJECT_AGENTS). + for (const id of ADK_A2UI_INJECT_AGENTS) { + (agents as Record)[id]?.use( + new A2UIMiddleware({ + injectA2UITool: true, + defaultCatalogId: A2UI_DOJO_CATALOG_ID, + }), + ); + } + return agents; + }, "server-starter-all-features": async () => mapAgents( @@ -163,6 +191,19 @@ export const agentsIntegrations = { agent.use(new A2UIMiddleware({ injectA2UITool: true })); return agent; })(), + a2ui_dynamic_schema: new LangGraphAgent({ + deploymentUrl: envVars.langgraphPythonUrl, + graphId: "a2ui_dynamic_schema", + }), + a2ui_fixed_schema: new LangGraphAgent({ + deploymentUrl: envVars.langgraphPythonUrl, + graphId: "a2ui_fixed_schema", + }), + // Advanced: same backend agent, frontend adds custom progress renderer + action handlers + a2ui_advanced: new LangGraphAgent({ + deploymentUrl: envVars.langgraphPythonUrl, + graphId: "a2ui_dynamic_schema", + }), }), "langgraph-fastapi": async () => ({ @@ -196,8 +237,8 @@ export const agentsIntegrations = { }), }), - "langgraph-typescript": async () => - mapAgents( + "langgraph-typescript": async () => ({ + ...mapAgents( (graphId) => { return new LangGraphAgent({ deploymentUrl: envVars.langgraphTypescriptUrl, @@ -217,27 +258,50 @@ export const agentsIntegrations = { subgraphs: "subgraphs", }, ), + a2ui_dynamic_schema: new LangGraphAgent({ + deploymentUrl: envVars.langgraphTypescriptUrl, + graphId: "a2ui_dynamic_schema", + }), + a2ui_fixed_schema: new LangGraphAgent({ + deploymentUrl: envVars.langgraphTypescriptUrl, + graphId: "a2ui_fixed_schema", + }), + // Advanced: same backend agent, frontend adds custom progress renderer + action handlers + a2ui_advanced: new LangGraphAgent({ + deploymentUrl: envVars.langgraphTypescriptUrl, + graphId: "a2ui_dynamic_schema", + }), + // OSS-162: A2UI error-recovery showcase (sub-agent emits a structural error, + // then recovers). Rides the runtime a2ui middleware like the others. + a2ui_recovery: new LangGraphAgent({ + deploymentUrl: envVars.langgraphTypescriptUrl, + graphId: "a2ui_recovery", + }), + }), + // TODO: fix this — CopilotKit 1.60.x bump flips @langchain/openai onto + // @langchain/core@1.1.40, which clashes with @ag-ui/langchain (pinned to + // core@0.3.80) and breaks the chainFn type-check. Re-enable once resolved. // TODO: @ranst91 Enable `langchain` integration in apps/dojo/src/menu.ts once ready - langchain: async () => { - const agent = new LangChainAgent({ - chainFn: async ({ messages, tools, threadId }) => { - const { ChatOpenAI } = await import("@langchain/openai"); - const chatOpenAI = new ChatOpenAI({ model: "gpt-4o" }); - const model = chatOpenAI.bindTools(tools, { - strict: true, - }); - return model.stream(messages, { - tools, - metadata: { conversation_id: threadId }, - }); - }, - }); - return { - agentic_chat: agent, - tool_based_generative_ui: agent, - }; - }, + // langchain: async () => { + // const agent = new LangChainAgent({ + // chainFn: async ({ messages, tools, threadId }) => { + // const { ChatOpenAI } = await import("@langchain/openai"); + // const chatOpenAI = new ChatOpenAI({ model: "gpt-4o" }); + // const model = chatOpenAI.bindTools(tools, { + // strict: true, + // }); + // return model.stream(messages, { + // tools, + // metadata: { conversation_id: threadId }, + // }); + // }, + // }); + // return { + // agentic_chat: agent, + // tool_based_generative_ui: agent, + // }; + // }, agno: async () => mapAgents( @@ -395,7 +459,6 @@ export const agentsIntegrations = { }, "aws-strands": async () => ({ - // Different URL pattern (hyphens) and one has debug:true, so not using mapAgents ...mapAgents( (path) => new AWSStrandsAgent({ url: `${envVars.awsStrandsUrl}/${path}/` }), @@ -403,9 +466,16 @@ export const agentsIntegrations = { agentic_chat: "agentic-chat", agentic_chat_reasoning: "agentic-chat-reasoning", agentic_chat_multimodal: "agentic-chat-multimodal", + // v1 page reuses the agentic-chat endpoint (menu advertises the + // feature; this mapping was missing). + v1_agentic_chat: "agentic-chat", backend_tool_rendering: "backend-tool-rendering", agentic_generative_ui: "agentic-generative-ui", shared_state: "shared-state", + // A2UI demos: plain Strands agents with no a2ui wiring (the + // runtime sends `injectA2UITool` and the adapter injects generate_a2ui). + a2ui_dynamic_schema: "a2ui-dynamic-schema", + a2ui_recovery: "a2ui-recovery", }, ), human_in_the_loop: new AWSStrandsAgent({ @@ -433,6 +503,11 @@ export const agentsIntegrations = { agentic_generative_ui: "agentic-generative-ui", shared_state: "shared-state", tool_based_generative_ui: "tool-based-generative-ui", + // A2UI demos (auto-injected, see above). The example server mounts + // plain Strands agents (no a2ui wiring); the runtime sends + // `injectA2UITool` and the adapter injects `generate_a2ui` itself. + a2ui_dynamic_schema: "a2ui-dynamic-schema", + a2ui_recovery: "a2ui-recovery", }, ), human_in_the_loop: new AWSStrandsAgent({ diff --git a/apps/dojo/src/app/[integrationId]/feature/(v2)/a2ui_recovery/README.mdx b/apps/dojo/src/app/[integrationId]/feature/(v2)/a2ui_recovery/README.mdx new file mode 100644 index 0000000000..842bb03b69 --- /dev/null +++ b/apps/dojo/src/app/[integrationId]/feature/(v2)/a2ui_recovery/README.mdx @@ -0,0 +1,26 @@ +# A2UI Error Recovery + +## What This Demo Shows + +Automatic, no-wipe recovery when a secondary LLM generates an **invalid** A2UI surface. + +1. **Server-side validation gate**: Each generated component tree is validated before it can paint. Invalid trees are suppressed — the user never sees a broken surface flash and disappear. +2. **Structured-error feedback loop**: The validation errors are fed back to the generating sub-agent, which regenerates (up to a configurable cap, default 3 attempts). +3. **No wipes**: Only a validated surface ever commits. Faulty attempts never paint, so there's no stream → error → wipe → retry flicker. +4. **Tasteful hard-failure**: If every attempt fails, a clean failure state is shown and the conversation stays usable. Developers get full per-attempt detail; end users don't see transient noise. + +## How to Interact + +Two suggestions are wired for this demo: + +- **"Compare 3 luxury hotels with ratings and prices."** — the first generated surface references a UI template the model "forgot" to include (a dangling child reference). The gate rejects it, the error is fed back, and the **second attempt is valid** and paints. You see the recovered surface, not the broken one. +- **"Compare 3 broken hotels with ratings and prices."** — every attempt is invalid, so the loop **exhausts** and the clean hard-failure state appears. The chat remains interactive afterward. + +## How It Works Technically + +- The **commit point is the component-tree close** — the only moment a tree is knowable as complete — where the middleware runs `validateA2UIComponents` and emits the surface **only if valid**. +- On rejection, `augmentPromptWithValidationErrors` appends the machine-readable errors to the sub-agent's prompt and the adapter re-invokes it (`runA2UIGenerationWithRecovery`), never retrying after a validated paint. +- Recovery is surfaced as an `a2ui_recovery` activity: a delayed "Retrying…" hint for slow/repeated retries, and a hard-failure state once the attempt cap is reached. +- The retry cap, the threshold before the retry hint appears, and how much debug state is exposed are all configurable. + +This feature drives errors deterministically via ai-mock fixtures so the recovery and hard-failure paths can be demonstrated and tested reliably. diff --git a/apps/dojo/src/app/[integrationId]/feature/(v2)/a2ui_recovery/page.tsx b/apps/dojo/src/app/[integrationId]/feature/(v2)/a2ui_recovery/page.tsx new file mode 100644 index 0000000000..23247db25b --- /dev/null +++ b/apps/dojo/src/app/[integrationId]/feature/(v2)/a2ui_recovery/page.tsx @@ -0,0 +1,63 @@ +"use client"; +import React from "react"; +import "@copilotkit/react-core/v2/styles.css"; +import "./style.css"; +import { + CopilotChat, + useConfigureSuggestions, +} from "@copilotkit/react-core/v2"; +import { CopilotKit } from "@copilotkit/react-core"; +import { dynamicSchemaCatalog } from "@/a2ui-catalog"; + +export const dynamic = "force-dynamic"; + +interface PageProps { + params: Promise<{ integrationId: string }>; +} + +function Chat() { + useConfigureSuggestions({ + suggestions: [ + { + title: "Recover from an error", + message: "Compare 3 luxury hotels with ratings and prices.", + }, + { + title: "Hard failure", + message: "Compare 3 broken hotels with ratings and prices.", + }, + ], + available: "always", + }); + + return ( + + ); +} + +export default function Page({ params }: PageProps) { + const { integrationId } = React.use(params); + + return ( + +
+
+ +
+
+
+ ); +} diff --git a/apps/dojo/src/app/[integrationId]/feature/(v2)/a2ui_recovery/style.css b/apps/dojo/src/app/[integrationId]/feature/(v2)/a2ui_recovery/style.css new file mode 100644 index 0000000000..60a90ef388 --- /dev/null +++ b/apps/dojo/src/app/[integrationId]/feature/(v2)/a2ui_recovery/style.css @@ -0,0 +1,27 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap'); + +.a2ui-surface { + --primary: #111111; + --primary-foreground: #ffffff; + --card: #ffffff; + --border: #e0e0e0; + --radius: 12px; + --foreground: #111111; + --input: #d4d4d4; + --background: #fafafa; + + font-family: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important; + letter-spacing: -0.01em; +} + +/* Constrain images to consistent sizes */ +.a2ui-surface img { + max-width: 28px; + max-height: 28px; + border-radius: 4px; +} + +/* Consistent card width so single-card streaming doesn't collapse narrow */ +.a2ui-surface .a2ui-card { + min-width: 280px; +} diff --git a/apps/dojo/src/app/api/copilotkit/[integrationId]/[[...slug]]/route.ts b/apps/dojo/src/app/api/copilotkit/[integrationId]/[[...slug]]/route.ts index e6167358a9..b87be2251c 100644 --- a/apps/dojo/src/app/api/copilotkit/[integrationId]/[[...slug]]/route.ts +++ b/apps/dojo/src/app/api/copilotkit/[integrationId]/[[...slug]]/route.ts @@ -7,7 +7,7 @@ import { handle } from "hono/vercel"; import type { NextRequest } from "next/server"; import type { AbstractAgent } from "@ag-ui/client"; -import { agentsIntegrations } from "@/agents"; +import { agentsIntegrations, ADK_A2UI_INJECT_AGENTS } from "@/agents"; import { IntegrationId } from "@/menu"; import { getPostHogClient } from "@/lib/posthog-server"; @@ -33,11 +33,43 @@ async function getHandler(integrationId: string) { const agents = await getAgents(); + // The AWS Strands a2ui demos are plain Strands agents with no a2ui tool + // wiring: the runtime sends `injectA2UITool` and the adapter injects + // `generate_a2ui` itself, inferring the model from the wrapped agent. + // Scope it to the Strands integrations only (both adapters implement the + // injection): + // the LangGraph a2ui demos define their tools in-backend and must keep their + // existing (no-injection) a2ui config so their passing tests are unaffected. + const injectsA2UITool = + integrationId === "aws-strands-typescript" || integrationId === "aws-strands" || integrationId.includes("langgraph"); + + // Agents whose A2UI rendering the runtime auto-applies A2UIMiddleware for. + // adk-middleware's inject-whitelisted agents (ADK_A2UI_INJECT_AGENTS) apply + // their OWN per-agent A2UIMiddleware (with injectA2UITool) in agents.ts, so + // they're excluded here — otherwise the middleware would be applied twice (the + // per-request clone preserves the construction-time `.use()`). + const allA2UIAgents = [ + "a2ui_fixed_schema", + "a2ui_dynamic_schema", + "a2ui_advanced", + "a2ui_recovery", + ]; + const a2uiAgents = + integrationId === "adk-middleware" + ? allA2UIAgents.filter((id) => !ADK_A2UI_INJECT_AGENTS.includes(id)) + : allA2UIAgents; + const runtime = new CopilotRuntime({ agents: agents as Record, runner: new InMemoryAgentRunner(), a2ui: { - agents: ["a2ui_fixed_schema", "a2ui_dynamic_schema", "a2ui_advanced"], + agents: a2uiAgents, + // Catalog used when creating a surface from a STREAMED render_a2ui call. + // Only the dynamic (subagent) agents stream; fixed_schema uses direct + // tools that carry their own catalog in the result envelope, so a single + // catalog id here is correct for every streaming agent. + defaultCatalogId: "https://a2ui.org/demos/dojo/dynamic_catalog.json", + ...(injectsA2UITool ? { injectA2UITool: true } : {}), }, }); diff --git a/apps/dojo/src/config.ts b/apps/dojo/src/config.ts index d18a9cdb19..62174c0663 100644 --- a/apps/dojo/src/config.ts +++ b/apps/dojo/src/config.ts @@ -111,6 +111,12 @@ export const featureConfig: FeatureConfig[] = [ description: "Dynamic A2UI with custom progress renderer and frontend action handlers", tags: ["A2UI", "Advanced", "Progress", "Action Handlers"], }), + createFeatureConfig({ + id: "a2ui_recovery", + name: "A2UI Error Recovery", + description: "Automatic A2UI error recovery — invalid surfaces are regenerated (no wipe), with a tasteful hard-failure fallback", + tags: ["A2UI", "Error Recovery", "Streaming"], + }), ]; export default featureConfig; diff --git a/apps/dojo/src/files.json b/apps/dojo/src/files.json index de7a86fce5..7639680d9c 100644 --- a/apps/dojo/src/files.json +++ b/apps/dojo/src/files.json @@ -232,7 +232,7 @@ }, { "name": "agent.ts", - "content": "/**\n * A simple agentic chat flow using LangGraph with AG-UI middleware.\n *\n * The AG-UI middleware handles:\n * - Injecting frontend tools from state.tools into the model\n * - Routing frontend tool calls (emit events, skip backend execution)\n */\n\nimport { createAgent } from \"langchain\";\nimport { MemorySaver } from \"@langchain/langgraph\";\nimport { copilotkitMiddleware } from \"@copilotkit/sdk-js/langgraph\";\n\nconst checkpointer = new MemorySaver();\n\nexport const agenticChatGraph = createAgent({\n model: \"openai:gpt-4o\",\n tools: [], // Backend tools go here\n middleware: [copilotkitMiddleware],\n systemPrompt: \"You are a helpful assistant.\",\n checkpointer\n});\n", + "content": "/**\n * A simple agentic chat flow using LangGraph with AG-UI middleware.\n *\n * The AG-UI middleware handles:\n * - Injecting frontend tools from state.tools into the model\n * - Routing frontend tool calls (emit events, skip backend execution)\n */\n\nimport { createAgent } from \"langchain\";\nimport { copilotkitMiddleware } from \"@copilotkit/sdk-js/langgraph\";\n\nconst agenticChatAgent = createAgent({\n model: \"openai:gpt-4o\",\n tools: [], // Backend tools go here\n middleware: [copilotkitMiddleware],\n systemPrompt: \"You are a helpful assistant.\",\n});\n\n// Export the inner graph, not the ReactAgent wrapper. On LangGraph Platform the\n// server injects its managed checkpointer into the graph; the wrapper does not\n// forward that injection to its private #graph (langchainjs#10144), so on the\n// 2nd turn getState/resume fails with MISSING_CHECKPOINTER. Exporting `.graph`\n// lets the platform inject persistence directly. No compiled checkpointer.\nexport const agenticChatGraph = agenticChatAgent.graph;\n", "language": "ts", "type": "file" } @@ -527,6 +527,90 @@ "type": "file" } ], + "langgraph::a2ui_dynamic_schema": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Hotel comparison\",\n message:\n \"Compare 3 luxury hotels in different cities with ratings and prices.\",\n },\n {\n title: \"Product comparison\",\n message:\n \"Compare 3 wireless headphones with prices, ratings, and descriptions.\",\n },\n {\n title: \"Team roster\",\n message:\n \"Show a team of 4 people with their roles, departments, and contact info.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Dynamic Schema\n\n## What This Demo Shows\n\nDynamic A2UI where a secondary LLM generates the entire UI schema and data from the conversation context.\n\n1. **LLM-generated UI**: A secondary GPT-4.1 call produces the `render_a2ui` tool call with components and data\n2. **No pre-defined schema**: The UI layout is created on-the-fly based on what the user asks for\n3. **Progressive streaming**: Components and data stream as the secondary LLM generates them\n4. **Built-in progress indicator**: Shows generation progress while the schema is being created\n", + "language": "markdown", + "type": "file" + }, + { + "name": "agent.py", + "content": "\"\"\"\nDynamic A2UI tool: LLM-generated UI from conversation context.\n\nA secondary LLM generates v0.9 A2UI components via a structured tool call.\nThe generate_a2ui tool wraps the output as a2ui_operations, which the\nmiddleware detects in the TOOL_CALL_RESULT and renders automatically.\n\"\"\"\n\nimport os\nimport sys\n\nfrom langchain.agents import create_agent\nfrom langchain_openai import ChatOpenAI\nfrom ag_ui_langgraph import get_a2ui_tools\n\nCUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\"\n\n# Project-specific composition rules — tells the subagent how to use the\n# pre-made domain components (HotelCard, ProductCard, TeamMemberCard) shipped\n# in the dojo's dynamic catalog.\nCOMPOSITION_GUIDE = \"\"\"\n## Available Pre-made Components\n\nYou have 4 components. Use Row as the root with structural children to repeat a card per item.\n\n### Row\nLayout container. Use structural children to repeat a card template:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard\nProps: name, location, rating (number 0-5), pricePerNight, amenities (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"HotelCard\",\"name\":{\"path\":\"name\"},\"location\":{\"path\":\"location\"},\n \"rating\":{\"path\":\"rating\"},\"pricePerNight\":{\"path\":\"pricePerNight\"},\n \"action\":{\"event\":{\"name\":\"book\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### ProductCard\nProps: name, price, rating (number 0-5), description (optional), badge (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"ProductCard\",\"name\":{\"path\":\"name\"},\"price\":{\"path\":\"price\"},\n \"rating\":{\"path\":\"rating\"},\"description\":{\"path\":\"description\"},\n \"action\":{\"event\":{\"name\":\"select\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### TeamMemberCard\nProps: name, role, department (optional), email (optional), avatarUrl (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"TeamMemberCard\",\"name\":{\"path\":\"name\"},\"role\":{\"path\":\"role\"},\n \"department\":{\"path\":\"department\"},\"email\":{\"path\":\"email\"},\n \"action\":{\"event\":{\"name\":\"contact\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- Inside templates, use RELATIVE paths (no leading slash): {\"path\":\"name\"} not {\"path\":\"/name\"}\n- Always provide data in the \"data\" argument as {\"items\":[...]}\n- Pick the card type that best matches the user's request\n- Generate 3-4 realistic items with diverse data\n\"\"\"\n\nbase_model = ChatOpenAI(model=\"gpt-4o\")\n\nTOOLS = [\n get_a2ui_tools(\n {\n \"model\": base_model,\n \"default_catalog_id\": CUSTOM_CATALOG_ID,\n \"guidelines\": {\"composition_guide\": COMPOSITION_GUIDE},\n }\n )\n]\n\n\nSYSTEM_PROMPT = \"\"\"You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (product comparisons, dashboards, lists, cards, etc.),\nuse the generate_a2ui tool to create a dynamic A2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.\"\"\"\n\n\n# Converted from a manual StateGraph + ToolNode to create_agent to isolate the\n# graph-shape variable in the A2UI-streaming investigation. The same\n# get_a2ui_tools tool is bound directly (NOT auto-injected via\n# CopilotKitMiddleware), so the ONLY difference vs the prior version is\n# StateGraph -> create_agent.\nis_fast_api = os.environ.get(\"LANGGRAPH_FAST_API\", \"false\").lower() == \"true\"\n\nif is_fast_api:\n from langgraph.checkpoint.memory import MemorySaver\n\n graph = create_agent(\n model=base_model,\n tools=TOOLS,\n system_prompt=SYSTEM_PROMPT,\n checkpointer=MemorySaver(),\n )\nelse:\n graph = create_agent(\n model=base_model,\n tools=TOOLS,\n system_prompt=SYSTEM_PROMPT,\n )\n", + "language": "python", + "type": "file" + }, + { + "name": "agent.ts", + "content": "/**\n * Dynamic A2UI agent (prebuilt).\n *\n * Uses LangChain's `createAgent` prebuilt with the AG-UI `getA2UITools`\n * factory. A secondary LLM (the subagent shipped inside the factory) designs\n * the A2UI components and data; the AG-UI middleware detects the resulting\n * `a2ui_operations` payload in the tool result and renders the surface.\n */\n\nimport { createAgent } from \"langchain\";\nimport { copilotkitMiddleware } from \"@copilotkit/sdk-js/langgraph\";\nimport { ChatOpenAI } from \"@langchain/openai\";\nimport { getA2UITools } from \"@ag-ui/langgraph\";\n\nconst CUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\";\n\n// Project-specific composition rules — tells the subagent how to use the\n// pre-made domain components (HotelCard, ProductCard, TeamMemberCard) shipped\n// in the dojo's dynamic catalog.\nconst COMPOSITION_GUIDE = `\n## Available Pre-made Components\n\nYou have 4 components. Use Row as the root with structural children to repeat a card per item.\n\n### Row\nLayout container. Use structural children to repeat a card template:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard\nProps: name, location, rating (number 0-5), pricePerNight, amenities (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"HotelCard\",\"name\":{\"path\":\"name\"},\"location\":{\"path\":\"location\"},\n \"rating\":{\"path\":\"rating\"},\"pricePerNight\":{\"path\":\"pricePerNight\"},\n \"action\":{\"event\":{\"name\":\"book\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### ProductCard\nProps: name, price, rating (number 0-5), description (optional), badge (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"ProductCard\",\"name\":{\"path\":\"name\"},\"price\":{\"path\":\"price\"},\n \"rating\":{\"path\":\"rating\"},\"description\":{\"path\":\"description\"},\n \"action\":{\"event\":{\"name\":\"select\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### TeamMemberCard\nProps: name, role, department (optional), email (optional), avatarUrl (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"TeamMemberCard\",\"name\":{\"path\":\"name\"},\"role\":{\"path\":\"role\"},\n \"department\":{\"path\":\"department\"},\"email\":{\"path\":\"email\"},\n \"action\":{\"event\":{\"name\":\"contact\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- Inside templates, use RELATIVE paths (no leading slash): {\"path\":\"name\"} not {\"path\":\"/name\"}\n- Always provide data in the \"data\" argument as {\"items\":[...]}\n- Pick the card type that best matches the user's request\n- Generate 3-4 realistic items with diverse data\n`;\n\nconst a2uiTool = getA2UITools({\n model: new ChatOpenAI({ model: \"gpt-4o\" }),\n defaultCatalogId: CUSTOM_CATALOG_ID,\n guidelines: { compositionGuide: COMPOSITION_GUIDE },\n});\n\nconst a2uiDynamicSchemaAgent = createAgent({\n model: \"openai:gpt-4o\",\n // Cast: tool returned by `getA2UITools` is typed against `@ag-ui/langgraph`'s\n // own `@langchain/core` peer, which can skew vs. the consumer's pin.\n tools: [a2uiTool as any],\n middleware: [copilotkitMiddleware],\n systemPrompt: `You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (product comparisons, dashboards, lists, cards, etc.),\nuse the generate_a2ui tool to create a dynamic A2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.`,\n});\n\n// Export the inner graph, not the ReactAgent wrapper, so LangGraph Platform can\n// inject its managed checkpointer (the wrapper swallows the injection —\n// langchainjs#10144 — causing MISSING_CHECKPOINTER on the 2nd turn deployed).\nexport const a2uiDynamicSchemaGraph = a2uiDynamicSchemaAgent.graph;\n", + "language": "ts", + "type": "file" + } + ], + "langgraph::a2ui_fixed_schema": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { fixedSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Search flights\",\n message: \"Find flights from SFO to JFK for next Tuesday.\",\n },\n {\n title: \"Search hotels\",\n message: \"Find hotels in downtown Manhattan for next weekend.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Fixed Schema\n\n## What This Demo Shows\n\nFixed-schema A2UI rendering where the UI schema is pre-defined in JSON files and only the data changes per invocation.\n\n1. **Pre-built schemas**: Flight card layout loaded from `flight_schema.json`\n2. **Data binding**: The agent populates flight data into the schema template\n3. **Action handlers**: \"Select\" button triggers an optimistic booking confirmation\n4. **No streaming**: All cards render at once after the tool completes\n", + "language": "markdown", + "type": "file" + }, + { + "name": "agent.py", + "content": "\"\"\"\nFixed-schema A2UI: flight + hotel search results (no streaming).\n\nSchema is loaded from JSON files. Only the data changes per invocation.\nThe hotel search demonstrates a custom catalog with a StarRating component.\n\"\"\"\n\nimport os\nfrom pathlib import Path\nfrom typing import Any, List\nfrom typing_extensions import TypedDict\n\nfrom copilotkit import a2ui\nfrom langchain.tools import tool\nfrom langchain_openai import ChatOpenAI\nfrom langchain_core.messages import SystemMessage\nfrom langchain_core.runnables import RunnableConfig\nfrom langgraph.graph import StateGraph, END, MessagesState\nfrom langgraph.prebuilt import ToolNode\n\n\n# --- Flight search (basic catalog) ---\n\nFLIGHT_SURFACE_ID = \"flight-search-results\"\nFLIGHT_SCHEMA = a2ui.load_schema(\n Path(__file__).parent / \"schemas\" / \"flight_schema.json\"\n)\n\nclass Flight(TypedDict):\n id: str\n airline: str\n airlineLogo: str\n flightNumber: str\n origin: str\n destination: str\n date: str\n departureTime: str\n arrivalTime: str\n duration: str\n status: str\n statusIcon: str\n price: str\n\n\n@tool\ndef search_flights(flights: list[Flight]) -> str:\n \"\"\"Search for flights and display the results as rich cards.\n\n Each flight must have: id, airline (e.g. \"United Airlines\"),\n airlineLogo (use Google favicon API: https://www.google.com/s2/favicons?domain={airline_domain}&sz=128\n e.g. \"https://www.google.com/s2/favicons?domain=united.com&sz=128\" for United,\n \"https://www.google.com/s2/favicons?domain=delta.com&sz=128\" for Delta,\n \"https://www.google.com/s2/favicons?domain=aa.com&sz=128\" for American,\n \"https://www.google.com/s2/favicons?domain=alaskaair.com&sz=128\" for Alaska),\n flightNumber, origin, destination,\n date (short readable format like \"Tue, Mar 18\" — use near-future dates),\n departureTime, arrivalTime,\n duration (e.g. \"4h 25m\"), status (e.g. \"On Time\" or \"Delayed\"),\n statusIcon (colored dot: use \"https://placehold.co/12/22c55e/22c55e.png\"\n for On Time, \"https://placehold.co/12/eab308/eab308.png\" for Delayed,\n \"https://placehold.co/12/ef4444/ef4444.png\" for Cancelled),\n and price (e.g. \"$289\").\n \"\"\"\n return a2ui.render(\n operations=[\n a2ui.create_surface(FLIGHT_SURFACE_ID, catalog_id=CUSTOM_CATALOG_ID),\n a2ui.update_components(FLIGHT_SURFACE_ID, FLIGHT_SCHEMA),\n a2ui.update_data_model(FLIGHT_SURFACE_ID, {\"flights\": flights}),\n ],\n )\n\n\n# --- Hotel search (custom catalog with StarRating) ---\n\nCUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/fixed_catalog.json\"\nHOTEL_SURFACE_ID = \"hotel-search-results\"\nHOTEL_SCHEMA = a2ui.load_schema(\n Path(__file__).parent / \"schemas\" / \"hotel_schema.json\"\n)\n\nclass Hotel(TypedDict):\n id: str\n name: str\n location: str\n rating: float\n price: str\n\n\n@tool\ndef search_hotels(hotels: list[Hotel]) -> str:\n \"\"\"Search for hotels and display the results as rich cards with star ratings.\n\n Each hotel must have: id, name (e.g. \"The Plaza\"),\n location (e.g. \"Midtown Manhattan, NYC\"),\n rating (float 0-5, e.g. 4.5),\n and price (per night, e.g. \"$350\").\n\n Generate 3-4 realistic hotel results.\n \"\"\"\n return a2ui.render(\n operations=[\n a2ui.create_surface(HOTEL_SURFACE_ID, catalog_id=CUSTOM_CATALOG_ID),\n a2ui.update_components(HOTEL_SURFACE_ID, HOTEL_SCHEMA),\n a2ui.update_data_model(HOTEL_SURFACE_ID, {\"hotels\": hotels}),\n ],\n )\n\n\nTOOLS = [search_flights, search_hotels]\n\n\nclass AgentState(MessagesState):\n tools: List[Any]\n\n\nSYSTEM_PROMPT = \"\"\"You are a helpful travel assistant that can search for flights and hotels.\n\nWhen the user asks about flights, use the search_flights tool.\nWhen the user asks about hotels, use the search_hotels tool.\nIMPORTANT: After calling a tool, do NOT repeat or summarize the data in your text response. The tool renders a rich UI automatically. Just say something brief like \"Here are your results\" or ask if they'd like to book.\n\nFor flights, each needs: id, airline, airlineLogo (Google favicon API), flightNumber, origin, destination,\ndate, departureTime, arrivalTime, duration, status, statusIcon, and price.\n\nFor hotels, each needs: id, name, location, rating (float 0-5), and price (per night).\n\nGenerate 3-5 realistic results.\"\"\"\n\n\nasync def chat_node(state: AgentState, config: RunnableConfig):\n model = ChatOpenAI(model=\"gpt-4o\")\n model = model.bind_tools(TOOLS, parallel_tool_calls=False)\n\n response = await model.ainvoke([\n SystemMessage(content=SYSTEM_PROMPT),\n *state[\"messages\"],\n ], config)\n\n return {\"messages\": [response]}\n\n\ndef route_after_chat(state: AgentState):\n last_message = state[\"messages\"][-1]\n if hasattr(last_message, \"tool_calls\") and last_message.tool_calls:\n return \"tool_node\"\n return END\n\n\nworkflow = StateGraph(AgentState)\nworkflow.add_node(\"chat_node\", chat_node)\nworkflow.add_node(\"tool_node\", ToolNode(tools=TOOLS))\nworkflow.set_entry_point(\"chat_node\")\nworkflow.add_conditional_edges(\"chat_node\", route_after_chat)\nworkflow.add_edge(\"tool_node\", \"chat_node\")\n\nis_fast_api = os.environ.get(\"LANGGRAPH_FAST_API\", \"false\").lower() == \"true\"\n\nif is_fast_api:\n from langgraph.checkpoint.memory import MemorySaver\n memory = MemorySaver()\n graph = workflow.compile(checkpointer=memory)\nelse:\n graph = workflow.compile()\n", + "language": "python", + "type": "file" + }, + { + "name": "agent.ts", + "content": "/**\n * Fixed-schema A2UI agent (prebuilt).\n *\n * Pre-built component layouts for flight and hotel cards. The agent only\n * supplies the data; layout/styling is fixed in code. Demonstrates the\n * \"controlled gen-UI\" pattern: author owns the UI shape, agent owns the data.\n */\n\nimport { createAgent } from \"langchain\";\nimport { copilotkitMiddleware } from \"@copilotkit/sdk-js/langgraph\";\nimport { tool } from \"@langchain/core/tools\";\n\nconst CUSTOM_CATALOG_ID =\n \"https://a2ui.org/demos/dojo/fixed_catalog.json\";\n\nconst A2UI_OPERATIONS_KEY = \"a2ui_operations\";\n\n// Flight search layout — agent supplies `flights` array; rendering is fixed.\nconst FLIGHT_SURFACE_ID = \"flight-search-results\";\nconst FLIGHT_SCHEMA: Array> = [\n {\n id: \"root\",\n component: \"Row\",\n children: { componentId: \"flight-card\", path: \"/flights\" },\n gap: 16,\n },\n {\n id: \"flight-card\",\n component: \"FlightCard\",\n airline: { path: \"airline\" },\n airlineLogo: { path: \"airlineLogo\" },\n flightNumber: { path: \"flightNumber\" },\n origin: { path: \"origin\" },\n destination: { path: \"destination\" },\n date: { path: \"date\" },\n departureTime: { path: \"departureTime\" },\n arrivalTime: { path: \"arrivalTime\" },\n duration: { path: \"duration\" },\n status: { path: \"status\" },\n price: { path: \"price\" },\n action: {\n event: {\n name: \"book_flight\",\n context: {\n flightNumber: { path: \"flightNumber\" },\n origin: { path: \"origin\" },\n destination: { path: \"destination\" },\n price: { path: \"price\" },\n },\n },\n },\n },\n];\n\n// Hotel search layout — agent supplies `hotels` array; rendering is fixed.\nconst HOTEL_SURFACE_ID = \"hotel-search-results\";\nconst HOTEL_SCHEMA: Array> = [\n {\n id: \"root\",\n component: \"Row\",\n children: { componentId: \"hotel-card\", path: \"/hotels\" },\n gap: 16,\n },\n {\n id: \"hotel-card\",\n component: \"HotelCard\",\n name: { path: \"name\" },\n location: { path: \"location\" },\n rating: { path: \"rating\" },\n pricePerNight: { path: \"price\" },\n action: {\n event: {\n name: \"book_hotel\",\n context: {\n hotelName: { path: \"name\" },\n price: { path: \"price\" },\n },\n },\n },\n },\n];\n\nfunction renderOperations(\n surfaceId: string,\n catalogId: string,\n schema: Array>,\n data: Record,\n): string {\n const ops = [\n {\n version: \"v0.9\",\n createSurface: { surfaceId, catalogId },\n },\n {\n version: \"v0.9\",\n updateComponents: { surfaceId, components: schema },\n },\n {\n version: \"v0.9\",\n updateDataModel: { surfaceId, path: \"/\", value: data },\n },\n ];\n return JSON.stringify({ [A2UI_OPERATIONS_KEY]: ops });\n}\n\nconst searchFlights = tool(\n async ({ flights }: { flights: Array> }) => {\n return renderOperations(\n FLIGHT_SURFACE_ID,\n CUSTOM_CATALOG_ID,\n FLIGHT_SCHEMA,\n { flights },\n );\n },\n {\n name: \"search_flights\",\n description:\n \"Search for flights and display the results as rich cards. Each flight \" +\n \"must have: id, airline (e.g. 'United Airlines'), airlineLogo (use Google \" +\n \"favicon API like 'https://www.google.com/s2/favicons?domain=united.com&sz=128'), \" +\n \"flightNumber, origin, destination, date (e.g. 'Tue, Mar 18'), departureTime, \" +\n \"arrivalTime, duration (e.g. '4h 25m'), status ('On Time' or 'Delayed'), \" +\n \"and price (e.g. '$289').\",\n schema: {\n type: \"object\",\n properties: {\n flights: {\n type: \"array\",\n items: { type: \"object\" },\n description: \"Array of flight result objects.\",\n },\n },\n required: [\"flights\"],\n } as any,\n },\n);\n\nconst searchHotels = tool(\n async ({ hotels }: { hotels: Array> }) => {\n return renderOperations(\n HOTEL_SURFACE_ID,\n CUSTOM_CATALOG_ID,\n HOTEL_SCHEMA,\n { hotels },\n );\n },\n {\n name: \"search_hotels\",\n description:\n \"Search for hotels and display the results as rich cards with star ratings. \" +\n \"Each hotel must have: id, name (e.g. 'The Plaza'), location \" +\n \"(e.g. 'Midtown Manhattan, NYC'), rating (float 0-5, e.g. 4.5), and \" +\n \"price (per night, e.g. '$350'). Generate 3-4 realistic results.\",\n schema: {\n type: \"object\",\n properties: {\n hotels: {\n type: \"array\",\n items: { type: \"object\" },\n description: \"Array of hotel result objects.\",\n },\n },\n required: [\"hotels\"],\n } as any,\n },\n);\n\nconst a2uiFixedSchemaAgent = createAgent({\n model: \"openai:gpt-4o\",\n tools: [searchFlights, searchHotels],\n middleware: [copilotkitMiddleware],\n systemPrompt: `You are a helpful travel assistant that can search for flights and hotels.\n\nWhen the user asks about flights, use the search_flights tool.\nWhen the user asks about hotels, use the search_hotels tool.\nIMPORTANT: After calling a tool, do NOT repeat or summarize the data in your text response. The tool renders a rich UI automatically. Just say something brief like \"Here are your results\" or ask if they'd like to book.\n\nFor flights, each needs: id, airline, airlineLogo (Google favicon API), flightNumber, origin, destination,\ndate, departureTime, arrivalTime, duration, status, and price.\n\nFor hotels, each needs: id, name, location, rating (float 0-5), and price (per night).\n\nGenerate 3-5 realistic results.`,\n});\n\n// Export the inner graph, not the ReactAgent wrapper, so LangGraph Platform can\n// inject its managed checkpointer (the wrapper swallows the injection —\n// langchainjs#10144 — causing MISSING_CHECKPOINTER on the 2nd turn deployed).\nexport const a2uiFixedSchemaGraph = a2uiFixedSchemaAgent.graph;\n", + "language": "ts", + "type": "file" + } + ], + "langgraph::a2ui_advanced": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { memo } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n useRenderTool,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\nimport { z } from \"zod\";\n\nexport const dynamic = \"force-dynamic\";\n\n// ---------------------------------------------------------------------------\n// 1. Custom Progress Renderer for Dynamic A2UI\n// Overrides the built-in render_a2ui progress indicator with a branded\n// violet skeleton showing live component/item counters.\n// ---------------------------------------------------------------------------\n\nconst A2UIProgress = memo(function A2UIProgress({\n parameters,\n}: {\n parameters: Record;\n}) {\n const componentCount = Array.isArray(parameters?.components)\n ? parameters.components.length\n : 0;\n const itemCount = Array.isArray(parameters?.items)\n ? parameters.items.length\n : 0;\n\n return (\n
\n {/* Header with branded spinner */}\n
\n
\n
\n
\n
\n
\n
\n
\n \n Custom A2UI Progress\n \n

\n useRenderTool("render_a2ui")\n

\n
\n
\n \n {componentCount > 0 ? `${componentCount} nodes` : \"parsing...\"}\n \n
\n\n {/* Live streaming counters */}\n
\n
\n
{componentCount}
\n
Components
\n
\n
\n
{itemCount}
\n
Data Items
\n
\n
\n
\n {parameters?.root ? \"1\" : \"0\"}\n
\n
Root Set
\n
\n
\n\n {/* Animated skeleton cards that light up as items stream in */}\n
\n {[0, 1, 2].map((i) => (\n i ? 1 : 0.4, transition: \"opacity 0.3s\" }}\n >\n
\n
\n
\n
\n
\n ))}\n
\n
\n );\n});\n\n// ---------------------------------------------------------------------------\n// 2. Frontend Action Handler (optimistic UI on button clicks)\n// Instant response when buttons are clicked — no server round-trip.\n// ---------------------------------------------------------------------------\n\nfunction useAdvancedA2UIFeatures() {\n // Custom progress renderer — overrides the built-in render_a2ui indicator\n useRenderTool(\n {\n name: \"render_a2ui\",\n parameters: z.any(),\n render: ({ status, parameters }) => {\n if (status === \"complete\") return <>;\n return ;\n },\n },\n [],\n );\n\n}\n\n// ---------------------------------------------------------------------------\n// Page\n// ---------------------------------------------------------------------------\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useAdvancedA2UIFeatures();\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Hotel comparison\",\n message:\n \"Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and star rating using the StarRating component.\",\n },\n {\n title: \"Product comparison\",\n message:\n \"Use the generate_a2ui tool to create a product comparison of 3 headphones with name, price, rating, a short description, and a Select button on each card.\",\n },\n {\n title: \"Team directory\",\n message:\n \"Use the generate_a2ui tool to create a team directory with 4 people showing name, role, department, and a Contact button.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Advanced\n\n## What This Demo Shows\n\nAdvanced A2UI patterns with dynamic schema generation.\n\n1. **Dynamic schema**: Agent generates UI components on the fly based on conversation context\n2. **Same dynamic backend**: Reuses the dynamic schema agent\n", + "language": "markdown", + "type": "file" + } + ], "langgraph-fastapi::agentic_chat": [ { "name": "page.tsx", @@ -830,7 +914,7 @@ }, { "name": "agent.py", - "content": "\"\"\"\nDynamic A2UI tool: LLM-generated UI from conversation context.\n\nA secondary LLM generates v0.9 A2UI components via a structured tool call.\nThe generate_a2ui tool wraps the output as a2ui_operations, which the\nmiddleware detects in the TOOL_CALL_RESULT and renders automatically.\n\"\"\"\n\nimport json\nimport os\nfrom typing import Any, List\n\nfrom langchain.tools import tool, ToolRuntime\nfrom langchain_core.messages import SystemMessage\nfrom langchain_core.tools import tool as lc_tool\nfrom langchain_core.runnables import RunnableConfig\nfrom langchain_openai import ChatOpenAI\nfrom langgraph.graph import StateGraph, END, MessagesState\nfrom langgraph.prebuilt import ToolNode\n\nfrom copilotkit import a2ui\n\n\n@lc_tool\ndef render_a2ui(\n surfaceId: str,\n catalogId: str,\n components: list[dict],\n data: dict | None = None,\n) -> str:\n \"\"\"Render a dynamic A2UI v0.9 surface.\n\n Args:\n surfaceId: Unique surface identifier.\n catalogId: The catalog ID (use \"https://a2ui.org/demos/dojo/custom_catalog.json\").\n components: A2UI v0.9 component array (flat format). The root\n component must have id \"root\".\n data: Optional initial data model for the surface (e.g. form values,\n list items for data-bound components).\n \"\"\"\n return \"rendered\"\n\n\ndef _build_context_prompt(state: dict) -> str:\n \"\"\"Build the A2UI generation prompt from client-provided context entries.\n\n The frontend sends generation guidelines, design guidelines, and the\n component schema as separate context entries. The LangGraph integration\n also extracts the schema into state[\"ag-ui\"][\"a2ui_schema\"].\n \"\"\"\n ag_ui = state.get(\"ag-ui\", {})\n parts: list[str] = []\n\n # Include all context entries (generation guidelines, design guidelines, etc.)\n # Entries may be Pydantic Context objects or plain dicts.\n for entry in ag_ui.get(\"context\", []):\n desc = entry.description\n value = entry.value\n if desc:\n parts.append(f\"## {desc}\\n{value}\\n\")\n else:\n parts.append(f\"{value}\\n\")\n\n # Include A2UI component schema (separated out by the LangGraph integration)\n a2ui_schema = ag_ui.get(\"a2ui_schema\")\n if a2ui_schema:\n parts.append(f\"## Available Components\\n{a2ui_schema}\\n\")\n\n return \"\\n\".join(parts)\n\n\nCUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\"\n\n# Local composition guide — tells the secondary LLM how to use our\n# pre-made domain components (HotelCard, ProductCard, TeamMemberCard).\nCOMPOSITION_GUIDE = \"\"\"\n## Available Pre-made Components\n\nYou have 4 components. Use Row as the root with structural children to repeat a card per item.\n\n### Row\nLayout container. Use structural children to repeat a card template:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard\nProps: name, location, rating (number 0-5), pricePerNight, amenities (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"HotelCard\",\"name\":{\"path\":\"name\"},\"location\":{\"path\":\"location\"},\n \"rating\":{\"path\":\"rating\"},\"pricePerNight\":{\"path\":\"pricePerNight\"},\n \"action\":{\"event\":{\"name\":\"book\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### ProductCard\nProps: name, price, rating (number 0-5), description (optional), badge (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"ProductCard\",\"name\":{\"path\":\"name\"},\"price\":{\"path\":\"price\"},\n \"rating\":{\"path\":\"rating\"},\"description\":{\"path\":\"description\"},\n \"action\":{\"event\":{\"name\":\"select\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### TeamMemberCard\nProps: name, role, department (optional), email (optional), avatarUrl (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"TeamMemberCard\",\"name\":{\"path\":\"name\"},\"role\":{\"path\":\"role\"},\n \"department\":{\"path\":\"department\"},\"email\":{\"path\":\"email\"},\n \"action\":{\"event\":{\"name\":\"contact\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- Inside templates, use RELATIVE paths (no leading slash): {\"path\":\"name\"} not {\"path\":\"/name\"}\n- Always provide data in the \"data\" argument as {\"items\":[...]}\n- Pick the card type that best matches the user's request\n- Generate 3-4 realistic items with diverse data\n\"\"\"\n\n\n@tool()\ndef generate_a2ui(runtime: ToolRuntime[Any]) -> str:\n \"\"\"Generate dynamic A2UI components based on the conversation.\n\n A secondary LLM designs the UI schema and data. The result is\n returned as an a2ui_operations container for the middleware to detect.\n \"\"\"\n # The last message is this tool call (generate_a2ui) so we remove it,\n # as it is not yet balanced with a tool call response.\n messages = runtime.state[\"messages\"][:-1]\n\n # Build prompt from client-provided context + local composition guide\n prompt = _build_context_prompt(runtime.state) + \"\\n\" + COMPOSITION_GUIDE\n\n model = ChatOpenAI(model=\"gpt-4.1\")\n model_with_tool = model.bind_tools(\n [render_a2ui],\n tool_choice=\"render_a2ui\",\n )\n\n response = model_with_tool.invoke(\n [SystemMessage(content=prompt), *messages],\n )\n\n # Extract the render_a2ui tool call arguments\n if not response.tool_calls:\n return json.dumps({\"error\": \"LLM did not call render_a2ui\"})\n\n tool_call = response.tool_calls[0]\n args = tool_call[\"args\"]\n\n surface_id = args.get(\"surfaceId\", \"dynamic-surface\")\n catalog_id = args.get(\"catalogId\", CUSTOM_CATALOG_ID)\n components = args.get(\"components\", [])\n data = args.get(\"data\", {})\n\n # Wrap as v0.9 a2ui_operations so the middleware detects it\n ops = [\n a2ui.create_surface(surface_id, catalog_id=catalog_id),\n a2ui.update_components(surface_id, components),\n ]\n if data:\n ops.append(a2ui.update_data_model(surface_id, data))\n\n result = a2ui.render(operations=ops)\n return result\n\n\nTOOLS = [generate_a2ui]\n\n\nclass AgentState(MessagesState):\n tools: List[Any]\n copilotkit: dict # CopilotKit context (actions, etc.)\n\n# LangGraph requires state keys declared in the schema.\n# \"ag-ui\" uses a hyphen which isn't valid as a Python identifier,\n# so we patch it into the annotations directly.\nAgentState.__annotations__[\"ag-ui\"] = dict\n\n\nSYSTEM_PROMPT = \"\"\"You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (product comparisons, dashboards, lists, cards, etc.),\nuse the generate_a2ui tool to create a dynamic A2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.\"\"\"\n\n\nasync def chat_node(state: AgentState, config: RunnableConfig):\n model = ChatOpenAI(model=\"gpt-4o\")\n model = model.bind_tools(TOOLS, parallel_tool_calls=False)\n\n response = await model.ainvoke([\n SystemMessage(content=SYSTEM_PROMPT),\n *state[\"messages\"],\n ], config)\n\n return {\"messages\": [response]}\n\n\ndef route_after_chat(state: AgentState):\n last_message = state[\"messages\"][-1]\n if hasattr(last_message, \"tool_calls\") and last_message.tool_calls:\n return \"tool_node\"\n return END\n\n\nworkflow = StateGraph(AgentState)\nworkflow.add_node(\"chat_node\", chat_node)\nworkflow.add_node(\"tool_node\", ToolNode(tools=TOOLS))\nworkflow.set_entry_point(\"chat_node\")\nworkflow.add_conditional_edges(\"chat_node\", route_after_chat)\nworkflow.add_edge(\"tool_node\", \"chat_node\")\n\nis_fast_api = os.environ.get(\"LANGGRAPH_FAST_API\", \"false\").lower() == \"true\"\n\nif is_fast_api:\n from langgraph.checkpoint.memory import MemorySaver\n memory = MemorySaver()\n graph = workflow.compile(checkpointer=memory)\nelse:\n graph = workflow.compile()\n", + "content": "\"\"\"\nDynamic A2UI tool: LLM-generated UI from conversation context.\n\nA secondary LLM generates v0.9 A2UI components via a structured tool call.\nThe generate_a2ui tool wraps the output as a2ui_operations, which the\nmiddleware detects in the TOOL_CALL_RESULT and renders automatically.\n\"\"\"\n\nimport os\nimport sys\n\nfrom langchain.agents import create_agent\nfrom langchain_openai import ChatOpenAI\nfrom ag_ui_langgraph import get_a2ui_tools\n\nCUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\"\n\n# Project-specific composition rules — tells the subagent how to use the\n# pre-made domain components (HotelCard, ProductCard, TeamMemberCard) shipped\n# in the dojo's dynamic catalog.\nCOMPOSITION_GUIDE = \"\"\"\n## Available Pre-made Components\n\nYou have 4 components. Use Row as the root with structural children to repeat a card per item.\n\n### Row\nLayout container. Use structural children to repeat a card template:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard\nProps: name, location, rating (number 0-5), pricePerNight, amenities (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"HotelCard\",\"name\":{\"path\":\"name\"},\"location\":{\"path\":\"location\"},\n \"rating\":{\"path\":\"rating\"},\"pricePerNight\":{\"path\":\"pricePerNight\"},\n \"action\":{\"event\":{\"name\":\"book\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### ProductCard\nProps: name, price, rating (number 0-5), description (optional), badge (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"ProductCard\",\"name\":{\"path\":\"name\"},\"price\":{\"path\":\"price\"},\n \"rating\":{\"path\":\"rating\"},\"description\":{\"path\":\"description\"},\n \"action\":{\"event\":{\"name\":\"select\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### TeamMemberCard\nProps: name, role, department (optional), email (optional), avatarUrl (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"TeamMemberCard\",\"name\":{\"path\":\"name\"},\"role\":{\"path\":\"role\"},\n \"department\":{\"path\":\"department\"},\"email\":{\"path\":\"email\"},\n \"action\":{\"event\":{\"name\":\"contact\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- Inside templates, use RELATIVE paths (no leading slash): {\"path\":\"name\"} not {\"path\":\"/name\"}\n- Always provide data in the \"data\" argument as {\"items\":[...]}\n- Pick the card type that best matches the user's request\n- Generate 3-4 realistic items with diverse data\n\"\"\"\n\nbase_model = ChatOpenAI(model=\"gpt-4o\")\n\nTOOLS = [\n get_a2ui_tools(\n {\n \"model\": base_model,\n \"default_catalog_id\": CUSTOM_CATALOG_ID,\n \"guidelines\": {\"composition_guide\": COMPOSITION_GUIDE},\n }\n )\n]\n\n\nSYSTEM_PROMPT = \"\"\"You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (product comparisons, dashboards, lists, cards, etc.),\nuse the generate_a2ui tool to create a dynamic A2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.\"\"\"\n\n\n# Converted from a manual StateGraph + ToolNode to create_agent to isolate the\n# graph-shape variable in the A2UI-streaming investigation. The same\n# get_a2ui_tools tool is bound directly (NOT auto-injected via\n# CopilotKitMiddleware), so the ONLY difference vs the prior version is\n# StateGraph -> create_agent.\nis_fast_api = os.environ.get(\"LANGGRAPH_FAST_API\", \"false\").lower() == \"true\"\n\nif is_fast_api:\n from langgraph.checkpoint.memory import MemorySaver\n\n graph = create_agent(\n model=base_model,\n tools=TOOLS,\n system_prompt=SYSTEM_PROMPT,\n checkpointer=MemorySaver(),\n )\nelse:\n graph = create_agent(\n model=base_model,\n tools=TOOLS,\n system_prompt=SYSTEM_PROMPT,\n )\n", "language": "python", "type": "file" } @@ -876,7 +960,7 @@ }, { "name": "agent.ts", - "content": "/**\n * A simple agentic chat flow using LangGraph with AG-UI middleware.\n *\n * The AG-UI middleware handles:\n * - Injecting frontend tools from state.tools into the model\n * - Routing frontend tool calls (emit events, skip backend execution)\n */\n\nimport { createAgent } from \"langchain\";\nimport { MemorySaver } from \"@langchain/langgraph\";\nimport { copilotkitMiddleware } from \"@copilotkit/sdk-js/langgraph\";\n\nconst checkpointer = new MemorySaver();\n\nexport const agenticChatGraph = createAgent({\n model: \"openai:gpt-4o\",\n tools: [], // Backend tools go here\n middleware: [copilotkitMiddleware],\n systemPrompt: \"You are a helpful assistant.\",\n checkpointer\n});\n", + "content": "/**\n * A simple agentic chat flow using LangGraph with AG-UI middleware.\n *\n * The AG-UI middleware handles:\n * - Injecting frontend tools from state.tools into the model\n * - Routing frontend tool calls (emit events, skip backend execution)\n */\n\nimport { createAgent } from \"langchain\";\nimport { copilotkitMiddleware } from \"@copilotkit/sdk-js/langgraph\";\n\nconst agenticChatAgent = createAgent({\n model: \"openai:gpt-4o\",\n tools: [], // Backend tools go here\n middleware: [copilotkitMiddleware],\n systemPrompt: \"You are a helpful assistant.\",\n});\n\n// Export the inner graph, not the ReactAgent wrapper. On LangGraph Platform the\n// server injects its managed checkpointer into the graph; the wrapper does not\n// forward that injection to its private #graph (langchainjs#10144), so on the\n// 2nd turn getState/resume fails with MISSING_CHECKPOINTER. Exporting `.graph`\n// lets the platform inject persistence directly. No compiled checkpointer.\nexport const agenticChatGraph = agenticChatAgent.graph;\n", "language": "ts", "type": "file" } @@ -1139,6 +1223,116 @@ "type": "file" } ], + "langgraph-typescript::a2ui_dynamic_schema": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Hotel comparison\",\n message:\n \"Compare 3 luxury hotels in different cities with ratings and prices.\",\n },\n {\n title: \"Product comparison\",\n message:\n \"Compare 3 wireless headphones with prices, ratings, and descriptions.\",\n },\n {\n title: \"Team roster\",\n message:\n \"Show a team of 4 people with their roles, departments, and contact info.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Dynamic Schema\n\n## What This Demo Shows\n\nDynamic A2UI where a secondary LLM generates the entire UI schema and data from the conversation context.\n\n1. **LLM-generated UI**: A secondary GPT-4.1 call produces the `render_a2ui` tool call with components and data\n2. **No pre-defined schema**: The UI layout is created on-the-fly based on what the user asks for\n3. **Progressive streaming**: Components and data stream as the secondary LLM generates them\n4. **Built-in progress indicator**: Shows generation progress while the schema is being created\n", + "language": "markdown", + "type": "file" + }, + { + "name": "agent.py", + "content": "\"\"\"\nDynamic A2UI tool: LLM-generated UI from conversation context.\n\nA secondary LLM generates v0.9 A2UI components via a structured tool call.\nThe generate_a2ui tool wraps the output as a2ui_operations, which the\nmiddleware detects in the TOOL_CALL_RESULT and renders automatically.\n\"\"\"\n\nimport os\nimport sys\n\nfrom langchain.agents import create_agent\nfrom langchain_openai import ChatOpenAI\nfrom ag_ui_langgraph import get_a2ui_tools\n\nCUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\"\n\n# Project-specific composition rules — tells the subagent how to use the\n# pre-made domain components (HotelCard, ProductCard, TeamMemberCard) shipped\n# in the dojo's dynamic catalog.\nCOMPOSITION_GUIDE = \"\"\"\n## Available Pre-made Components\n\nYou have 4 components. Use Row as the root with structural children to repeat a card per item.\n\n### Row\nLayout container. Use structural children to repeat a card template:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard\nProps: name, location, rating (number 0-5), pricePerNight, amenities (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"HotelCard\",\"name\":{\"path\":\"name\"},\"location\":{\"path\":\"location\"},\n \"rating\":{\"path\":\"rating\"},\"pricePerNight\":{\"path\":\"pricePerNight\"},\n \"action\":{\"event\":{\"name\":\"book\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### ProductCard\nProps: name, price, rating (number 0-5), description (optional), badge (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"ProductCard\",\"name\":{\"path\":\"name\"},\"price\":{\"path\":\"price\"},\n \"rating\":{\"path\":\"rating\"},\"description\":{\"path\":\"description\"},\n \"action\":{\"event\":{\"name\":\"select\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### TeamMemberCard\nProps: name, role, department (optional), email (optional), avatarUrl (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"TeamMemberCard\",\"name\":{\"path\":\"name\"},\"role\":{\"path\":\"role\"},\n \"department\":{\"path\":\"department\"},\"email\":{\"path\":\"email\"},\n \"action\":{\"event\":{\"name\":\"contact\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- Inside templates, use RELATIVE paths (no leading slash): {\"path\":\"name\"} not {\"path\":\"/name\"}\n- Always provide data in the \"data\" argument as {\"items\":[...]}\n- Pick the card type that best matches the user's request\n- Generate 3-4 realistic items with diverse data\n\"\"\"\n\nbase_model = ChatOpenAI(model=\"gpt-4o\")\n\nTOOLS = [\n get_a2ui_tools(\n {\n \"model\": base_model,\n \"default_catalog_id\": CUSTOM_CATALOG_ID,\n \"guidelines\": {\"composition_guide\": COMPOSITION_GUIDE},\n }\n )\n]\n\n\nSYSTEM_PROMPT = \"\"\"You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (product comparisons, dashboards, lists, cards, etc.),\nuse the generate_a2ui tool to create a dynamic A2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.\"\"\"\n\n\n# Converted from a manual StateGraph + ToolNode to create_agent to isolate the\n# graph-shape variable in the A2UI-streaming investigation. The same\n# get_a2ui_tools tool is bound directly (NOT auto-injected via\n# CopilotKitMiddleware), so the ONLY difference vs the prior version is\n# StateGraph -> create_agent.\nis_fast_api = os.environ.get(\"LANGGRAPH_FAST_API\", \"false\").lower() == \"true\"\n\nif is_fast_api:\n from langgraph.checkpoint.memory import MemorySaver\n\n graph = create_agent(\n model=base_model,\n tools=TOOLS,\n system_prompt=SYSTEM_PROMPT,\n checkpointer=MemorySaver(),\n )\nelse:\n graph = create_agent(\n model=base_model,\n tools=TOOLS,\n system_prompt=SYSTEM_PROMPT,\n )\n", + "language": "python", + "type": "file" + }, + { + "name": "agent.ts", + "content": "/**\n * Dynamic A2UI agent (prebuilt).\n *\n * Uses LangChain's `createAgent` prebuilt with the AG-UI `getA2UITools`\n * factory. A secondary LLM (the subagent shipped inside the factory) designs\n * the A2UI components and data; the AG-UI middleware detects the resulting\n * `a2ui_operations` payload in the tool result and renders the surface.\n */\n\nimport { createAgent } from \"langchain\";\nimport { copilotkitMiddleware } from \"@copilotkit/sdk-js/langgraph\";\nimport { ChatOpenAI } from \"@langchain/openai\";\nimport { getA2UITools } from \"@ag-ui/langgraph\";\n\nconst CUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\";\n\n// Project-specific composition rules — tells the subagent how to use the\n// pre-made domain components (HotelCard, ProductCard, TeamMemberCard) shipped\n// in the dojo's dynamic catalog.\nconst COMPOSITION_GUIDE = `\n## Available Pre-made Components\n\nYou have 4 components. Use Row as the root with structural children to repeat a card per item.\n\n### Row\nLayout container. Use structural children to repeat a card template:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard\nProps: name, location, rating (number 0-5), pricePerNight, amenities (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"HotelCard\",\"name\":{\"path\":\"name\"},\"location\":{\"path\":\"location\"},\n \"rating\":{\"path\":\"rating\"},\"pricePerNight\":{\"path\":\"pricePerNight\"},\n \"action\":{\"event\":{\"name\":\"book\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### ProductCard\nProps: name, price, rating (number 0-5), description (optional), badge (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"ProductCard\",\"name\":{\"path\":\"name\"},\"price\":{\"path\":\"price\"},\n \"rating\":{\"path\":\"rating\"},\"description\":{\"path\":\"description\"},\n \"action\":{\"event\":{\"name\":\"select\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### TeamMemberCard\nProps: name, role, department (optional), email (optional), avatarUrl (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"TeamMemberCard\",\"name\":{\"path\":\"name\"},\"role\":{\"path\":\"role\"},\n \"department\":{\"path\":\"department\"},\"email\":{\"path\":\"email\"},\n \"action\":{\"event\":{\"name\":\"contact\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- Inside templates, use RELATIVE paths (no leading slash): {\"path\":\"name\"} not {\"path\":\"/name\"}\n- Always provide data in the \"data\" argument as {\"items\":[...]}\n- Pick the card type that best matches the user's request\n- Generate 3-4 realistic items with diverse data\n`;\n\nconst a2uiTool = getA2UITools({\n model: new ChatOpenAI({ model: \"gpt-4o\" }),\n defaultCatalogId: CUSTOM_CATALOG_ID,\n guidelines: { compositionGuide: COMPOSITION_GUIDE },\n});\n\nconst a2uiDynamicSchemaAgent = createAgent({\n model: \"openai:gpt-4o\",\n // Cast: tool returned by `getA2UITools` is typed against `@ag-ui/langgraph`'s\n // own `@langchain/core` peer, which can skew vs. the consumer's pin.\n tools: [a2uiTool as any],\n middleware: [copilotkitMiddleware],\n systemPrompt: `You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (product comparisons, dashboards, lists, cards, etc.),\nuse the generate_a2ui tool to create a dynamic A2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.`,\n});\n\n// Export the inner graph, not the ReactAgent wrapper, so LangGraph Platform can\n// inject its managed checkpointer (the wrapper swallows the injection —\n// langchainjs#10144 — causing MISSING_CHECKPOINTER on the 2nd turn deployed).\nexport const a2uiDynamicSchemaGraph = a2uiDynamicSchemaAgent.graph;\n", + "language": "ts", + "type": "file" + } + ], + "langgraph-typescript::a2ui_fixed_schema": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { fixedSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Search flights\",\n message: \"Find flights from SFO to JFK for next Tuesday.\",\n },\n {\n title: \"Search hotels\",\n message: \"Find hotels in downtown Manhattan for next weekend.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Fixed Schema\n\n## What This Demo Shows\n\nFixed-schema A2UI rendering where the UI schema is pre-defined in JSON files and only the data changes per invocation.\n\n1. **Pre-built schemas**: Flight card layout loaded from `flight_schema.json`\n2. **Data binding**: The agent populates flight data into the schema template\n3. **Action handlers**: \"Select\" button triggers an optimistic booking confirmation\n4. **No streaming**: All cards render at once after the tool completes\n", + "language": "markdown", + "type": "file" + }, + { + "name": "agent.py", + "content": "\"\"\"\nFixed-schema A2UI: flight + hotel search results (no streaming).\n\nSchema is loaded from JSON files. Only the data changes per invocation.\nThe hotel search demonstrates a custom catalog with a StarRating component.\n\"\"\"\n\nimport os\nfrom pathlib import Path\nfrom typing import Any, List\nfrom typing_extensions import TypedDict\n\nfrom copilotkit import a2ui\nfrom langchain.tools import tool\nfrom langchain_openai import ChatOpenAI\nfrom langchain_core.messages import SystemMessage\nfrom langchain_core.runnables import RunnableConfig\nfrom langgraph.graph import StateGraph, END, MessagesState\nfrom langgraph.prebuilt import ToolNode\n\n\n# --- Flight search (basic catalog) ---\n\nFLIGHT_SURFACE_ID = \"flight-search-results\"\nFLIGHT_SCHEMA = a2ui.load_schema(\n Path(__file__).parent / \"schemas\" / \"flight_schema.json\"\n)\n\nclass Flight(TypedDict):\n id: str\n airline: str\n airlineLogo: str\n flightNumber: str\n origin: str\n destination: str\n date: str\n departureTime: str\n arrivalTime: str\n duration: str\n status: str\n statusIcon: str\n price: str\n\n\n@tool\ndef search_flights(flights: list[Flight]) -> str:\n \"\"\"Search for flights and display the results as rich cards.\n\n Each flight must have: id, airline (e.g. \"United Airlines\"),\n airlineLogo (use Google favicon API: https://www.google.com/s2/favicons?domain={airline_domain}&sz=128\n e.g. \"https://www.google.com/s2/favicons?domain=united.com&sz=128\" for United,\n \"https://www.google.com/s2/favicons?domain=delta.com&sz=128\" for Delta,\n \"https://www.google.com/s2/favicons?domain=aa.com&sz=128\" for American,\n \"https://www.google.com/s2/favicons?domain=alaskaair.com&sz=128\" for Alaska),\n flightNumber, origin, destination,\n date (short readable format like \"Tue, Mar 18\" — use near-future dates),\n departureTime, arrivalTime,\n duration (e.g. \"4h 25m\"), status (e.g. \"On Time\" or \"Delayed\"),\n statusIcon (colored dot: use \"https://placehold.co/12/22c55e/22c55e.png\"\n for On Time, \"https://placehold.co/12/eab308/eab308.png\" for Delayed,\n \"https://placehold.co/12/ef4444/ef4444.png\" for Cancelled),\n and price (e.g. \"$289\").\n \"\"\"\n return a2ui.render(\n operations=[\n a2ui.create_surface(FLIGHT_SURFACE_ID, catalog_id=CUSTOM_CATALOG_ID),\n a2ui.update_components(FLIGHT_SURFACE_ID, FLIGHT_SCHEMA),\n a2ui.update_data_model(FLIGHT_SURFACE_ID, {\"flights\": flights}),\n ],\n )\n\n\n# --- Hotel search (custom catalog with StarRating) ---\n\nCUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/fixed_catalog.json\"\nHOTEL_SURFACE_ID = \"hotel-search-results\"\nHOTEL_SCHEMA = a2ui.load_schema(\n Path(__file__).parent / \"schemas\" / \"hotel_schema.json\"\n)\n\nclass Hotel(TypedDict):\n id: str\n name: str\n location: str\n rating: float\n price: str\n\n\n@tool\ndef search_hotels(hotels: list[Hotel]) -> str:\n \"\"\"Search for hotels and display the results as rich cards with star ratings.\n\n Each hotel must have: id, name (e.g. \"The Plaza\"),\n location (e.g. \"Midtown Manhattan, NYC\"),\n rating (float 0-5, e.g. 4.5),\n and price (per night, e.g. \"$350\").\n\n Generate 3-4 realistic hotel results.\n \"\"\"\n return a2ui.render(\n operations=[\n a2ui.create_surface(HOTEL_SURFACE_ID, catalog_id=CUSTOM_CATALOG_ID),\n a2ui.update_components(HOTEL_SURFACE_ID, HOTEL_SCHEMA),\n a2ui.update_data_model(HOTEL_SURFACE_ID, {\"hotels\": hotels}),\n ],\n )\n\n\nTOOLS = [search_flights, search_hotels]\n\n\nclass AgentState(MessagesState):\n tools: List[Any]\n\n\nSYSTEM_PROMPT = \"\"\"You are a helpful travel assistant that can search for flights and hotels.\n\nWhen the user asks about flights, use the search_flights tool.\nWhen the user asks about hotels, use the search_hotels tool.\nIMPORTANT: After calling a tool, do NOT repeat or summarize the data in your text response. The tool renders a rich UI automatically. Just say something brief like \"Here are your results\" or ask if they'd like to book.\n\nFor flights, each needs: id, airline, airlineLogo (Google favicon API), flightNumber, origin, destination,\ndate, departureTime, arrivalTime, duration, status, statusIcon, and price.\n\nFor hotels, each needs: id, name, location, rating (float 0-5), and price (per night).\n\nGenerate 3-5 realistic results.\"\"\"\n\n\nasync def chat_node(state: AgentState, config: RunnableConfig):\n model = ChatOpenAI(model=\"gpt-4o\")\n model = model.bind_tools(TOOLS, parallel_tool_calls=False)\n\n response = await model.ainvoke([\n SystemMessage(content=SYSTEM_PROMPT),\n *state[\"messages\"],\n ], config)\n\n return {\"messages\": [response]}\n\n\ndef route_after_chat(state: AgentState):\n last_message = state[\"messages\"][-1]\n if hasattr(last_message, \"tool_calls\") and last_message.tool_calls:\n return \"tool_node\"\n return END\n\n\nworkflow = StateGraph(AgentState)\nworkflow.add_node(\"chat_node\", chat_node)\nworkflow.add_node(\"tool_node\", ToolNode(tools=TOOLS))\nworkflow.set_entry_point(\"chat_node\")\nworkflow.add_conditional_edges(\"chat_node\", route_after_chat)\nworkflow.add_edge(\"tool_node\", \"chat_node\")\n\nis_fast_api = os.environ.get(\"LANGGRAPH_FAST_API\", \"false\").lower() == \"true\"\n\nif is_fast_api:\n from langgraph.checkpoint.memory import MemorySaver\n memory = MemorySaver()\n graph = workflow.compile(checkpointer=memory)\nelse:\n graph = workflow.compile()\n", + "language": "python", + "type": "file" + }, + { + "name": "agent.ts", + "content": "/**\n * Fixed-schema A2UI agent (prebuilt).\n *\n * Pre-built component layouts for flight and hotel cards. The agent only\n * supplies the data; layout/styling is fixed in code. Demonstrates the\n * \"controlled gen-UI\" pattern: author owns the UI shape, agent owns the data.\n */\n\nimport { createAgent } from \"langchain\";\nimport { copilotkitMiddleware } from \"@copilotkit/sdk-js/langgraph\";\nimport { tool } from \"@langchain/core/tools\";\n\nconst CUSTOM_CATALOG_ID =\n \"https://a2ui.org/demos/dojo/fixed_catalog.json\";\n\nconst A2UI_OPERATIONS_KEY = \"a2ui_operations\";\n\n// Flight search layout — agent supplies `flights` array; rendering is fixed.\nconst FLIGHT_SURFACE_ID = \"flight-search-results\";\nconst FLIGHT_SCHEMA: Array> = [\n {\n id: \"root\",\n component: \"Row\",\n children: { componentId: \"flight-card\", path: \"/flights\" },\n gap: 16,\n },\n {\n id: \"flight-card\",\n component: \"FlightCard\",\n airline: { path: \"airline\" },\n airlineLogo: { path: \"airlineLogo\" },\n flightNumber: { path: \"flightNumber\" },\n origin: { path: \"origin\" },\n destination: { path: \"destination\" },\n date: { path: \"date\" },\n departureTime: { path: \"departureTime\" },\n arrivalTime: { path: \"arrivalTime\" },\n duration: { path: \"duration\" },\n status: { path: \"status\" },\n price: { path: \"price\" },\n action: {\n event: {\n name: \"book_flight\",\n context: {\n flightNumber: { path: \"flightNumber\" },\n origin: { path: \"origin\" },\n destination: { path: \"destination\" },\n price: { path: \"price\" },\n },\n },\n },\n },\n];\n\n// Hotel search layout — agent supplies `hotels` array; rendering is fixed.\nconst HOTEL_SURFACE_ID = \"hotel-search-results\";\nconst HOTEL_SCHEMA: Array> = [\n {\n id: \"root\",\n component: \"Row\",\n children: { componentId: \"hotel-card\", path: \"/hotels\" },\n gap: 16,\n },\n {\n id: \"hotel-card\",\n component: \"HotelCard\",\n name: { path: \"name\" },\n location: { path: \"location\" },\n rating: { path: \"rating\" },\n pricePerNight: { path: \"price\" },\n action: {\n event: {\n name: \"book_hotel\",\n context: {\n hotelName: { path: \"name\" },\n price: { path: \"price\" },\n },\n },\n },\n },\n];\n\nfunction renderOperations(\n surfaceId: string,\n catalogId: string,\n schema: Array>,\n data: Record,\n): string {\n const ops = [\n {\n version: \"v0.9\",\n createSurface: { surfaceId, catalogId },\n },\n {\n version: \"v0.9\",\n updateComponents: { surfaceId, components: schema },\n },\n {\n version: \"v0.9\",\n updateDataModel: { surfaceId, path: \"/\", value: data },\n },\n ];\n return JSON.stringify({ [A2UI_OPERATIONS_KEY]: ops });\n}\n\nconst searchFlights = tool(\n async ({ flights }: { flights: Array> }) => {\n return renderOperations(\n FLIGHT_SURFACE_ID,\n CUSTOM_CATALOG_ID,\n FLIGHT_SCHEMA,\n { flights },\n );\n },\n {\n name: \"search_flights\",\n description:\n \"Search for flights and display the results as rich cards. Each flight \" +\n \"must have: id, airline (e.g. 'United Airlines'), airlineLogo (use Google \" +\n \"favicon API like 'https://www.google.com/s2/favicons?domain=united.com&sz=128'), \" +\n \"flightNumber, origin, destination, date (e.g. 'Tue, Mar 18'), departureTime, \" +\n \"arrivalTime, duration (e.g. '4h 25m'), status ('On Time' or 'Delayed'), \" +\n \"and price (e.g. '$289').\",\n schema: {\n type: \"object\",\n properties: {\n flights: {\n type: \"array\",\n items: { type: \"object\" },\n description: \"Array of flight result objects.\",\n },\n },\n required: [\"flights\"],\n } as any,\n },\n);\n\nconst searchHotels = tool(\n async ({ hotels }: { hotels: Array> }) => {\n return renderOperations(\n HOTEL_SURFACE_ID,\n CUSTOM_CATALOG_ID,\n HOTEL_SCHEMA,\n { hotels },\n );\n },\n {\n name: \"search_hotels\",\n description:\n \"Search for hotels and display the results as rich cards with star ratings. \" +\n \"Each hotel must have: id, name (e.g. 'The Plaza'), location \" +\n \"(e.g. 'Midtown Manhattan, NYC'), rating (float 0-5, e.g. 4.5), and \" +\n \"price (per night, e.g. '$350'). Generate 3-4 realistic results.\",\n schema: {\n type: \"object\",\n properties: {\n hotels: {\n type: \"array\",\n items: { type: \"object\" },\n description: \"Array of hotel result objects.\",\n },\n },\n required: [\"hotels\"],\n } as any,\n },\n);\n\nconst a2uiFixedSchemaAgent = createAgent({\n model: \"openai:gpt-4o\",\n tools: [searchFlights, searchHotels],\n middleware: [copilotkitMiddleware],\n systemPrompt: `You are a helpful travel assistant that can search for flights and hotels.\n\nWhen the user asks about flights, use the search_flights tool.\nWhen the user asks about hotels, use the search_hotels tool.\nIMPORTANT: After calling a tool, do NOT repeat or summarize the data in your text response. The tool renders a rich UI automatically. Just say something brief like \"Here are your results\" or ask if they'd like to book.\n\nFor flights, each needs: id, airline, airlineLogo (Google favicon API), flightNumber, origin, destination,\ndate, departureTime, arrivalTime, duration, status, and price.\n\nFor hotels, each needs: id, name, location, rating (float 0-5), and price (per night).\n\nGenerate 3-5 realistic results.`,\n});\n\n// Export the inner graph, not the ReactAgent wrapper, so LangGraph Platform can\n// inject its managed checkpointer (the wrapper swallows the injection —\n// langchainjs#10144 — causing MISSING_CHECKPOINTER on the 2nd turn deployed).\nexport const a2uiFixedSchemaGraph = a2uiFixedSchemaAgent.graph;\n", + "language": "ts", + "type": "file" + } + ], + "langgraph-typescript::a2ui_advanced": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React, { memo } from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n useRenderTool,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\nimport { z } from \"zod\";\n\nexport const dynamic = \"force-dynamic\";\n\n// ---------------------------------------------------------------------------\n// 1. Custom Progress Renderer for Dynamic A2UI\n// Overrides the built-in render_a2ui progress indicator with a branded\n// violet skeleton showing live component/item counters.\n// ---------------------------------------------------------------------------\n\nconst A2UIProgress = memo(function A2UIProgress({\n parameters,\n}: {\n parameters: Record;\n}) {\n const componentCount = Array.isArray(parameters?.components)\n ? parameters.components.length\n : 0;\n const itemCount = Array.isArray(parameters?.items)\n ? parameters.items.length\n : 0;\n\n return (\n
\n {/* Header with branded spinner */}\n
\n
\n
\n
\n
\n
\n
\n
\n \n Custom A2UI Progress\n \n

\n useRenderTool("render_a2ui")\n

\n
\n
\n \n {componentCount > 0 ? `${componentCount} nodes` : \"parsing...\"}\n \n
\n\n {/* Live streaming counters */}\n
\n
\n
{componentCount}
\n
Components
\n
\n
\n
{itemCount}
\n
Data Items
\n
\n
\n
\n {parameters?.root ? \"1\" : \"0\"}\n
\n
Root Set
\n
\n
\n\n {/* Animated skeleton cards that light up as items stream in */}\n
\n {[0, 1, 2].map((i) => (\n i ? 1 : 0.4, transition: \"opacity 0.3s\" }}\n >\n
\n
\n
\n
\n
\n ))}\n
\n
\n );\n});\n\n// ---------------------------------------------------------------------------\n// 2. Frontend Action Handler (optimistic UI on button clicks)\n// Instant response when buttons are clicked — no server round-trip.\n// ---------------------------------------------------------------------------\n\nfunction useAdvancedA2UIFeatures() {\n // Custom progress renderer — overrides the built-in render_a2ui indicator\n useRenderTool(\n {\n name: \"render_a2ui\",\n parameters: z.any(),\n render: ({ status, parameters }) => {\n if (status === \"complete\") return <>;\n return ;\n },\n },\n [],\n );\n\n}\n\n// ---------------------------------------------------------------------------\n// Page\n// ---------------------------------------------------------------------------\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useAdvancedA2UIFeatures();\n\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Hotel comparison\",\n message:\n \"Use the generate_a2ui tool to create a comparison of 3 hotels with name, location, price per night, and star rating using the StarRating component.\",\n },\n {\n title: \"Product comparison\",\n message:\n \"Use the generate_a2ui tool to create a product comparison of 3 headphones with name, price, rating, a short description, and a Select button on each card.\",\n },\n {\n title: \"Team directory\",\n message:\n \"Use the generate_a2ui tool to create a team directory with 4 people showing name, role, department, and a Contact button.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Advanced\n\n## What This Demo Shows\n\nAdvanced A2UI patterns with dynamic schema generation.\n\n1. **Dynamic schema**: Agent generates UI components on the fly based on conversation context\n2. **Same dynamic backend**: Reuses the dynamic schema agent\n", + "language": "markdown", + "type": "file" + } + ], + "langgraph-typescript::a2ui_recovery": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Recover from an error\",\n message: \"Compare 3 luxury hotels with ratings and prices.\",\n },\n {\n title: \"Hard failure\",\n message: \"Compare 3 broken hotels with ratings and prices.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Error Recovery\n\n## What This Demo Shows\n\nAutomatic, no-wipe recovery when a secondary LLM generates an **invalid** A2UI surface.\n\n1. **Server-side validation gate**: Each generated component tree is validated before it can paint. Invalid trees are suppressed — the user never sees a broken surface flash and disappear.\n2. **Structured-error feedback loop**: The validation errors are fed back to the generating sub-agent, which regenerates (up to a configurable cap, default 3 attempts).\n3. **No wipes**: Only a validated surface ever commits. Faulty attempts never paint, so there's no stream → error → wipe → retry flicker.\n4. **Tasteful hard-failure**: If every attempt fails, a clean failure state is shown and the conversation stays usable. Developers get full per-attempt detail; end users don't see transient noise.\n\n## How to Interact\n\nTwo suggestions are wired for this demo:\n\n- **\"Compare 3 luxury hotels with ratings and prices.\"** — the first generated surface references a UI template the model \"forgot\" to include (a dangling child reference). The gate rejects it, the error is fed back, and the **second attempt is valid** and paints. You see the recovered surface, not the broken one.\n- **\"Compare 3 broken hotels with ratings and prices.\"** — every attempt is invalid, so the loop **exhausts** and the clean hard-failure state appears. The chat remains interactive afterward.\n\n## How It Works Technically\n\n- The **commit point is the component-tree close** — the only moment a tree is knowable as complete — where the middleware runs `validateA2UIComponents` and emits the surface **only if valid**.\n- On rejection, `augmentPromptWithValidationErrors` appends the machine-readable errors to the sub-agent's prompt and the adapter re-invokes it (`runA2UIGenerationWithRecovery`), never retrying after a validated paint.\n- Recovery is surfaced as an `a2ui_recovery` activity: a delayed \"Retrying…\" hint for slow/repeated retries, and a hard-failure state once the attempt cap is reached.\n- The retry cap, the threshold before the retry hint appears, and how much debug state is exposed are all configurable.\n\nThis feature drives errors deterministically via ai-mock fixtures so the recovery and hard-failure paths can be demonstrated and tested reliably.\n", + "language": "markdown", + "type": "file" + }, + { + "name": "agent.ts", + "content": "/**\n * A2UI recovery agent (OSS-162) — DRAFT showcase, verify before wiring.\n *\n * A clone of `a2ui_dynamic_schema` that showcases the error-recovery loop. It\n * needs NO new mechanism: on this branch `getA2UITools` already runs\n * `runA2UIGenerationWithRecovery` (default 3 attempts) and the middleware gate\n * runs at the component-close boundary — both default to STRUCTURAL validation\n * when no catalog is supplied (missing root, dangling child reference,\n * unresolved binding, malformed/empty components). So this rides the exact same\n * runtime A2UI wiring as the existing demos (add it to the runtime `a2ui.agents`\n * list); no catalog/`schema` and no A/B middleware choice required.\n *\n * In the dojo demo the sub-agent's render_a2ui output is driven by aimock: the\n * first attempt emits a structurally-invalid surface (a Row whose repeated child\n * references a `card` component the model forgot to include → \"unresolved child\"),\n * which the gate suppresses (no wipe) and the loop regenerates with the error fed\n * back, then a valid surface paints. A second prompt forces repeated failure to\n * demonstrate the tasteful hard-failure state.\n *\n * (Catalog-aware SEMANTIC validation — unknown component / missing required prop —\n * is the separate, optional scope that would need the catalog wired; not used here.)\n */\n\nimport { createAgent } from \"langchain\";\nimport { copilotkitMiddleware } from \"@copilotkit/sdk-js/langgraph\";\nimport { ChatOpenAI } from \"@langchain/openai\";\nimport { getA2UITools, type A2UIAttemptRecord } from \"@ag-ui/langgraph\";\n\nconst CUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\";\n\nconst COMPOSITION_GUIDE = `\n## Available Pre-made Components\n\nUse Row as the root with structural children to repeat a card per item.\n\n### Row\nLayout container. Repeat a card template via structural children:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard / ProductCard / TeamMemberCard\nCard components bound to per-item data (relative paths inside the template).\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- ALWAYS include the referenced card component in the components array.\n- Inside templates, use RELATIVE paths (no leading slash): {\"path\":\"name\"} not {\"path\":\"/name\"}\n- Always provide data in the \"data\" argument as {\"items\":[...]}\n- Generate 3-4 realistic items with diverse data.\n`;\n\nconst a2uiTool = getA2UITools({\n model: new ChatOpenAI({ model: \"gpt-4o\" }),\n defaultCatalogId: CUSTOM_CATALOG_ID,\n guidelines: { compositionGuide: COMPOSITION_GUIDE },\n // Recovery loop runs by default; set explicitly for the showcase. No catalog\n // → structural validation (which is all this demo's error needs).\n recovery: { maxAttempts: 3 },\n onA2UIAttempt: (rec: A2UIAttemptRecord) => {\n // Dev observability: each attempt (incl. rejected ones) is logged.\n // eslint-disable-next-line no-console\n console.log(\n `[a2ui recovery] attempt ${rec.attempt}: ${rec.ok ? \"valid\" : \"invalid\"}`,\n rec.errors,\n );\n },\n});\n\nexport const a2uiRecoveryGraph = createAgent({\n model: \"openai:gpt-4o\",\n // Cast: tool typed against @ag-ui/langgraph's own @langchain/core peer.\n tools: [a2uiTool as any],\n middleware: [copilotkitMiddleware],\n systemPrompt: `You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (hotel/product comparisons, team rosters, lists, cards, etc.),\nuse the generate_a2ui tool to create a dynamic A2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.`,\n});\n", + "language": "ts", + "type": "file" + } + ], "mastra::agentic_chat": [ { "name": "page.tsx", @@ -1568,7 +1762,7 @@ }, { "name": "backend_tool_rendering.py", - "content": "\"\"\"Backend Tool Rendering feature.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom textwrap import dedent\nfrom zoneinfo import ZoneInfo\n\nimport httpx\nfrom pydantic_ai import Agent\n\nagent = Agent(\n \"openai:gpt-4o-mini\",\n instructions=dedent(\n \"\"\"\n You are a helpful weather assistant that provides accurate weather information.\n\n Your primary function is to help users get weather details for specific locations. When responding:\n - Always ask for a location if none is provided\n - If the location name isn’t in English, please translate it\n - If giving a location with multiple parts (e.g. \"New York, NY\"), use the most relevant part (e.g. \"New York\")\n - Include relevant details like humidity, wind conditions, and precipitation\n - Keep responses concise but informative\n\n Use the get_weather tool to fetch current weather data.\n \"\"\"\n ),\n)\napp = agent.to_ag_ui()\n\n\ndef get_weather_condition(code: int) -> str:\n \"\"\"Map weather code to human-readable condition.\n\n Args:\n code: WMO weather code.\n\n Returns:\n Human-readable weather condition string.\n \"\"\"\n conditions = {\n 0: \"Clear sky\",\n 1: \"Mainly clear\",\n 2: \"Partly cloudy\",\n 3: \"Overcast\",\n 45: \"Foggy\",\n 48: \"Depositing rime fog\",\n 51: \"Light drizzle\",\n 53: \"Moderate drizzle\",\n 55: \"Dense drizzle\",\n 56: \"Light freezing drizzle\",\n 57: \"Dense freezing drizzle\",\n 61: \"Slight rain\",\n 63: \"Moderate rain\",\n 65: \"Heavy rain\",\n 66: \"Light freezing rain\",\n 67: \"Heavy freezing rain\",\n 71: \"Slight snow fall\",\n 73: \"Moderate snow fall\",\n 75: \"Heavy snow fall\",\n 77: \"Snow grains\",\n 80: \"Slight rain showers\",\n 81: \"Moderate rain showers\",\n 82: \"Violent rain showers\",\n 85: \"Slight snow showers\",\n 86: \"Heavy snow showers\",\n 95: \"Thunderstorm\",\n 96: \"Thunderstorm with slight hail\",\n 99: \"Thunderstorm with heavy hail\",\n }\n return conditions.get(code, \"Unknown\")\n\n\n@agent.tool_plain\nasync def get_weather(location: str) -> dict[str, str | float]:\n \"\"\"Get current weather for a location.\n\n Args:\n location: City name.\n\n Returns:\n Dictionary with weather information including temperature, feels like,\n humidity, wind speed, wind gust, conditions, and location name.\n \"\"\"\n async with httpx.AsyncClient() as client:\n # Geocode the location\n geocoding_url = (\n f\"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1\"\n )\n geocoding_response = await client.get(geocoding_url)\n geocoding_data = geocoding_response.json()\n\n if not geocoding_data.get(\"results\"):\n raise ValueError(f\"Location '{location}' not found\")\n\n result = geocoding_data[\"results\"][0]\n latitude = result[\"latitude\"]\n longitude = result[\"longitude\"]\n name = result[\"name\"]\n\n # Get weather data\n weather_url = (\n f\"https://api.open-meteo.com/v1/forecast?\"\n f\"latitude={latitude}&longitude={longitude}\"\n f\"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,\"\n f\"wind_speed_10m,wind_gusts_10m,weather_code\"\n )\n weather_response = await client.get(weather_url)\n weather_data = weather_response.json()\n\n current = weather_data[\"current\"]\n\n return {\n \"temperature\": current[\"temperature_2m\"],\n \"feelsLike\": current[\"apparent_temperature\"],\n \"humidity\": current[\"relative_humidity_2m\"],\n \"windSpeed\": current[\"wind_speed_10m\"],\n \"windGust\": current[\"wind_gusts_10m\"],\n \"conditions\": get_weather_condition(current[\"weather_code\"]),\n \"location\": name,\n }\n", + "content": "\"\"\"Backend Tool Rendering feature.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom datetime import datetime\nfrom textwrap import dedent\nfrom zoneinfo import ZoneInfo\n\nimport httpx\nfrom pydantic_ai import Agent\n\n\ndef _mock_weather(location: str) -> dict[str, str | float]:\n \"\"\"Return deterministic canned weather data for tests.\n\n Used when ``AG_UI_MOCK_WEATHER`` is set so e2e runs don't depend on the\n live open-meteo API (which rate-limits CI's shared egress IPs).\n \"\"\"\n return {\n \"temperature\": 21.0,\n \"feelsLike\": 20.0,\n \"humidity\": 65.0,\n \"windSpeed\": 12.0,\n \"windGust\": 18.0,\n \"conditions\": get_weather_condition(1),\n \"location\": location,\n }\n\n\nagent = Agent(\n \"openai:gpt-4o-mini\",\n instructions=dedent(\n \"\"\"\n You are a helpful weather assistant that provides accurate weather information.\n\n Your primary function is to help users get weather details for specific locations. When responding:\n - Always ask for a location if none is provided\n - If the location name isn’t in English, please translate it\n - If giving a location with multiple parts (e.g. \"New York, NY\"), use the most relevant part (e.g. \"New York\")\n - Include relevant details like humidity, wind conditions, and precipitation\n - Keep responses concise but informative\n\n Use the get_weather tool to fetch current weather data.\n \"\"\"\n ),\n)\napp = agent.to_ag_ui()\n\n\ndef get_weather_condition(code: int) -> str:\n \"\"\"Map weather code to human-readable condition.\n\n Args:\n code: WMO weather code.\n\n Returns:\n Human-readable weather condition string.\n \"\"\"\n conditions = {\n 0: \"Clear sky\",\n 1: \"Mainly clear\",\n 2: \"Partly cloudy\",\n 3: \"Overcast\",\n 45: \"Foggy\",\n 48: \"Depositing rime fog\",\n 51: \"Light drizzle\",\n 53: \"Moderate drizzle\",\n 55: \"Dense drizzle\",\n 56: \"Light freezing drizzle\",\n 57: \"Dense freezing drizzle\",\n 61: \"Slight rain\",\n 63: \"Moderate rain\",\n 65: \"Heavy rain\",\n 66: \"Light freezing rain\",\n 67: \"Heavy freezing rain\",\n 71: \"Slight snow fall\",\n 73: \"Moderate snow fall\",\n 75: \"Heavy snow fall\",\n 77: \"Snow grains\",\n 80: \"Slight rain showers\",\n 81: \"Moderate rain showers\",\n 82: \"Violent rain showers\",\n 85: \"Slight snow showers\",\n 86: \"Heavy snow showers\",\n 95: \"Thunderstorm\",\n 96: \"Thunderstorm with slight hail\",\n 99: \"Thunderstorm with heavy hail\",\n }\n return conditions.get(code, \"Unknown\")\n\n\n@agent.tool_plain\nasync def get_weather(location: str) -> dict[str, str | float]:\n \"\"\"Get current weather for a location.\n\n Args:\n location: City name.\n\n Returns:\n Dictionary with weather information including temperature, feels like,\n humidity, wind speed, wind gust, conditions, and location name.\n \"\"\"\n if os.getenv(\"AG_UI_MOCK_WEATHER\"):\n return _mock_weather(location)\n\n async with httpx.AsyncClient() as client:\n # Geocode the location\n geocoding_url = (\n f\"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1\"\n )\n geocoding_response = await client.get(geocoding_url)\n geocoding_data = geocoding_response.json()\n\n if not geocoding_data.get(\"results\"):\n raise ValueError(f\"Location '{location}' not found\")\n\n result = geocoding_data[\"results\"][0]\n latitude = result[\"latitude\"]\n longitude = result[\"longitude\"]\n name = result[\"name\"]\n\n # Get weather data\n weather_url = (\n f\"https://api.open-meteo.com/v1/forecast?\"\n f\"latitude={latitude}&longitude={longitude}\"\n f\"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,\"\n f\"wind_speed_10m,wind_gusts_10m,weather_code\"\n )\n weather_response = await client.get(weather_url)\n weather_data = weather_response.json()\n\n current = weather_data[\"current\"]\n\n return {\n \"temperature\": current[\"temperature_2m\"],\n \"feelsLike\": current[\"apparent_temperature\"],\n \"humidity\": current[\"relative_humidity_2m\"],\n \"windSpeed\": current[\"wind_speed_10m\"],\n \"windGust\": current[\"wind_gusts_10m\"],\n \"conditions\": get_weather_condition(current[\"weather_code\"]),\n \"location\": name,\n }\n", "language": "python", "type": "file" } @@ -1726,7 +1920,7 @@ }, { "name": "backend_tool_rendering.py", - "content": "\"\"\"Basic Chat feature.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import FastAPI\nfrom ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint, AGUIToolset\nfrom google.adk.agents import LlmAgent\nfrom google.adk import tools as adk_tools\nimport httpx\nimport json\n\n# Compatibility shim for PreloadMemoryTool (renamed in newer ADK versions)\ntry:\n PreloadMemoryTool = adk_tools.preload_memory.PreloadMemoryTool\nexcept AttributeError:\n PreloadMemoryTool = adk_tools.preload_memory_tool.PreloadMemoryTool\n\n\ndef get_weather_condition(code: int) -> str:\n \"\"\"Map weather code to human-readable condition.\n\n Args:\n code: WMO weather code.\n\n Returns:\n Human-readable weather condition string.\n \"\"\"\n conditions = {\n 0: \"Clear sky\",\n 1: \"Mainly clear\",\n 2: \"Partly cloudy\",\n 3: \"Overcast\",\n 45: \"Foggy\",\n 48: \"Depositing rime fog\",\n 51: \"Light drizzle\",\n 53: \"Moderate drizzle\",\n 55: \"Dense drizzle\",\n 56: \"Light freezing drizzle\",\n 57: \"Dense freezing drizzle\",\n 61: \"Slight rain\",\n 63: \"Moderate rain\",\n 65: \"Heavy rain\",\n 66: \"Light freezing rain\",\n 67: \"Heavy freezing rain\",\n 71: \"Slight snow fall\",\n 73: \"Moderate snow fall\",\n 75: \"Heavy snow fall\",\n 77: \"Snow grains\",\n 80: \"Slight rain showers\",\n 81: \"Moderate rain showers\",\n 82: \"Violent rain showers\",\n 85: \"Slight snow showers\",\n 86: \"Heavy snow showers\",\n 95: \"Thunderstorm\",\n 96: \"Thunderstorm with slight hail\",\n 99: \"Thunderstorm with heavy hail\",\n }\n return conditions.get(code, \"Unknown\")\n\n\nasync def get_weather(location: str) -> dict[str, str | float]:\n \"\"\"Get current weather for a location.\n\n Args:\n location: City name.\n\n Returns:\n Dictionary with weather information including temperature, feels like,\n humidity, wind speed, wind gust, conditions, and location name.\n \"\"\"\n async with httpx.AsyncClient() as client:\n # Geocode the location\n geocoding_url = (\n f\"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1\"\n )\n geocoding_response = await client.get(geocoding_url)\n geocoding_data = geocoding_response.json()\n\n if not geocoding_data.get(\"results\"):\n raise ValueError(f\"Location '{location}' not found\")\n\n result = geocoding_data[\"results\"][0]\n latitude = result[\"latitude\"]\n longitude = result[\"longitude\"]\n name = result[\"name\"]\n\n # Get weather data\n weather_url = (\n f\"https://api.open-meteo.com/v1/forecast?\"\n f\"latitude={latitude}&longitude={longitude}\"\n f\"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,\"\n f\"wind_speed_10m,wind_gusts_10m,weather_code\"\n )\n weather_response = await client.get(weather_url)\n weather_data = weather_response.json()\n\n current = weather_data[\"current\"]\n\n return {\n \"temperature\": current[\"temperature_2m\"],\n \"feelsLike\": current[\"apparent_temperature\"],\n \"humidity\": current[\"relative_humidity_2m\"],\n \"windSpeed\": current[\"wind_speed_10m\"],\n \"windGust\": current[\"wind_gusts_10m\"],\n \"conditions\": get_weather_condition(current[\"weather_code\"]),\n \"location\": name,\n }\n\n\n# Create a sample ADK agent (this would be your actual agent)\nsample_agent = LlmAgent(\n name=\"assistant\",\n model=\"gemini-2.5-flash\",\n instruction=\"\"\"\n You are a helpful weather assistant that provides accurate weather information.\n\n Your primary function is to help users get weather details for specific locations. When responding:\n - Always ask for a location if none is provided\n - If the location name isn’t in English, please translate it\n - If giving a location with multiple parts (e.g. \"New York, NY\"), use the most relevant part (e.g. \"New York\")\n - Include relevant details like humidity, wind conditions, and precipitation\n - Keep responses concise but informative\n\n Use the get_weather tool to fetch current weather data.\n \"\"\",\n tools=[\n AGUIToolset(), # Add the tools provided by the AG-UI client\n PreloadMemoryTool(),\n get_weather,\n ],\n)\n\n# Create ADK middleware agent instance\nchat_agent = ADKAgent(\n adk_agent=sample_agent,\n app_name=\"demo_app\",\n user_id=\"demo_user\",\n session_timeout_seconds=3600,\n use_in_memory_services=True,\n)\n\n# Create FastAPI app\napp = FastAPI(title=\"ADK Middleware Weather Agent\")\n\n# Add the ADK endpoint\nadd_adk_fastapi_endpoint(app, chat_agent, path=\"/\")\n", + "content": "\"\"\"Basic Chat feature.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import FastAPI\nfrom ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint, AGUIToolset\nfrom google.adk.agents import LlmAgent\nfrom google.adk import tools as adk_tools\nimport httpx\nimport json\nimport os\n\n# Compatibility shim for PreloadMemoryTool (renamed in newer ADK versions)\ntry:\n PreloadMemoryTool = adk_tools.preload_memory.PreloadMemoryTool\nexcept AttributeError:\n PreloadMemoryTool = adk_tools.preload_memory_tool.PreloadMemoryTool\n\n\ndef get_weather_condition(code: int) -> str:\n \"\"\"Map weather code to human-readable condition.\n\n Args:\n code: WMO weather code.\n\n Returns:\n Human-readable weather condition string.\n \"\"\"\n conditions = {\n 0: \"Clear sky\",\n 1: \"Mainly clear\",\n 2: \"Partly cloudy\",\n 3: \"Overcast\",\n 45: \"Foggy\",\n 48: \"Depositing rime fog\",\n 51: \"Light drizzle\",\n 53: \"Moderate drizzle\",\n 55: \"Dense drizzle\",\n 56: \"Light freezing drizzle\",\n 57: \"Dense freezing drizzle\",\n 61: \"Slight rain\",\n 63: \"Moderate rain\",\n 65: \"Heavy rain\",\n 66: \"Light freezing rain\",\n 67: \"Heavy freezing rain\",\n 71: \"Slight snow fall\",\n 73: \"Moderate snow fall\",\n 75: \"Heavy snow fall\",\n 77: \"Snow grains\",\n 80: \"Slight rain showers\",\n 81: \"Moderate rain showers\",\n 82: \"Violent rain showers\",\n 85: \"Slight snow showers\",\n 86: \"Heavy snow showers\",\n 95: \"Thunderstorm\",\n 96: \"Thunderstorm with slight hail\",\n 99: \"Thunderstorm with heavy hail\",\n }\n return conditions.get(code, \"Unknown\")\n\n\ndef _mock_weather(location: str) -> dict[str, str | float]:\n \"\"\"Return deterministic canned weather data for tests.\n\n Used when ``AG_UI_MOCK_WEATHER`` is set so e2e runs don't depend on the\n live open-meteo API (which rate-limits CI's shared egress IPs).\n \"\"\"\n return {\n \"temperature\": 21.0,\n \"feelsLike\": 20.0,\n \"humidity\": 65.0,\n \"windSpeed\": 12.0,\n \"windGust\": 18.0,\n \"conditions\": get_weather_condition(1),\n \"location\": location,\n }\n\n\nasync def get_weather(location: str) -> dict[str, str | float]:\n \"\"\"Get current weather for a location.\n\n Args:\n location: City name.\n\n Returns:\n Dictionary with weather information including temperature, feels like,\n humidity, wind speed, wind gust, conditions, and location name.\n \"\"\"\n if os.getenv(\"AG_UI_MOCK_WEATHER\"):\n return _mock_weather(location)\n\n async with httpx.AsyncClient() as client:\n # Geocode the location\n geocoding_url = (\n f\"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1\"\n )\n geocoding_response = await client.get(geocoding_url)\n geocoding_data = geocoding_response.json()\n\n if not geocoding_data.get(\"results\"):\n raise ValueError(f\"Location '{location}' not found\")\n\n result = geocoding_data[\"results\"][0]\n latitude = result[\"latitude\"]\n longitude = result[\"longitude\"]\n name = result[\"name\"]\n\n # Get weather data\n weather_url = (\n f\"https://api.open-meteo.com/v1/forecast?\"\n f\"latitude={latitude}&longitude={longitude}\"\n f\"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,\"\n f\"wind_speed_10m,wind_gusts_10m,weather_code\"\n )\n weather_response = await client.get(weather_url)\n weather_data = weather_response.json()\n\n current = weather_data[\"current\"]\n\n return {\n \"temperature\": current[\"temperature_2m\"],\n \"feelsLike\": current[\"apparent_temperature\"],\n \"humidity\": current[\"relative_humidity_2m\"],\n \"windSpeed\": current[\"wind_speed_10m\"],\n \"windGust\": current[\"wind_gusts_10m\"],\n \"conditions\": get_weather_condition(current[\"weather_code\"]),\n \"location\": name,\n }\n\n\n# Create a sample ADK agent (this would be your actual agent)\nsample_agent = LlmAgent(\n name=\"assistant\",\n model=\"gemini-2.5-flash\",\n instruction=\"\"\"\n You are a helpful weather assistant that provides accurate weather information.\n\n Your primary function is to help users get weather details for specific locations. When responding:\n - Always ask for a location if none is provided\n - If the location name isn’t in English, please translate it\n - If giving a location with multiple parts (e.g. \"New York, NY\"), use the most relevant part (e.g. \"New York\")\n - Include relevant details like humidity, wind conditions, and precipitation\n - Keep responses concise but informative\n\n Use the get_weather tool to fetch current weather data.\n \"\"\",\n tools=[\n AGUIToolset(), # Add the tools provided by the AG-UI client\n PreloadMemoryTool(),\n get_weather,\n ],\n)\n\n# Create ADK middleware agent instance\nchat_agent = ADKAgent(\n adk_agent=sample_agent,\n app_name=\"demo_app\",\n user_id=\"demo_user\",\n session_timeout_seconds=3600,\n use_in_memory_services=True,\n)\n\n# Create FastAPI app\napp = FastAPI(title=\"ADK Middleware Weather Agent\")\n\n# Add the ADK endpoint\nadd_adk_fastapi_endpoint(app, chat_agent, path=\"/\")\n", "language": "python", "type": "file" } @@ -1829,6 +2023,84 @@ "type": "file" } ], + "adk-middleware::a2ui_fixed_schema": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { fixedSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Search flights\",\n message: \"Find flights from SFO to JFK for next Tuesday.\",\n },\n {\n title: \"Search hotels\",\n message: \"Find hotels in downtown Manhattan for next weekend.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Fixed Schema\n\n## What This Demo Shows\n\nFixed-schema A2UI rendering where the UI schema is pre-defined in JSON files and only the data changes per invocation.\n\n1. **Pre-built schemas**: Flight card layout loaded from `flight_schema.json`\n2. **Data binding**: The agent populates flight data into the schema template\n3. **Action handlers**: \"Select\" button triggers an optimistic booking confirmation\n4. **No streaming**: All cards render at once after the tool completes\n", + "language": "markdown", + "type": "file" + }, + { + "name": "a2ui_fixed_schema.py", + "content": "\"\"\"A2UI Fixed Schema feature (OSS-158).\n\nADK port of the LangGraph ``a2ui_fixed_schema`` example. Unlike the dynamic\ndemo (which forces a ``render_a2ui`` sub-agent to *generate* a surface), the\nfixed-schema demo uses two plain ADK backend tools — ``search_flights`` and\n``search_hotels``. The component layout is loaded from JSON files at startup\n(``a2ui.load_schema`` equivalent); only the *data* changes per call. Each tool\nreturns the ``a2ui_operations`` envelope directly (createSurface ->\nupdateComponents -> updateDataModel), which the A2UI middleware detects in the\ntool result and paints. No sub-agent, no generation, no recovery loop.\n\nThe result is returned as a Python ``dict`` (not a JSON string): ADK keeps a\ndict tool-return as the function response as-is, and the middleware's\n``_serialize_tool_response`` then ``json.dumps`` it into the\n``{\"a2ui_operations\": [...]}`` string the client's A2UIMiddleware looks for.\nReturning a string instead would make ADK wrap it as ``{\"result\": \"...\"}``,\nwhich the middleware would not recognize.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom typing import Any, List\n\nfrom fastapi import FastAPI\nfrom google.adk.agents import LlmAgent\n\nfrom ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint\n\nfrom ag_ui_a2ui_toolkit import (\n A2UI_OPERATIONS_KEY,\n create_surface,\n update_components,\n update_data_model,\n)\n\n# Both surfaces render against the dojo's fixed catalog (Row / FlightCard /\n# HotelCard / StarRating). The client (dojo page) supplies the catalog via the\n# CopilotKit `a2ui` prop; here we only reference its id in createSurface.\nCUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/fixed_catalog.json\"\n\n_SCHEMAS_DIR = Path(__file__).parent / \"a2ui_fixed_schema_schemas\"\n\n\ndef _load_schema(name: str) -> list[dict[str, Any]]:\n \"\"\"Load a fixed A2UI component layout from a JSON file.\"\"\"\n with open(_SCHEMAS_DIR / name) as f:\n return json.load(f)\n\n\nFLIGHT_SURFACE_ID = \"flight-search-results\"\nFLIGHT_SCHEMA = _load_schema(\"flight_schema.json\")\n\nHOTEL_SURFACE_ID = \"hotel-search-results\"\nHOTEL_SCHEMA = _load_schema(\"hotel_schema.json\")\n\n\ndef _envelope(\n surface_id: str, schema: list[dict[str, Any]], data: dict[str, Any]\n) -> dict[str, Any]:\n \"\"\"Build the A2UI operations envelope dict for a fixed-schema surface.\"\"\"\n return {\n A2UI_OPERATIONS_KEY: [\n create_surface(surface_id, catalog_id=CUSTOM_CATALOG_ID),\n update_components(surface_id, schema),\n update_data_model(surface_id, data),\n ]\n }\n\n\ndef search_flights(flights: List[dict]) -> dict[str, Any]:\n \"\"\"Search for flights and display the results as rich cards.\n\n Args:\n flights: A list of flight objects. Each flight must have:\n id, airline (e.g. \"United Airlines\"),\n airlineLogo (Google favicon API:\n \"https://www.google.com/s2/favicons?domain={airline_domain}&sz=128\"\n e.g. \"https://www.google.com/s2/favicons?domain=united.com&sz=128\"),\n flightNumber, origin, destination,\n date (short readable format like \"Tue, Mar 18\" — use near-future dates),\n departureTime, arrivalTime,\n duration (e.g. \"4h 25m\"), status (e.g. \"On Time\" or \"Delayed\"),\n statusIcon (colored dot: \"https://placehold.co/12/22c55e/22c55e.png\"\n for On Time, \"https://placehold.co/12/eab308/eab308.png\" for Delayed),\n and price (e.g. \"$289\").\n \"\"\"\n return _envelope(FLIGHT_SURFACE_ID, FLIGHT_SCHEMA, {\"flights\": flights})\n\n\ndef search_hotels(hotels: List[dict]) -> dict[str, Any]:\n \"\"\"Search for hotels and display the results as rich cards with star ratings.\n\n Args:\n hotels: A list of hotel objects. Each hotel must have:\n id, name (e.g. \"The Plaza\"),\n location (e.g. \"Midtown Manhattan, NYC\"),\n rating (float 0-5, e.g. 4.5),\n and price (per night, e.g. \"$350\").\n\n Generate 3-4 realistic hotel results.\n \"\"\"\n return _envelope(HOTEL_SURFACE_ID, HOTEL_SCHEMA, {\"hotels\": hotels})\n\n\nSYSTEM_PROMPT = \"\"\"You are a helpful travel assistant that can search for flights and hotels.\n\nWhen the user asks about flights, use the search_flights tool.\nWhen the user asks about hotels, use the search_hotels tool.\nIMPORTANT: After calling a tool, do NOT repeat or summarize the data in your text response. The tool renders a rich UI automatically. Just say something brief like \"Here are your results\" or ask if they'd like to book.\n\nFor flights, each needs: id, airline, airlineLogo (Google favicon API), flightNumber, origin, destination,\ndate, departureTime, arrivalTime, duration, status, statusIcon, and price.\n\nFor hotels, each needs: id, name, location, rating (float 0-5), and price (per night).\n\nGenerate 3-5 realistic results.\"\"\"\n\n# gemini-2.5-pro reliably calls the right tool with well-formed data for this\n# demo; keep it on the same model as the dynamic demo for parity.\n_MODEL = \"gemini-2.5-pro\"\n\nfixed_schema_agent = LlmAgent(\n model=_MODEL,\n name=\"a2ui_fixed_schema\",\n instruction=SYSTEM_PROMPT,\n tools=[search_flights, search_hotels],\n)\n\nadk_a2ui_fixed_schema = ADKAgent(\n adk_agent=fixed_schema_agent,\n app_name=\"demo_app\",\n user_id=\"demo_user\",\n session_timeout_seconds=3600,\n use_in_memory_services=True,\n)\n\napp = FastAPI(title=\"ADK Middleware A2UI Fixed Schema\")\nadd_adk_fastapi_endpoint(app, adk_a2ui_fixed_schema, path=\"/\")\n", + "language": "python", + "type": "file" + } + ], + "adk-middleware::a2ui_dynamic_schema": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Hotel comparison\",\n message:\n \"Compare 3 luxury hotels in different cities with ratings and prices.\",\n },\n {\n title: \"Product comparison\",\n message:\n \"Compare 3 wireless headphones with prices, ratings, and descriptions.\",\n },\n {\n title: \"Team roster\",\n message:\n \"Show a team of 4 people with their roles, departments, and contact info.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Dynamic Schema\n\n## What This Demo Shows\n\nDynamic A2UI where a secondary LLM generates the entire UI schema and data from the conversation context.\n\n1. **LLM-generated UI**: A secondary GPT-4.1 call produces the `render_a2ui` tool call with components and data\n2. **No pre-defined schema**: The UI layout is created on-the-fly based on what the user asks for\n3. **Progressive streaming**: Components and data stream as the secondary LLM generates them\n4. **Built-in progress indicator**: Shows generation progress while the schema is being created\n", + "language": "markdown", + "type": "file" + }, + { + "name": "a2ui_dynamic_schema.py", + "content": "\"\"\"A2UI Dynamic Schema feature (OSS-158).\n\nADK port of the LangGraph ``a2ui_dynamic_schema`` example, using the adapter's\nA2UI **auto-injection**: the ``LlmAgent`` wires no A2UI tool itself. When the\nruntime forwards ``injectA2UITool``, the ADKAgent injects ``generate_a2ui``\nonto the agent and infers the sub-agent model from the agent's\n``canonical_model``. Inside the tool, a forced ``render_a2ui`` sub-agent\ngenerates a v0.9 A2UI surface and the toolkit's validate->retry recovery loop\nruns. The result is wrapped as ``a2ui_operations``, which the A2UI middleware\ndetects in the tool result and renders automatically.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import FastAPI\nfrom google.adk.agents import LlmAgent\n\nfrom ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint\n\n# Catalog the dojo renders this demo against (HotelCard / ProductCard /\n# TeamMemberCard / Row). The client (dojo page) supplies the catalog via the\n# CopilotKit `a2ui` prop; the middleware injects it into the run, and the adapter\n# renders it into the sub-agent prompt (Google's render_as_llm_instructions) and\n# validates against it (toolkit, structural/lenient). The subagent never picks one.\nCUSTOM_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\"\n\n# Project-specific composition rules — tells the subagent how to use the\n# pre-made domain components shipped in the dojo's dynamic catalog. Kept\n# byte-identical to the LangGraph python example so both integrations behave\n# the same for a given prompt.\nCOMPOSITION_GUIDE = \"\"\"\n## Available Pre-made Components\n\nYou have 4 components. Use Row as the root with structural children to repeat a card per item.\n\n### Row\nLayout container. Use structural children to repeat a card template:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard\nProps: name, location, rating (number 0-5), pricePerNight, amenities (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"HotelCard\",\"name\":{\"path\":\"name\"},\"location\":{\"path\":\"location\"},\n \"rating\":{\"path\":\"rating\"},\"pricePerNight\":{\"path\":\"pricePerNight\"},\n \"action\":{\"event\":{\"name\":\"book\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### ProductCard\nProps: name, price, rating (number 0-5), description (optional), badge (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"ProductCard\",\"name\":{\"path\":\"name\"},\"price\":{\"path\":\"price\"},\n \"rating\":{\"path\":\"rating\"},\"description\":{\"path\":\"description\"},\n \"action\":{\"event\":{\"name\":\"select\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n### TeamMemberCard\nProps: name, role, department (optional), email (optional), avatarUrl (optional), action\nExample:\n {\"id\":\"card\",\"component\":\"TeamMemberCard\",\"name\":{\"path\":\"name\"},\"role\":{\"path\":\"role\"},\n \"department\":{\"path\":\"department\"},\"email\":{\"path\":\"email\"},\n \"action\":{\"event\":{\"name\":\"contact\",\"context\":{\"name\":{\"path\":\"name\"}}}}}\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- Inside templates, use RELATIVE paths (no leading slash): {\"path\":\"name\"} not {\"path\":\"/name\"}\n- Always provide data in the \"data\" argument as {\"items\":[...]}\n- Pick the card type that best matches the user's request\n- Generate 3-4 realistic items with diverse data\n\"\"\"\n\nSYSTEM_PROMPT = \"\"\"You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (product comparisons, dashboards, lists, cards, etc.),\nuse the generate_a2ui tool to create a dynamic A2UI surface.\nWhen the user asks to MODIFY a surface you already rendered, call generate_a2ui with\nintent=\"update\" and target_surface_id set to that surface's id.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.\"\"\"\n\n# gemini-2.5-pro reliably produces valid, in-catalog A2UI for this demo. The\n# auto-injected generate_a2ui tool infers its sub-agent model from this agent's\n# canonical_model (the registry resolves the string to a Gemini instance).\n_MODEL = \"gemini-2.5-pro\"\n\ndynamic_schema_agent = LlmAgent(\n model=_MODEL,\n name=\"a2ui_dynamic_schema\",\n instruction=SYSTEM_PROMPT,\n # generate_a2ui is auto-injected by the adapter; nothing wired here.\n)\n\nadk_a2ui_dynamic_schema = ADKAgent(\n adk_agent=dynamic_schema_agent,\n app_name=\"demo_app\",\n user_id=\"demo_user\",\n session_timeout_seconds=3600,\n use_in_memory_services=True,\n # Optional A2UI preferences; the runtime's injectA2UITool flag (forwarded by\n # the dojo's per-agent A2UIMiddleware) triggers injection and the adapter\n # renders these into the sub-agent prompt.\n a2ui={\n \"default_catalog_id\": CUSTOM_CATALOG_ID,\n \"guidelines\": {\"composition_guide\": COMPOSITION_GUIDE},\n },\n)\n\napp = FastAPI(title=\"ADK Middleware A2UI Dynamic Schema\")\nadd_adk_fastapi_endpoint(app, adk_a2ui_dynamic_schema, path=\"/\")\n", + "language": "python", + "type": "file" + } + ], + "adk-middleware::a2ui_recovery": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Recover from an error\",\n message: \"Compare 3 luxury hotels with ratings and prices.\",\n },\n {\n title: \"Hard failure\",\n message: \"Compare 3 broken hotels with ratings and prices.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Error Recovery\n\n## What This Demo Shows\n\nAutomatic, no-wipe recovery when a secondary LLM generates an **invalid** A2UI surface.\n\n1. **Server-side validation gate**: Each generated component tree is validated before it can paint. Invalid trees are suppressed — the user never sees a broken surface flash and disappear.\n2. **Structured-error feedback loop**: The validation errors are fed back to the generating sub-agent, which regenerates (up to a configurable cap, default 3 attempts).\n3. **No wipes**: Only a validated surface ever commits. Faulty attempts never paint, so there's no stream → error → wipe → retry flicker.\n4. **Tasteful hard-failure**: If every attempt fails, a clean failure state is shown and the conversation stays usable. Developers get full per-attempt detail; end users don't see transient noise.\n\n## How to Interact\n\nTwo suggestions are wired for this demo:\n\n- **\"Compare 3 luxury hotels with ratings and prices.\"** — the first generated surface references a UI template the model \"forgot\" to include (a dangling child reference). The gate rejects it, the error is fed back, and the **second attempt is valid** and paints. You see the recovered surface, not the broken one.\n- **\"Compare 3 broken hotels with ratings and prices.\"** — every attempt is invalid, so the loop **exhausts** and the clean hard-failure state appears. The chat remains interactive afterward.\n\n## How It Works Technically\n\n- The **commit point is the component-tree close** — the only moment a tree is knowable as complete — where the middleware runs `validateA2UIComponents` and emits the surface **only if valid**.\n- On rejection, `augmentPromptWithValidationErrors` appends the machine-readable errors to the sub-agent's prompt and the adapter re-invokes it (`runA2UIGenerationWithRecovery`), never retrying after a validated paint.\n- Recovery is surfaced as an `a2ui_recovery` activity: a delayed \"Retrying…\" hint for slow/repeated retries, and a hard-failure state once the attempt cap is reached.\n- The retry cap, the threshold before the retry hint appears, and how much debug state is exposed are all configurable.\n\nThis feature drives errors deterministically via ai-mock fixtures so the recovery and hard-failure paths can be demonstrated and tested reliably.\n", + "language": "markdown", + "type": "file" + }, + { + "name": "a2ui_recovery.py", + "content": "\"\"\"A2UI Error Recovery feature (OSS-158).\n\nADK port of the LangGraph ``a2ui_recovery`` example — the same dynamic-schema\nsetup with the validate->retry recovery loop made explicit. The showcase forces\nan invalid->valid (recover) and an always-invalid (exhaust) sequence via aimock\nfixtures: a faulty surface never paints (the middleware gate suppresses it), the\nerrors are fed back, and either a valid surface paints or a tasteful hard-failure\nis shown once the attempt cap is hit.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\n\nfrom fastapi import FastAPI\nfrom google.adk.agents import LlmAgent\nfrom google.adk.models import Gemini\n\nfrom ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint, get_a2ui_tool\n\nfrom .a2ui_dynamic_schema import COMPOSITION_GUIDE, CUSTOM_CATALOG_ID, SYSTEM_PROMPT\n\nlogger = logging.getLogger(__name__)\n\n_MODEL = \"gemini-2.5-pro\"\n\n\ndef _log_attempt(record: dict) -> None:\n # Dev observability: each attempt (incl. rejected ones) is logged.\n logger.info(\n \"[a2ui recovery] attempt %s: %s %s\",\n record.get(\"attempt\"),\n \"valid\" if record.get(\"ok\") else \"invalid\",\n record.get(\"errors\"),\n )\n\n\na2ui_tool = get_a2ui_tool({\n \"model\": Gemini(model=_MODEL),\n \"default_catalog_id\": CUSTOM_CATALOG_ID,\n \"guidelines\": {\"composition_guide\": COMPOSITION_GUIDE},\n # Recovery runs by default; set explicitly for the showcase. Each rejected\n # attempt's structural validation errors are fed back into the retry prompt.\n \"recovery\": {\"maxAttempts\": 3},\n \"on_a2ui_attempt\": _log_attempt,\n})\n\nrecovery_agent = LlmAgent(\n model=_MODEL,\n name=\"a2ui_recovery\",\n instruction=SYSTEM_PROMPT,\n tools=[a2ui_tool],\n)\n\nadk_a2ui_recovery = ADKAgent(\n adk_agent=recovery_agent,\n app_name=\"demo_app\",\n user_id=\"demo_user\",\n session_timeout_seconds=3600,\n use_in_memory_services=True,\n)\n\napp = FastAPI(title=\"ADK Middleware A2UI Error Recovery\")\nadd_adk_fastapi_endpoint(app, adk_a2ui_recovery, path=\"/\")\n", + "language": "python", + "type": "file" + } + ], "microsoft-agent-framework-dotnet::agentic_chat": [ { "name": "page.tsx", @@ -2312,7 +2584,7 @@ }, { "name": "backend_tool_rendering.py", - "content": "\"\"\"Backend Tool Rendering example using AG2 with AG-UI protocol.\n\nExposes a ConversableAgent with a get_weather tool via AGUIStream.\nThe frontend renders tool calls and results (e.g. weather card).\nSee: https://docs.ag2.ai/latest/docs/user-guide/ag-ui/\n\"\"\"\n\nimport json\n\nimport httpx\nfrom fastapi import FastAPI\nfrom autogen import ConversableAgent, LLMConfig\nfrom autogen.ag_ui import AGUIStream\n\n\ndef get_weather_condition(code: int) -> str:\n \"\"\"Map WMO weather code to human-readable condition.\"\"\"\n conditions = {\n 0: \"Clear sky\",\n 1: \"Mainly clear\",\n 2: \"Partly cloudy\",\n 3: \"Overcast\",\n 45: \"Foggy\",\n 48: \"Depositing rime fog\",\n 51: \"Light drizzle\",\n 53: \"Moderate drizzle\",\n 55: \"Dense drizzle\",\n 61: \"Slight rain\",\n 63: \"Moderate rain\",\n 65: \"Heavy rain\",\n 71: \"Slight snow fall\",\n 73: \"Moderate snow fall\",\n 75: \"Heavy snow fall\",\n 80: \"Slight rain showers\",\n 81: \"Moderate rain showers\",\n 85: \"Slight snow showers\",\n 86: \"Heavy snow showers\",\n 95: \"Thunderstorm\",\n 96: \"Thunderstorm with slight hail\",\n 99: \"Thunderstorm with heavy hail\",\n }\n return conditions.get(code, \"Unknown\")\n\n\nasync def get_weather(location: str) -> str:\n \"\"\"Get current weather for a location.\n\n Args:\n location: City name.\n\n Returns:\n Dictionary with temperature, conditions, humidity, wind_speed, feels_like, location.\n \"\"\"\n async with httpx.AsyncClient() as client:\n geocoding_url = (\n f\"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1\"\n )\n geocoding_response = await client.get(geocoding_url)\n geocoding_data = geocoding_response.json()\n\n if not geocoding_data.get(\"results\"):\n raise ValueError(f\"Location '{location}' not found\")\n\n result = geocoding_data[\"results\"][0]\n latitude = result[\"latitude\"]\n longitude = result[\"longitude\"]\n name = result[\"name\"]\n\n weather_url = (\n f\"https://api.open-meteo.com/v1/forecast?\"\n f\"latitude={latitude}&longitude={longitude}\"\n f\"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,\"\n f\"wind_speed_10m,wind_gusts_10m,weather_code\"\n )\n weather_response = await client.get(weather_url)\n weather_data = await weather_response.json()\n current = weather_data[\"current\"]\n\n return json.dumps({\n \"temperature\": current[\"temperature_2m\"],\n \"feels_like\": current[\"apparent_temperature\"],\n \"humidity\": current[\"relative_humidity_2m\"],\n \"wind_speed\": current[\"wind_speed_10m\"],\n \"wind_gust\": current[\"wind_gusts_10m\"],\n \"conditions\": get_weather_condition(current[\"weather_code\"]),\n \"location\": name,\n })\n\n\nagent = ConversableAgent(\n name=\"weather_bot\",\n system_message=\"\"\"You are a helpful weather assistant that provides accurate weather information.\n\nYour primary function is to help users get weather details for specific locations. When responding:\n- Always ask for a location if none is provided\n- If the location name isn't in English, please translate it\n- If giving a location with multiple parts (e.g. \"New York, NY\"), use the most relevant part (e.g. \"New York\")\n- Include relevant details like humidity, wind conditions, and precipitation\n- Keep responses concise but informative\n\nUse the get_weather tool to fetch current weather data.\"\"\",\n llm_config=LLMConfig({\"model\": \"gpt-4o-mini\", \"stream\": True}),\n human_input_mode=\"NEVER\",\n functions=[get_weather],\n)\n\nstream = AGUIStream(agent)\nbackend_tool_rendering_app = FastAPI()\nbackend_tool_rendering_app.mount(\"\", stream.build_asgi())\n", + "content": "\"\"\"Backend Tool Rendering example using AG2 with AG-UI protocol.\n\nExposes a ConversableAgent with a get_weather tool via AGUIStream.\nThe frontend renders tool calls and results (e.g. weather card).\nSee: https://docs.ag2.ai/latest/docs/user-guide/ag-ui/\n\"\"\"\n\nimport json\nimport os\n\nimport httpx\nfrom fastapi import FastAPI\nfrom autogen import ConversableAgent, LLMConfig\nfrom autogen.ag_ui import AGUIStream\n\n\ndef get_weather_condition(code: int) -> str:\n \"\"\"Map WMO weather code to human-readable condition.\"\"\"\n conditions = {\n 0: \"Clear sky\",\n 1: \"Mainly clear\",\n 2: \"Partly cloudy\",\n 3: \"Overcast\",\n 45: \"Foggy\",\n 48: \"Depositing rime fog\",\n 51: \"Light drizzle\",\n 53: \"Moderate drizzle\",\n 55: \"Dense drizzle\",\n 61: \"Slight rain\",\n 63: \"Moderate rain\",\n 65: \"Heavy rain\",\n 71: \"Slight snow fall\",\n 73: \"Moderate snow fall\",\n 75: \"Heavy snow fall\",\n 80: \"Slight rain showers\",\n 81: \"Moderate rain showers\",\n 85: \"Slight snow showers\",\n 86: \"Heavy snow showers\",\n 95: \"Thunderstorm\",\n 96: \"Thunderstorm with slight hail\",\n 99: \"Thunderstorm with heavy hail\",\n }\n return conditions.get(code, \"Unknown\")\n\n\ndef _mock_weather(location: str) -> str:\n \"\"\"Return deterministic canned weather data for tests.\n\n Used when ``AG_UI_MOCK_WEATHER`` is set so e2e runs don't depend on the\n live open-meteo API (which rate-limits CI's shared egress IPs).\n \"\"\"\n return json.dumps({\n \"temperature\": 21.0,\n \"feels_like\": 20.0,\n \"humidity\": 65.0,\n \"wind_speed\": 12.0,\n \"wind_gust\": 18.0,\n \"conditions\": get_weather_condition(1),\n \"location\": location,\n })\n\n\nasync def get_weather(location: str) -> str:\n \"\"\"Get current weather for a location.\n\n Args:\n location: City name.\n\n Returns:\n Dictionary with temperature, conditions, humidity, wind_speed, feels_like, location.\n \"\"\"\n if os.getenv(\"AG_UI_MOCK_WEATHER\"):\n return _mock_weather(location)\n\n async with httpx.AsyncClient() as client:\n geocoding_url = (\n f\"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1\"\n )\n geocoding_response = await client.get(geocoding_url)\n geocoding_data = geocoding_response.json()\n\n if not geocoding_data.get(\"results\"):\n raise ValueError(f\"Location '{location}' not found\")\n\n result = geocoding_data[\"results\"][0]\n latitude = result[\"latitude\"]\n longitude = result[\"longitude\"]\n name = result[\"name\"]\n\n weather_url = (\n f\"https://api.open-meteo.com/v1/forecast?\"\n f\"latitude={latitude}&longitude={longitude}\"\n f\"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,\"\n f\"wind_speed_10m,wind_gusts_10m,weather_code\"\n )\n weather_response = await client.get(weather_url)\n weather_data = await weather_response.json()\n current = weather_data[\"current\"]\n\n return json.dumps({\n \"temperature\": current[\"temperature_2m\"],\n \"feels_like\": current[\"apparent_temperature\"],\n \"humidity\": current[\"relative_humidity_2m\"],\n \"wind_speed\": current[\"wind_speed_10m\"],\n \"wind_gust\": current[\"wind_gusts_10m\"],\n \"conditions\": get_weather_condition(current[\"weather_code\"]),\n \"location\": name,\n })\n\n\nagent = ConversableAgent(\n name=\"weather_bot\",\n system_message=\"\"\"You are a helpful weather assistant that provides accurate weather information.\n\nYour primary function is to help users get weather details for specific locations. When responding:\n- Always ask for a location if none is provided\n- If the location name isn't in English, please translate it\n- If giving a location with multiple parts (e.g. \"New York, NY\"), use the most relevant part (e.g. \"New York\")\n- Include relevant details like humidity, wind conditions, and precipitation\n- Keep responses concise but informative\n\nUse the get_weather tool to fetch current weather data.\"\"\",\n llm_config=LLMConfig({\"model\": \"gpt-4o-mini\", \"stream\": True}),\n human_input_mode=\"NEVER\",\n functions=[get_weather],\n)\n\nstream = AGUIStream(agent)\nbackend_tool_rendering_app = FastAPI()\nbackend_tool_rendering_app.mount(\"\", stream.build_asgi())\n", "language": "python", "type": "file" } @@ -2470,7 +2742,7 @@ }, { "name": "backend_tool_rendering.py", - "content": "\"\"\"Example: Agno Agent with Finance tools\n\nThis example shows how to create an Agno Agent with tools (YFinanceTools) and expose it in an AG-UI compatible way.\n\"\"\"\n\nimport json\n\nimport httpx\nfrom agno.agent.agent import Agent\nfrom agno.models.openai import OpenAIChat\nfrom agno.os import AgentOS\nfrom agno.os.interfaces.agui import AGUI\nfrom agno.tools import tool\nfrom agno.tools.yfinance import YFinanceTools\n\n\ndef get_weather_condition(code: int) -> str:\n \"\"\"Map weather code to human-readable condition.\n\n Args:\n code: WMO weather code.\n\n Returns:\n Human-readable weather condition string.\n \"\"\"\n conditions = {\n 0: \"Clear sky\",\n 1: \"Mainly clear\",\n 2: \"Partly cloudy\",\n 3: \"Overcast\",\n 45: \"Foggy\",\n 48: \"Depositing rime fog\",\n 51: \"Light drizzle\",\n 53: \"Moderate drizzle\",\n 55: \"Dense drizzle\",\n 56: \"Light freezing drizzle\",\n 57: \"Dense freezing drizzle\",\n 61: \"Slight rain\",\n 63: \"Moderate rain\",\n 65: \"Heavy rain\",\n 66: \"Light freezing rain\",\n 67: \"Heavy freezing rain\",\n 71: \"Slight snow fall\",\n 73: \"Moderate snow fall\",\n 75: \"Heavy snow fall\",\n 77: \"Snow grains\",\n 80: \"Slight rain showers\",\n 81: \"Moderate rain showers\",\n 82: \"Violent rain showers\",\n 85: \"Slight snow showers\",\n 86: \"Heavy snow showers\",\n 95: \"Thunderstorm\",\n 96: \"Thunderstorm with slight hail\",\n 99: \"Thunderstorm with heavy hail\",\n }\n return conditions.get(code, \"Unknown\")\n\n\n@tool(external_execution=False)\nasync def get_weather(location: str) -> str:\n \"\"\"Get current weather for a location.\n\n Args:\n location: City name.\n\n Returns:\n A json string with weather information including temperature, feels like,\n humidity, wind speed, wind gust, conditions, and location name.\n \"\"\"\n async with httpx.AsyncClient() as client:\n # Geocode the location\n geocoding_url = (\n f\"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1\"\n )\n geocoding_response = await client.get(geocoding_url)\n geocoding_data = geocoding_response.json()\n\n if not geocoding_data.get(\"results\"):\n raise ValueError(f\"Location '{location}' not found\")\n\n result = geocoding_data[\"results\"][0]\n latitude = result[\"latitude\"]\n longitude = result[\"longitude\"]\n name = result[\"name\"]\n\n # Get weather data\n weather_url = (\n f\"https://api.open-meteo.com/v1/forecast?\"\n f\"latitude={latitude}&longitude={longitude}\"\n f\"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,\"\n f\"wind_speed_10m,wind_gusts_10m,weather_code\"\n )\n weather_response = await client.get(weather_url)\n weather_data = weather_response.json()\n\n current = weather_data[\"current\"]\n\n return json.dumps(\n {\n \"temperature\": current[\"temperature_2m\"],\n \"feels_like\": current[\"apparent_temperature\"],\n \"humidity\": current[\"relative_humidity_2m\"],\n \"wind_speed\": current[\"wind_speed_10m\"],\n \"windGust\": current[\"wind_gusts_10m\"],\n \"conditions\": get_weather_condition(current[\"weather_code\"]),\n \"location\": name,\n }\n )\n\n\nagent = Agent(\n model=OpenAIChat(id=\"gpt-4o\"),\n tools=[\n get_weather,\n ],\n description=\"You are a helpful weather assistant that provides accurate weather information.\",\n instructions=\"\"\"\n Your primary function is to help users get weather details for specific locations. When responding:\n - Always ask for a location if none is provided\n - If the location name isn't in English, please translate it\n - If giving a location with multiple parts (e.g. \"New York, NY\"), use the most relevant part (e.g. \"New York\")\n - Include relevant details like humidity, wind conditions, and precipitation\n - Keep responses concise but informative\n\n Use the get_weather tool to fetch current weather data.\n \"\"\",\n)\n\nagent_os = AgentOS(agents=[agent], interfaces=[AGUI(agent=agent)])\n\napp = agent_os.get_app()\n", + "content": "\"\"\"Example: Agno Agent with Finance tools\n\nThis example shows how to create an Agno Agent with tools (YFinanceTools) and expose it in an AG-UI compatible way.\n\"\"\"\n\nimport json\nimport os\n\nimport httpx\nfrom agno.agent.agent import Agent\nfrom agno.models.openai import OpenAIChat\nfrom agno.os import AgentOS\nfrom agno.os.interfaces.agui import AGUI\nfrom agno.tools import tool\nfrom agno.tools.yfinance import YFinanceTools\n\n\ndef get_weather_condition(code: int) -> str:\n \"\"\"Map weather code to human-readable condition.\n\n Args:\n code: WMO weather code.\n\n Returns:\n Human-readable weather condition string.\n \"\"\"\n conditions = {\n 0: \"Clear sky\",\n 1: \"Mainly clear\",\n 2: \"Partly cloudy\",\n 3: \"Overcast\",\n 45: \"Foggy\",\n 48: \"Depositing rime fog\",\n 51: \"Light drizzle\",\n 53: \"Moderate drizzle\",\n 55: \"Dense drizzle\",\n 56: \"Light freezing drizzle\",\n 57: \"Dense freezing drizzle\",\n 61: \"Slight rain\",\n 63: \"Moderate rain\",\n 65: \"Heavy rain\",\n 66: \"Light freezing rain\",\n 67: \"Heavy freezing rain\",\n 71: \"Slight snow fall\",\n 73: \"Moderate snow fall\",\n 75: \"Heavy snow fall\",\n 77: \"Snow grains\",\n 80: \"Slight rain showers\",\n 81: \"Moderate rain showers\",\n 82: \"Violent rain showers\",\n 85: \"Slight snow showers\",\n 86: \"Heavy snow showers\",\n 95: \"Thunderstorm\",\n 96: \"Thunderstorm with slight hail\",\n 99: \"Thunderstorm with heavy hail\",\n }\n return conditions.get(code, \"Unknown\")\n\n\ndef _mock_weather(location: str) -> str:\n \"\"\"Return deterministic canned weather data for tests.\n\n Used when ``AG_UI_MOCK_WEATHER`` is set so e2e runs don't depend on the\n live open-meteo API (which rate-limits CI's shared egress IPs).\n \"\"\"\n return json.dumps(\n {\n \"temperature\": 21.0,\n \"feels_like\": 20.0,\n \"humidity\": 65.0,\n \"wind_speed\": 12.0,\n \"windGust\": 18.0,\n \"conditions\": get_weather_condition(1),\n \"location\": location,\n }\n )\n\n\n@tool(external_execution=False)\nasync def get_weather(location: str) -> str:\n \"\"\"Get current weather for a location.\n\n Args:\n location: City name.\n\n Returns:\n A json string with weather information including temperature, feels like,\n humidity, wind speed, wind gust, conditions, and location name.\n \"\"\"\n if os.getenv(\"AG_UI_MOCK_WEATHER\"):\n return _mock_weather(location)\n\n async with httpx.AsyncClient() as client:\n # Geocode the location\n geocoding_url = (\n f\"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1\"\n )\n geocoding_response = await client.get(geocoding_url)\n geocoding_data = geocoding_response.json()\n\n if not geocoding_data.get(\"results\"):\n raise ValueError(f\"Location '{location}' not found\")\n\n result = geocoding_data[\"results\"][0]\n latitude = result[\"latitude\"]\n longitude = result[\"longitude\"]\n name = result[\"name\"]\n\n # Get weather data\n weather_url = (\n f\"https://api.open-meteo.com/v1/forecast?\"\n f\"latitude={latitude}&longitude={longitude}\"\n f\"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,\"\n f\"wind_speed_10m,wind_gusts_10m,weather_code\"\n )\n weather_response = await client.get(weather_url)\n weather_data = weather_response.json()\n\n current = weather_data[\"current\"]\n\n return json.dumps(\n {\n \"temperature\": current[\"temperature_2m\"],\n \"feels_like\": current[\"apparent_temperature\"],\n \"humidity\": current[\"relative_humidity_2m\"],\n \"wind_speed\": current[\"wind_speed_10m\"],\n \"windGust\": current[\"wind_gusts_10m\"],\n \"conditions\": get_weather_condition(current[\"weather_code\"]),\n \"location\": name,\n }\n )\n\n\nagent = Agent(\n model=OpenAIChat(id=\"gpt-4o\"),\n tools=[\n get_weather,\n ],\n description=\"You are a helpful weather assistant that provides accurate weather information.\",\n instructions=\"\"\"\n Your primary function is to help users get weather details for specific locations. When responding:\n - Always ask for a location if none is provided\n - If the location name isn't in English, please translate it\n - If giving a location with multiple parts (e.g. \"New York, NY\"), use the most relevant part (e.g. \"New York\")\n - Include relevant details like humidity, wind conditions, and precipitation\n - Keep responses concise but informative\n\n Use the get_weather tool to fetch current weather data.\n \"\"\",\n)\n\nagent_os = AgentOS(agents=[agent], interfaces=[AGUI(agent=agent)])\n\napp = agent_os.get_app()\n", "language": "python", "type": "file" } @@ -2576,7 +2848,7 @@ }, { "name": "backend_tool_rendering.py", - "content": "\"\"\"Backend Tool Rendering feature.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime\nimport json\nfrom textwrap import dedent\nfrom zoneinfo import ZoneInfo\n\nimport httpx\nfrom llama_index.llms.openai import OpenAI\nfrom llama_index.protocols.ag_ui.router import get_ag_ui_workflow_router\n\n\ndef get_weather_condition(code: int) -> str:\n \"\"\"Map weather code to human-readable condition.\n\n Args:\n code: WMO weather code.\n\n Returns:\n Human-readable weather condition string.\n \"\"\"\n conditions = {\n 0: \"Clear sky\",\n 1: \"Mainly clear\",\n 2: \"Partly cloudy\",\n 3: \"Overcast\",\n 45: \"Foggy\",\n 48: \"Depositing rime fog\",\n 51: \"Light drizzle\",\n 53: \"Moderate drizzle\",\n 55: \"Dense drizzle\",\n 56: \"Light freezing drizzle\",\n 57: \"Dense freezing drizzle\",\n 61: \"Slight rain\",\n 63: \"Moderate rain\",\n 65: \"Heavy rain\",\n 66: \"Light freezing rain\",\n 67: \"Heavy freezing rain\",\n 71: \"Slight snow fall\",\n 73: \"Moderate snow fall\",\n 75: \"Heavy snow fall\",\n 77: \"Snow grains\",\n 80: \"Slight rain showers\",\n 81: \"Moderate rain showers\",\n 82: \"Violent rain showers\",\n 85: \"Slight snow showers\",\n 86: \"Heavy snow showers\",\n 95: \"Thunderstorm\",\n 96: \"Thunderstorm with slight hail\",\n 99: \"Thunderstorm with heavy hail\",\n }\n return conditions.get(code, \"Unknown\")\n\n\nasync def get_weather(location: str) -> str:\n \"\"\"Get current weather for a location.\n\n Args:\n location: City name.\n\n Returns:\n Dictionary with weather information including temperature, feels like,\n humidity, wind speed, wind gust, conditions, and location name.\n \"\"\"\n async with httpx.AsyncClient() as client:\n # Geocode the location\n geocoding_url = (\n f\"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1\"\n )\n geocoding_response = await client.get(geocoding_url)\n geocoding_data = geocoding_response.json()\n\n if not geocoding_data.get(\"results\"):\n raise ValueError(f\"Location '{location}' not found\")\n\n result = geocoding_data[\"results\"][0]\n latitude = result[\"latitude\"]\n longitude = result[\"longitude\"]\n name = result[\"name\"]\n\n # Get weather data\n weather_url = (\n f\"https://api.open-meteo.com/v1/forecast?\"\n f\"latitude={latitude}&longitude={longitude}\"\n f\"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,\"\n f\"wind_speed_10m,wind_gusts_10m,weather_code\"\n )\n weather_response = await client.get(weather_url)\n weather_data = weather_response.json()\n\n current = weather_data[\"current\"]\n\n return json.dumps({\n \"temperature\": current[\"temperature_2m\"],\n \"feelsLike\": current[\"apparent_temperature\"],\n \"humidity\": current[\"relative_humidity_2m\"],\n \"windSpeed\": current[\"wind_speed_10m\"],\n \"windGust\": current[\"wind_gusts_10m\"],\n \"conditions\": get_weather_condition(current[\"weather_code\"]),\n \"location\": name,\n })\n\n\n# Create the router with weather tools\nbackend_tool_rendering_router = get_ag_ui_workflow_router(\n llm=OpenAI(model=\"gpt-4o-mini\"),\n backend_tools=[get_weather],\n system_prompt=dedent(\n \"\"\"\n You are a helpful weather assistant that provides accurate weather information.\n\n Your primary function is to help users get weather details for specific locations. When responding:\n - Always ask for a location if none is provided\n - If the location name isn’t in English, please translate it\n - If giving a location with multiple parts (e.g. \"New York, NY\"), use the most relevant part (e.g. \"New York\")\n - Include relevant details like humidity, wind conditions, and precipitation\n - Keep responses concise but informative\n\n Use the get_weather tool to fetch current weather data.\n \"\"\"\n ),\n)\n", + "content": "\"\"\"Backend Tool Rendering feature.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime\nimport json\nimport os\nfrom textwrap import dedent\nfrom zoneinfo import ZoneInfo\n\nimport httpx\nfrom llama_index.llms.openai import OpenAI\nfrom llama_index.protocols.ag_ui.router import get_ag_ui_workflow_router\n\n\ndef get_weather_condition(code: int) -> str:\n \"\"\"Map weather code to human-readable condition.\n\n Args:\n code: WMO weather code.\n\n Returns:\n Human-readable weather condition string.\n \"\"\"\n conditions = {\n 0: \"Clear sky\",\n 1: \"Mainly clear\",\n 2: \"Partly cloudy\",\n 3: \"Overcast\",\n 45: \"Foggy\",\n 48: \"Depositing rime fog\",\n 51: \"Light drizzle\",\n 53: \"Moderate drizzle\",\n 55: \"Dense drizzle\",\n 56: \"Light freezing drizzle\",\n 57: \"Dense freezing drizzle\",\n 61: \"Slight rain\",\n 63: \"Moderate rain\",\n 65: \"Heavy rain\",\n 66: \"Light freezing rain\",\n 67: \"Heavy freezing rain\",\n 71: \"Slight snow fall\",\n 73: \"Moderate snow fall\",\n 75: \"Heavy snow fall\",\n 77: \"Snow grains\",\n 80: \"Slight rain showers\",\n 81: \"Moderate rain showers\",\n 82: \"Violent rain showers\",\n 85: \"Slight snow showers\",\n 86: \"Heavy snow showers\",\n 95: \"Thunderstorm\",\n 96: \"Thunderstorm with slight hail\",\n 99: \"Thunderstorm with heavy hail\",\n }\n return conditions.get(code, \"Unknown\")\n\n\ndef _mock_weather(location: str) -> str:\n \"\"\"Return deterministic canned weather data for tests.\n\n Used when ``AG_UI_MOCK_WEATHER`` is set so e2e runs don't depend on the\n live open-meteo API (which rate-limits CI's shared egress IPs).\n \"\"\"\n return json.dumps({\n \"temperature\": 21.0,\n \"feelsLike\": 20.0,\n \"humidity\": 65.0,\n \"windSpeed\": 12.0,\n \"windGust\": 18.0,\n \"conditions\": get_weather_condition(1),\n \"location\": location,\n })\n\n\nasync def get_weather(location: str) -> str:\n \"\"\"Get current weather for a location.\n\n Args:\n location: City name.\n\n Returns:\n Dictionary with weather information including temperature, feels like,\n humidity, wind speed, wind gust, conditions, and location name.\n \"\"\"\n if os.getenv(\"AG_UI_MOCK_WEATHER\"):\n return _mock_weather(location)\n\n async with httpx.AsyncClient() as client:\n # Geocode the location\n geocoding_url = (\n f\"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1\"\n )\n geocoding_response = await client.get(geocoding_url)\n geocoding_data = geocoding_response.json()\n\n if not geocoding_data.get(\"results\"):\n raise ValueError(f\"Location '{location}' not found\")\n\n result = geocoding_data[\"results\"][0]\n latitude = result[\"latitude\"]\n longitude = result[\"longitude\"]\n name = result[\"name\"]\n\n # Get weather data\n weather_url = (\n f\"https://api.open-meteo.com/v1/forecast?\"\n f\"latitude={latitude}&longitude={longitude}\"\n f\"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,\"\n f\"wind_speed_10m,wind_gusts_10m,weather_code\"\n )\n weather_response = await client.get(weather_url)\n weather_data = weather_response.json()\n\n current = weather_data[\"current\"]\n\n return json.dumps({\n \"temperature\": current[\"temperature_2m\"],\n \"feelsLike\": current[\"apparent_temperature\"],\n \"humidity\": current[\"relative_humidity_2m\"],\n \"windSpeed\": current[\"wind_speed_10m\"],\n \"windGust\": current[\"wind_gusts_10m\"],\n \"conditions\": get_weather_condition(current[\"weather_code\"]),\n \"location\": name,\n })\n\n\n# Create the router with weather tools\nbackend_tool_rendering_router = get_ag_ui_workflow_router(\n llm=OpenAI(model=\"gpt-4o-mini\"),\n backend_tools=[get_weather],\n system_prompt=dedent(\n \"\"\"\n You are a helpful weather assistant that provides accurate weather information.\n\n Your primary function is to help users get weather details for specific locations. When responding:\n - Always ask for a location if none is provided\n - If the location name isn’t in English, please translate it\n - If giving a location with multiple parts (e.g. \"New York, NY\"), use the most relevant part (e.g. \"New York\")\n - Include relevant details like humidity, wind conditions, and precipitation\n - Keep responses concise but informative\n\n Use the get_weather tool to fetch current weather data.\n \"\"\"\n ),\n)\n", "language": "python", "type": "file" } @@ -3281,6 +3553,58 @@ "type": "file" } ], + "aws-strands::a2ui_dynamic_schema": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Hotel comparison\",\n message:\n \"Compare 3 luxury hotels in different cities with ratings and prices.\",\n },\n {\n title: \"Product comparison\",\n message:\n \"Compare 3 wireless headphones with prices, ratings, and descriptions.\",\n },\n {\n title: \"Team roster\",\n message:\n \"Show a team of 4 people with their roles, departments, and contact info.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Dynamic Schema\n\n## What This Demo Shows\n\nDynamic A2UI where a secondary LLM generates the entire UI schema and data from the conversation context.\n\n1. **LLM-generated UI**: A secondary GPT-4.1 call produces the `render_a2ui` tool call with components and data\n2. **No pre-defined schema**: The UI layout is created on-the-fly based on what the user asks for\n3. **Progressive streaming**: Components and data stream as the secondary LLM generates them\n4. **Built-in progress indicator**: Shows generation progress while the schema is being created\n", + "language": "markdown", + "type": "file" + }, + { + "name": "a2ui_dynamic_schema.py", + "content": "\"\"\"Dynamic A2UI example for AWS Strands.\n\nA plain agent with no a2ui wiring. When the runtime enables A2UI tool\ninjection, the adapter auto-injects ``generate_a2ui`` and renders surfaces\ngenerated from the conversation.\n\"\"\"\nimport os\nfrom pathlib import Path\nfrom dotenv import load_dotenv\n\n# Suppress OpenTelemetry context warnings from Strands SDK\nos.environ[\"OTEL_SDK_DISABLED\"] = \"true\"\nos.environ[\"OTEL_PYTHON_DISABLED_INSTRUMENTATIONS\"] = \"all\"\n\nfrom strands import Agent\nfrom ag_ui_strands import StrandsAgent, StrandsAgentConfig, create_strands_app\nfrom server.model_factory import create_model\n\n# Load environment variables from .env file\nenv_path = Path(__file__).parent.parent.parent / '.env'\nload_dotenv(dotenv_path=env_path)\n\n# The dojo registers its dynamic component catalog (HotelCard, ProductCard,\n# TeamMemberCard) under this id; auto-injected surfaces must reference it so\n# the renderer can resolve their components.\nDOJO_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\"\n\n# Teaches the sub-agent how to compose the dojo catalog's components. Mirrors\n# the LangGraph dynamic-schema demo's COMPOSITION_GUIDE so a real model (not\n# just the e2e mock) can produce valid surfaces.\nCOMPOSITION_GUIDE = \"\"\"\n## Available Pre-made Components\n\nYou have 3 card components. Use Row as the root with structural children to\nrepeat a card per item.\n\n### Row\nLayout container. Repeat a card template via structural children:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard\nProps: name, location, rating (number 0-5), pricePerNight, action\n\n### ProductCard\nProps: name, price, rating (number 0-5), description (optional), action\n\n### TeamMemberCard\nProps: name, role, department (optional), email (optional), action\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- ALWAYS include the referenced card component in the components array.\n- Inside templates use RELATIVE paths (no leading slash): {\"path\":\"name\"}.\n- Always provide data in the \"data\" argument as {\"items\":[...]}.\n- Pick the card type that best matches the request; generate 3-4 realistic items.\n\"\"\"\n\nSYSTEM_PROMPT = \"\"\"You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (product comparisons, dashboards, team\nrosters, lists, cards, etc.), use the generate_a2ui tool to create a dynamic\nA2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response.\nThe tool renders UI automatically. Just confirm what was rendered.\"\"\"\n\nstrands_agent = Agent(\n # Chat Completions API (OpenAI provider only; other providers ignore the\n # kwarg): the Responses model buffers tool-call argument deltas, which\n # would defeat A2UI's progressive surface streaming.\n model=create_model(openai_api=\"chat\"),\n system_prompt=SYSTEM_PROMPT,\n # generate_a2ui is auto-injected by the adapter; nothing wired here.\n)\n\nagui_agent = StrandsAgent(\n agent=strands_agent,\n name=\"a2ui_dynamic_schema\",\n description=\"Dynamic A2UI surfaces generated on the fly (auto-injected tool)\",\n config=StrandsAgentConfig(\n a2ui={\n \"default_catalog_id\": DOJO_CATALOG_ID,\n \"guidelines\": {\"composition_guide\": COMPOSITION_GUIDE},\n }\n ),\n)\n\napp = create_strands_app(agui_agent, \"/\")\n", + "language": "python", + "type": "file" + } + ], + "aws-strands::a2ui_recovery": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Recover from an error\",\n message: \"Compare 3 luxury hotels with ratings and prices.\",\n },\n {\n title: \"Hard failure\",\n message: \"Compare 3 broken hotels with ratings and prices.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Error Recovery\n\n## What This Demo Shows\n\nAutomatic, no-wipe recovery when a secondary LLM generates an **invalid** A2UI surface.\n\n1. **Server-side validation gate**: Each generated component tree is validated before it can paint. Invalid trees are suppressed — the user never sees a broken surface flash and disappear.\n2. **Structured-error feedback loop**: The validation errors are fed back to the generating sub-agent, which regenerates (up to a configurable cap, default 3 attempts).\n3. **No wipes**: Only a validated surface ever commits. Faulty attempts never paint, so there's no stream → error → wipe → retry flicker.\n4. **Tasteful hard-failure**: If every attempt fails, a clean failure state is shown and the conversation stays usable. Developers get full per-attempt detail; end users don't see transient noise.\n\n## How to Interact\n\nTwo suggestions are wired for this demo:\n\n- **\"Compare 3 luxury hotels with ratings and prices.\"** — the first generated surface references a UI template the model \"forgot\" to include (a dangling child reference). The gate rejects it, the error is fed back, and the **second attempt is valid** and paints. You see the recovered surface, not the broken one.\n- **\"Compare 3 broken hotels with ratings and prices.\"** — every attempt is invalid, so the loop **exhausts** and the clean hard-failure state appears. The chat remains interactive afterward.\n\n## How It Works Technically\n\n- The **commit point is the component-tree close** — the only moment a tree is knowable as complete — where the middleware runs `validateA2UIComponents` and emits the surface **only if valid**.\n- On rejection, `augmentPromptWithValidationErrors` appends the machine-readable errors to the sub-agent's prompt and the adapter re-invokes it (`runA2UIGenerationWithRecovery`), never retrying after a validated paint.\n- Recovery is surfaced as an `a2ui_recovery` activity: a delayed \"Retrying…\" hint for slow/repeated retries, and a hard-failure state once the attempt cap is reached.\n- The retry cap, the threshold before the retry hint appears, and how much debug state is exposed are all configurable.\n\nThis feature drives errors deterministically via ai-mock fixtures so the recovery and hard-failure paths can be demonstrated and tested reliably.\n", + "language": "markdown", + "type": "file" + }, + { + "name": "a2ui_recovery.py", + "content": "\"\"\"A2UI Error Recovery example for AWS Strands.\n\nA plain agent with no a2ui wiring. The adapter auto-injects ``generate_a2ui``,\nwhich validates each generated surface and retries on failure (up to 3\ntotal attempts) before falling back to a tasteful hard-failure.\n\"\"\"\nimport os\nfrom pathlib import Path\nfrom dotenv import load_dotenv\n\n# Suppress OpenTelemetry context warnings from Strands SDK\nos.environ[\"OTEL_SDK_DISABLED\"] = \"true\"\nos.environ[\"OTEL_PYTHON_DISABLED_INSTRUMENTATIONS\"] = \"all\"\n\nfrom strands import Agent\nfrom ag_ui_strands import StrandsAgent, StrandsAgentConfig, create_strands_app\nfrom server.model_factory import create_model\n\n# Load environment variables from .env file\nenv_path = Path(__file__).parent.parent.parent / '.env'\nload_dotenv(dotenv_path=env_path)\n\n# The dojo registers its dynamic component catalog under this id; auto-injected\n# surfaces must reference it so the renderer can resolve their components.\nDOJO_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\"\n\n# Teaches the sub-agent how to compose the dojo catalog's components. Mirrors\n# the LangGraph recovery demo's COMPOSITION_GUIDE.\nCOMPOSITION_GUIDE = \"\"\"\n## Available Pre-made Components\n\nUse Row as the root with structural children to repeat a card per item.\n\n### Row\nRepeat a card template via structural children:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard / ProductCard / TeamMemberCard\nCard components bound to per-item data (relative paths inside the template).\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- ALWAYS include the referenced card component in the components array.\n- Inside templates use RELATIVE paths (no leading slash): {\"path\":\"name\"}.\n- Always provide data in the \"data\" argument as {\"items\":[...]}.\n- Generate 3-4 realistic items with diverse data.\n\"\"\"\n\nSYSTEM_PROMPT = \"\"\"You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (hotel/product comparisons, team rosters,\nlists, cards, etc.), use the generate_a2ui tool to create a dynamic A2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response.\nThe tool renders UI automatically. Just confirm what was rendered.\"\"\"\n\nstrands_agent = Agent(\n # Chat Completions API (OpenAI provider only; other providers ignore the\n # kwarg): the Responses model buffers tool-call argument deltas, which\n # would defeat A2UI's progressive surface streaming.\n model=create_model(openai_api=\"chat\"),\n system_prompt=SYSTEM_PROMPT,\n # generate_a2ui is auto-injected by the adapter; nothing wired here.\n)\n\nagui_agent = StrandsAgent(\n agent=strands_agent,\n name=\"a2ui_recovery\",\n description=\"Dynamic A2UI with automatic error recovery (auto-injected tool)\",\n config=StrandsAgentConfig(\n a2ui={\n \"default_catalog_id\": DOJO_CATALOG_ID,\n \"guidelines\": {\"composition_guide\": COMPOSITION_GUIDE},\n }\n ),\n)\n\napp = create_strands_app(agui_agent, \"/\")\n", + "language": "python", + "type": "file" + } + ], "aws-strands-typescript::agentic_chat": [ { "name": "page.tsx", @@ -3485,6 +3809,58 @@ "type": "file" } ], + "aws-strands-typescript::a2ui_dynamic_schema": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Hotel comparison\",\n message:\n \"Compare 3 luxury hotels in different cities with ratings and prices.\",\n },\n {\n title: \"Product comparison\",\n message:\n \"Compare 3 wireless headphones with prices, ratings, and descriptions.\",\n },\n {\n title: \"Team roster\",\n message:\n \"Show a team of 4 people with their roles, departments, and contact info.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Status dot should be even smaller */\n.a2ui-surface img[alt=\"On Time\"],\n.a2ui-surface img[alt=\"Delayed\"],\n.a2ui-surface img[alt=\"Cancelled\"] {\n max-width: 10px;\n max-height: 10px;\n border-radius: 50%;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Dynamic Schema\n\n## What This Demo Shows\n\nDynamic A2UI where a secondary LLM generates the entire UI schema and data from the conversation context.\n\n1. **LLM-generated UI**: A secondary GPT-4.1 call produces the `render_a2ui` tool call with components and data\n2. **No pre-defined schema**: The UI layout is created on-the-fly based on what the user asks for\n3. **Progressive streaming**: Components and data stream as the secondary LLM generates them\n4. **Built-in progress indicator**: Shows generation progress while the schema is being created\n", + "language": "markdown", + "type": "file" + }, + { + "name": "a2ui-dynamic-schema.ts", + "content": "/**\n * Dynamic A2UI example for AWS Strands (TypeScript).\n *\n * A plain agent with no a2ui wiring. When the runtime enables A2UI tool\n * injection, the adapter auto-injects `generate_a2ui` and renders surfaces\n * generated from the conversation.\n */\n\nimport { Agent } from \"@strands-agents/sdk\";\nimport { StrandsAgent } from \"@ag-ui/aws-strands\";\nimport { createModel } from \"../model-factory\";\n\n// The dojo registers its dynamic component catalog (HotelCard, ProductCard,\n// TeamMemberCard) under this id; auto-injected surfaces must reference it so the\n// renderer can resolve their components.\nconst DOJO_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\";\n\n// Teaches the sub-agent how to compose the dojo catalog's components. Mirrors\n// the LangGraph dynamic-schema demo's COMPOSITION_GUIDE so a real model (not\n// just the e2e mock) can produce valid surfaces.\nconst COMPOSITION_GUIDE = `\n## Available Pre-made Components\n\nYou have 3 card components. Use Row as the root with structural children to\nrepeat a card per item.\n\n### Row\nLayout container. Repeat a card template via structural children:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard\nProps: name, location, rating (number 0-5), pricePerNight, action\n\n### ProductCard\nProps: name, price, rating (number 0-5), description (optional), action\n\n### TeamMemberCard\nProps: name, role, department (optional), email (optional), action\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- ALWAYS include the referenced card component in the components array.\n- Inside templates use RELATIVE paths (no leading slash): {\"path\":\"name\"}.\n- Always provide data in the \"data\" argument as {\"items\":[...]}.\n- Pick the card type that best matches the request; generate 3-4 realistic items.\n`;\n\nconst SYSTEM_PROMPT = `You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (product comparisons, dashboards, team\nrosters, lists, cards, etc.), use the generate_a2ui tool to create a dynamic\nA2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response.\nThe tool renders UI automatically. Just confirm what was rendered.`;\n\nexport async function createA2UIDynamicSchemaAgent(): Promise {\n const agent = new Agent({\n // Chat Completions API: the Responses adapter buffers tool-call argument\n // deltas, which would defeat A2UI's progressive surface streaming.\n model: await createModel({ openaiApi: \"chat\" }),\n systemPrompt: SYSTEM_PROMPT,\n // generate_a2ui is auto-injected by the adapter; nothing wired here.\n });\n\n return new StrandsAgent({\n agent,\n name: \"a2ui_dynamic_schema\",\n description: \"Dynamic A2UI surfaces generated on the fly (auto-injected tool)\",\n config: {\n a2ui: {\n defaultCatalogId: DOJO_CATALOG_ID,\n guidelines: { compositionGuide: COMPOSITION_GUIDE },\n },\n },\n });\n}\n", + "language": "ts", + "type": "file" + } + ], + "aws-strands-typescript::a2ui_recovery": [ + { + "name": "page.tsx", + "content": "\"use client\";\nimport React from \"react\";\nimport \"@copilotkit/react-core/v2/styles.css\";\nimport \"./style.css\";\nimport {\n CopilotChat,\n useConfigureSuggestions,\n} from \"@copilotkit/react-core/v2\";\nimport { CopilotKit } from \"@copilotkit/react-core\";\nimport { dynamicSchemaCatalog } from \"@/a2ui-catalog\";\n\nexport const dynamic = \"force-dynamic\";\n\ninterface PageProps {\n params: Promise<{ integrationId: string }>;\n}\n\nfunction Chat() {\n useConfigureSuggestions({\n suggestions: [\n {\n title: \"Recover from an error\",\n message: \"Compare 3 luxury hotels with ratings and prices.\",\n },\n {\n title: \"Hard failure\",\n message: \"Compare 3 broken hotels with ratings and prices.\",\n },\n ],\n available: \"always\",\n });\n\n return (\n \n );\n}\n\nexport default function Page({ params }: PageProps) {\n const { integrationId } = React.use(params);\n\n return (\n \n
\n
\n \n
\n
\n \n );\n}\n", + "language": "typescript", + "type": "file" + }, + { + "name": "style.css", + "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');\n\n.a2ui-surface {\n --primary: #111111;\n --primary-foreground: #ffffff;\n --card: #ffffff;\n --border: #e0e0e0;\n --radius: 12px;\n --foreground: #111111;\n --input: #d4d4d4;\n --background: #fafafa;\n\n font-family: \"Plus Jakarta Sans\", -apple-system, BlinkMacSystemFont, system-ui, sans-serif !important;\n letter-spacing: -0.01em;\n}\n\n/* Constrain images to consistent sizes */\n.a2ui-surface img {\n max-width: 28px;\n max-height: 28px;\n border-radius: 4px;\n}\n\n/* Consistent card width so single-card streaming doesn't collapse narrow */\n.a2ui-surface .a2ui-card {\n min-width: 280px;\n}\n", + "language": "css", + "type": "file" + }, + { + "name": "README.mdx", + "content": "# A2UI Error Recovery\n\n## What This Demo Shows\n\nAutomatic, no-wipe recovery when a secondary LLM generates an **invalid** A2UI surface.\n\n1. **Server-side validation gate**: Each generated component tree is validated before it can paint. Invalid trees are suppressed — the user never sees a broken surface flash and disappear.\n2. **Structured-error feedback loop**: The validation errors are fed back to the generating sub-agent, which regenerates (up to a configurable cap, default 3 attempts).\n3. **No wipes**: Only a validated surface ever commits. Faulty attempts never paint, so there's no stream → error → wipe → retry flicker.\n4. **Tasteful hard-failure**: If every attempt fails, a clean failure state is shown and the conversation stays usable. Developers get full per-attempt detail; end users don't see transient noise.\n\n## How to Interact\n\nTwo suggestions are wired for this demo:\n\n- **\"Compare 3 luxury hotels with ratings and prices.\"** — the first generated surface references a UI template the model \"forgot\" to include (a dangling child reference). The gate rejects it, the error is fed back, and the **second attempt is valid** and paints. You see the recovered surface, not the broken one.\n- **\"Compare 3 broken hotels with ratings and prices.\"** — every attempt is invalid, so the loop **exhausts** and the clean hard-failure state appears. The chat remains interactive afterward.\n\n## How It Works Technically\n\n- The **commit point is the component-tree close** — the only moment a tree is knowable as complete — where the middleware runs `validateA2UIComponents` and emits the surface **only if valid**.\n- On rejection, `augmentPromptWithValidationErrors` appends the machine-readable errors to the sub-agent's prompt and the adapter re-invokes it (`runA2UIGenerationWithRecovery`), never retrying after a validated paint.\n- Recovery is surfaced as an `a2ui_recovery` activity: a delayed \"Retrying…\" hint for slow/repeated retries, and a hard-failure state once the attempt cap is reached.\n- The retry cap, the threshold before the retry hint appears, and how much debug state is exposed are all configurable.\n\nThis feature drives errors deterministically via ai-mock fixtures so the recovery and hard-failure paths can be demonstrated and tested reliably.\n", + "language": "markdown", + "type": "file" + }, + { + "name": "a2ui-recovery.ts", + "content": "/**\n * A2UI Error Recovery example for AWS Strands (TypeScript).\n *\n * A plain agent with no a2ui wiring. The adapter auto-injects `generate_a2ui`,\n * which validates each generated surface and retries on failure (up to 3\n * total attempts) before falling back to a tasteful hard-failure.\n */\n\nimport { Agent } from \"@strands-agents/sdk\";\nimport { StrandsAgent } from \"@ag-ui/aws-strands\";\nimport { createModel } from \"../model-factory\";\n\n// The dojo registers its dynamic component catalog under this id; auto-injected\n// surfaces must reference it so the renderer can resolve their components.\nconst DOJO_CATALOG_ID = \"https://a2ui.org/demos/dojo/dynamic_catalog.json\";\n\n// Teaches the sub-agent how to compose the dojo catalog's components. Mirrors\n// the LangGraph recovery demo's COMPOSITION_GUIDE.\nconst COMPOSITION_GUIDE = `\n## Available Pre-made Components\n\nUse Row as the root with structural children to repeat a card per item.\n\n### Row\nRepeat a card template via structural children:\n {\"id\":\"root\",\"component\":\"Row\",\"children\":{\"componentId\":\"card\",\"path\":\"/items\"}}\n\n### HotelCard / ProductCard / TeamMemberCard\nCard components bound to per-item data (relative paths inside the template).\n\n## RULES\n- Root is ALWAYS a Row with structural children: {\"componentId\":\"\",\"path\":\"/items\"}\n- ALWAYS include the referenced card component in the components array.\n- Inside templates use RELATIVE paths (no leading slash): {\"path\":\"name\"}.\n- Always provide data in the \"data\" argument as {\"items\":[...]}.\n- Generate 3-4 realistic items with diverse data.\n`;\n\nconst SYSTEM_PROMPT = `You are a helpful assistant that creates rich visual UI on the fly.\n\nWhen the user asks for visual content (hotel/product comparisons, team rosters,\nlists, cards, etc.), use the generate_a2ui tool to create a dynamic A2UI surface.\nIMPORTANT: After calling the tool, do NOT repeat the data in your text response.\nThe tool renders UI automatically. Just confirm what was rendered.`;\n\nexport async function createA2UIRecoveryAgent(): Promise {\n const agent = new Agent({\n // Chat Completions API: the Responses adapter buffers tool-call argument\n // deltas, which would defeat A2UI's progressive surface streaming.\n model: await createModel({ openaiApi: \"chat\" }),\n systemPrompt: SYSTEM_PROMPT,\n // generate_a2ui is auto-injected by the adapter; nothing wired here.\n });\n\n return new StrandsAgent({\n agent,\n name: \"a2ui_recovery\",\n description:\n \"Dynamic A2UI with automatic error recovery (auto-injected tool)\",\n config: {\n a2ui: {\n defaultCatalogId: DOJO_CATALOG_ID,\n guidelines: { compositionGuide: COMPOSITION_GUIDE },\n },\n },\n });\n}\n", + "language": "ts", + "type": "file" + } + ], "claude-agent-sdk-python::agentic_chat": [ { "name": "page.tsx", diff --git a/apps/dojo/src/menu.ts b/apps/dojo/src/menu.ts index bedc8f312e..fe414aef81 100644 --- a/apps/dojo/src/menu.ts +++ b/apps/dojo/src/menu.ts @@ -49,6 +49,9 @@ export const menuIntegrations = [ "shared_state", "tool_based_generative_ui", "subgraphs", + "a2ui_dynamic_schema", + "a2ui_fixed_schema", + "a2ui_advanced", ], }, { @@ -87,6 +90,10 @@ export const menuIntegrations = [ "shared_state", "tool_based_generative_ui", "subgraphs", + "a2ui_dynamic_schema", + "a2ui_fixed_schema", + "a2ui_advanced", + "a2ui_recovery", ], }, // { @@ -160,6 +167,9 @@ export const menuIntegrations = [ "predictive_state_updates", "shared_state", "tool_based_generative_ui", + "a2ui_fixed_schema", + "a2ui_dynamic_schema", + "a2ui_recovery", ], }, { @@ -290,6 +300,8 @@ export const menuIntegrations = [ "agentic_generative_ui", "shared_state", "human_in_the_loop", + "a2ui_dynamic_schema", + "a2ui_recovery", ], }, { @@ -305,6 +317,8 @@ export const menuIntegrations = [ "shared_state", "human_in_the_loop", "tool_based_generative_ui", + "a2ui_dynamic_schema", + "a2ui_recovery", ], }, { diff --git a/apps/dojo/src/types/integration.ts b/apps/dojo/src/types/integration.ts index 965ebd1192..153cb5d954 100644 --- a/apps/dojo/src/types/integration.ts +++ b/apps/dojo/src/types/integration.ts @@ -17,6 +17,7 @@ export type Feature = | "a2ui_fixed_schema" | "a2ui_dynamic_schema" | "a2ui_advanced" + | "a2ui_recovery" | "crew_chat" | "error_flow"; diff --git a/docs/sdk/go/core/events.mdx b/docs/sdk/go/core/events.mdx index fd8ac53344..afb34d0fe0 100644 --- a/docs/sdk/go/core/events.mdx +++ b/docs/sdk/go/core/events.mdx @@ -35,7 +35,7 @@ const ( EventTypeStepStarted EventType = "STEP_STARTED" EventTypeStepFinished EventType = "STEP_FINISHED" - // Thinking events for reasoning phase support + // Thinking events (DEPRECATED — use the REASONING_* events below; removed in 1.0.0) EventTypeThinkingStart EventType = "THINKING_START" EventTypeThinkingEnd EventType = "THINKING_END" EventTypeThinkingTextMessageStart EventType = "THINKING_TEXT_MESSAGE_START" @@ -456,10 +456,28 @@ delta := []events.JSONPatchOperation{ event := events.NewStateDeltaEvent(delta) ``` -## Thinking Events +## Thinking Events (Deprecated) + + + The `THINKING_*` events are deprecated and will be removed in version 1.0.0. + New implementations should use `REASONING_*` events instead. + These events support reasoning/thinking phases where the agent shows its thought process. +The following event types are deprecated: + +| Deprecated Event | Replacement | +| ------------------------------- | --------------------------- | +| `THINKING_START` | `REASONING_START` | +| `THINKING_END` | `REASONING_END` | +| `THINKING_TEXT_MESSAGE_START` | `REASONING_MESSAGE_START` | +| `THINKING_TEXT_MESSAGE_CONTENT` | `REASONING_MESSAGE_CONTENT` | +| `THINKING_TEXT_MESSAGE_END` | `REASONING_MESSAGE_END` | + +See [Reasoning Migration](/concepts/reasoning#migration-from-thinking-events) +for detailed migration guidance. + ### ThinkingStartEvent Signals the start of a thinking phase. diff --git a/docs/sdk/js/client/middleware.mdx b/docs/sdk/js/client/middleware.mdx index 88b9d4e120..597dbb7e19 100644 --- a/docs/sdk/js/client/middleware.mdx +++ b/docs/sdk/js/client/middleware.mdx @@ -246,7 +246,17 @@ const allowFilter = new FilterToolCallsMiddleware({ agent.use(allowFilter) ``` -You can also use `disallowedToolCalls` instead of `allowedToolCalls`. +#### Block Specific Tools + +```typescript +const blockFilter = new FilterToolCallsMiddleware({ + disallowedToolCalls: ["deleteFile", "sendEmail"] +}) + +agent.use(blockFilter) +``` + +You must specify exactly one of `allowedToolCalls` or `disallowedToolCalls`. The constructor throws if both or neither are provided. ## Middleware Patterns diff --git a/docs/sdk/kotlin/core/events.mdx b/docs/sdk/kotlin/core/events.mdx index 735c5bb87e..0ff5de7088 100644 --- a/docs/sdk/kotlin/core/events.mdx +++ b/docs/sdk/kotlin/core/events.mdx @@ -89,9 +89,28 @@ data class TextMessageChunkEvent( ) : BaseEvent() ``` -### Thinking Events (5) +### Thinking Events (5) (Deprecated) + + + The `THINKING_*` events are deprecated and will be removed in version 1.0.0. + New implementations should use `REASONING_*` events instead. + + Handle agent internal reasoning processes. +The following event types are deprecated: + +| Deprecated Event | Replacement | +| ------------------------------- | --------------------------- | +| `THINKING_START` | `REASONING_START` | +| `THINKING_END` | `REASONING_END` | +| `THINKING_TEXT_MESSAGE_START` | `REASONING_MESSAGE_START` | +| `THINKING_TEXT_MESSAGE_CONTENT` | `REASONING_MESSAGE_CONTENT` | +| `THINKING_TEXT_MESSAGE_END` | `REASONING_MESSAGE_END` | + +See [Reasoning Migration](/concepts/reasoning#migration-from-thinking-events) +for detailed migration guidance. + #### ThinkingStartEvent Agent begins internal reasoning. diff --git a/docs/sdk/ruby/core/events.mdx b/docs/sdk/ruby/core/events.mdx index 4fe7ae1d4f..bef8fcb393 100644 --- a/docs/sdk/ruby/core/events.mdx +++ b/docs/sdk/ruby/core/events.mdx @@ -433,12 +433,30 @@ event = AgUiProtocol::Core::Events::ToolCallStartEvent.new( | `timestamp` | `Time` (optional) | Timestamp when the event was created. Default: `nil`. | | `raw_event` | `Object` (optional) | Original event data if this event was transformed. Default: `nil`. | -## Thinking Events +## Thinking Events (Deprecated) + + + The `THINKING_*` events are deprecated and will be removed in version 1.0.0. + New implementations should use the `REASONING_*` events instead. + These events represent the lifecycle of an agent's thinking steps, conveying intermediate reasoning to the frontend without contributing to the final message. +The following event types are deprecated: + +| Deprecated Event | Replacement | +| ------------------------------- | --------------------------- | +| `THINKING_START` | `REASONING_START` | +| `THINKING_END` | `REASONING_END` | +| `THINKING_TEXT_MESSAGE_START` | `REASONING_MESSAGE_START` | +| `THINKING_TEXT_MESSAGE_CONTENT` | `REASONING_MESSAGE_CONTENT` | +| `THINKING_TEXT_MESSAGE_END` | `REASONING_MESSAGE_END` | + +See [Reasoning Migration](/concepts/reasoning#migration-from-thinking-events) +for detailed migration guidance. + ### ThinkingEndEvent `AgUiProtocol::Core::Events::ThinkingEndEvent` diff --git a/integrations/a2a/typescript/LICENSE b/integrations/a2a/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/a2a/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/adk-middleware/python/CHANGELOG.md b/integrations/adk-middleware/python/CHANGELOG.md index 163366a73f..8a935c10b2 100644 --- a/integrations/adk-middleware/python/CHANGELOG.md +++ b/integrations/adk-middleware/python/CHANGELOG.md @@ -7,6 +7,166 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **PERFORMANCE**: Cache session reads per execution to cut redundant `get_session` round-trips (#1880, #1890, thanks @he-yufeng) + - `SessionManager` now memoizes session reads in a short-lived, execution-local cache so repeated state accessors within one turn reuse a single fetch instead of re-pulling the full session (and event history) from the backing `SessionService` on every call — a notable latency win on remote backends like `VertexAiSessionService`. The cache is invalidated on writes and deliberately disabled before the runner and before the post-run HITL cleanup guard, where ADK can mutate session state outside `SessionManager`. +- **CHORE**: Update the default model for the live tests to `gemini-3.5-flash` + - `gemini-2.0-flash` reached its shutdown date (2026-06-01) and `gemini-2.5-flash` is scheduled to shut down (2026-10-16), so the live/integration tests and documentation snippets now target the current stable flash GA, `gemini-3.5-flash`. The large file count is purely this model-string sweep — there are no library or runtime behavior changes. + - The test model is centralized in `tests/constants.py` as `LIVE_TEST_MODEL` (env-overridable via `ADK_TEST_MODEL`) so future cutovers are a one-line change instead of a sweep across every test file. A companion `LIVE_TEST_PRO_MODEL` (env-overridable via `ADK_TEST_PRO_MODEL`) holds the high-reasoning model at `gemini-2.5-pro` for now. + - The HITL resumption live test was hardened for determinism alongside the model bump: the agent instruction now mandates a single `plan_steps` tool call, `temperature` is `0.0`, and per-run `thread_id`s use a UUID instead of a second-resolution timestamp to avoid collisions under concurrent test load. + +### Fixed + +- **FIX**: `output_schema` text suppression now reaches agents used as Workflow + graph nodes (#1889, fixes #1860, thanks @he-yufeng). The #1390 suppression + walks the agent tree to find `LlmAgent`s with an `output_schema` and tells + `EventTranslator` to drop their `TEXT_MESSAGE_*` events, so the structured + JSON they emit never leaks into the chat transcript. The collector only + traversed `.sub_agents`, but an ADK 2.x `Workflow`'s child agents live in + `workflow.graph.nodes`, not `.sub_agents` — so an `output_schema` agent used + as a graph node (the canonical Workflow pattern) was never added to the + suppression set, and its structured output, including the streamed + `partial=True` chunks, leaked as visible text. + `ADKAgent._collect_output_schema_agent_names` now also descends into + `agent.graph.nodes` when present, leaving the existing `.sub_agents` + traversal unchanged. +- **FIX**: Resume is gated until all of a turn's long-running results arrive + (#1935). When one model turn emits **multiple long-running tool calls** and + their results arrive in **separate submissions** (an instant frontend tool + resolves before a HITL one), ag-ui-adk resumed the model on the *first* + result. That replays a turn whose function-**call** parts outnumber its + function-**response** parts, which Gemini rejects server-side (`400 + INVALID_ARGUMENT — number of function response parts [must] equal the number + of function call parts`). Where the provider tolerated the rearranged history + instead, ADK dropped the unanswered call and the model re-issued it under a + fresh id — a **duplicate HITL widget** on the client plus an orphaned + `pending_tool_calls` entry. The middleware now resumes **once**, after all of + the turn's long-running calls have results: earlier results are persisted to + the session (and merged in by ADK) but don't advance the model on their own. + The gate is scoped to the arriving turn's `invocation_id`, so a leaked or + orphaned pending entry from another turn can't stall the thread; persistence + happens before any pending/processed bookkeeping is mutated, so a failed + persist leaves the turn cleanly re-submittable. + - **New client-visible `RUN_ERROR` codes.** `PENDING_TOOL_CALLS` — a trailing + user/system message arrived while another long-running call from the same + turn was still unanswered; the middleware rejects it and mutates nothing + (resolve or cancel the open call, then resubmit) rather than forwarding an + under-answered turn (an opaque provider 400) and silently dropping the + message. `TOOL_RESULT_BUFFER_ERROR` — persisting a buffered result failed; + no state was changed, so the client can simply resubmit. + - **Scope/non-goals**: same-name parallel long-running calls resolved + *separately* remain unsupported (ADK's `_merge_function_response_events` + can't pair them); distinct-named staggered calls and same-name calls + resolved together in one submission both work. See #1334 / PR #1355. +- **FIX**: `ADKAgent.run()` no longer emits `RUN_FINISHED` after `RUN_ERROR` + (#1892). When a tool raised mid-stream, the background queue path emitted + `RUN_ERROR` and the consumer loop then fell through to its unconditional + `RUN_FINISHED`, producing two terminal events for a single run. + `@ag-ui/client`'s state machine correctly rejects the second event with + "Cannot send event type 'RUN_FINISHED': The run has already errored". The + consumer loop now tracks whether a `RUN_ERROR` already flowed through the + queue and skips the trailing `RUN_FINISHED`, enforcing the AG-UI invariant of + at most one terminal event per run at the source rather than pushing it onto + every downstream SSE wrapper. This covers all queue-borne terminal errors + (tool throw, execution timeout, background-execution failure), not just the + tool-throw case. Thanks to @sunholo-voight-kampff for the detailed report. +- **FIX**: HITL confirmation on a standalone `LlmAgent` root now re-executes the + original tool after the user confirms (#1839). Previously, for resumable + `LlmAgent` roots the #1534 pre-append workaround substituted `new_message` + with an empty-text placeholder that became the last user event in the + session. ADK's `_RequestConfirmationLlmRequestProcessor` reverse-scans for + the last user event and bails on the first one lacking `function_responses`, + so it never reached the pre-appended confirmation `FunctionResponse` — the + LLM was invoked instead and hallucinated an "awaiting confirmation" reply. + (The same workaround also hard-crashed `SequentialAgent`/`LoopAgent` + composites of `LlmAgent`s on confirmation with "No agent to transfer to".) + Confirmation responses (`adk_request_confirmation`) are now routed through + the direct `new_message` path — the same path ADK 2.0 Workflow roots already + take — making the `FunctionResponse` the trailing user event the processor + expects. Because `adk_request_confirmation` is a long-running tool that pauses + rather than ends the invocation, this does not re-trigger the `end_of_agent` + early-return that motivated the #1534 workaround for turn-ending + client/frontend tools. This is the `LlmAgent` cousin of the Workflow-root fix + in #1669; true ADK 2.0 Workflow roots are unaffected (they already bypass the + workaround). +- **FIX**: Duplicate HITL tool-call emission under SSE streaming (long-running client tools) + - With SSE streaming (the default), ADK can deliver the *same logical* long-running client tool call **several times** — a streaming chunk (`partial=True`), an aggregated partial, the persisted final (`partial=False`) — and ADK separately **invokes the `ClientProxyTool`**, with `populate_client_function_call_id` assigning a **different ID to every replay** (#1168). Each replay produced its own `TOOL_CALL_START/ARGS/END` trio because every existing dedupe was keyed by tool-call ID — the dojo rendered the Human-in-the-Loop card **twice** (two cards, two different `adk-…` IDs visible in the event stream). + - **Translator** (`translate_lro_function_calls`): replays are now suppressed via a **high-water mark per tool name** — the Nth same-name LRO call *within one event* only emits if fewer than N calls for that name have been emitted this run (ledger: `lro_emitted_ids_by_name`). This uniformly covers second-partial replays, aggregated partials, and the final, regardless of `partial` flags. Genuinely parallel same-name calls arrive as multiple parts of *one* event, exceed the mark, and still emit individually; a later same-name event cannot be a real second call because an LRO pauses the invocation. + - **ClientProxyTool**: consults the translator's same ledger (shared into the proxy toolsets like the emitted-ID set already is) and suppresses its invocation when it is the positional twin of an already-streamed emission. + - The positional (FIFO) pairing is the same one `_extract_lro_id_remap` uses for ID remapping, so result-routing is unaffected; the non-streaming (final-only) path still emits normally. + - Reproduced deterministically with scripted `BaseLlm` streams driving the real runner + proxy + translator for all three shapes (`partial→final`, `partial→partial→final`, `partial→partial` — the "last event is partial" shape). End-to-end regression `TestLroNoDuplicateToolCallEndToEnd` is parametrized over all three; translator-level tests cover twin/second-partial/parallel/final-only/reset. + - Reproduced deterministically at the translator level (partial id-A then final id-B for one logical call → previously two `TOOL_CALL_START`, now one). New regression tests in `tests/test_lro_sse_id_remap.py` cover the partial→final twin, parallel same-name calls (no over-suppression), final-only emission, and reset. + - Also verified **live against google-adk 1.23.0 + Vertex**, where the partial(id-A)/final(id-B) replay occurs on **every** HITL turn (the unfixed translator emitted both → 100% duplicate cards in the dojo); with this fix the same stack emits exactly one. On google-adk 2.x the replay shape does not occur on this path. + - `examples/uv.lock` refreshed: it pinned `google-adk==1.23.0` (plus a similarly stale dependency set), so `uv run dev` served the demo on an ADK whose event shapes differ from the 2.x the middleware is developed and tested against — making this bug deterministic for example-server users yet invisible in development. The lock now resolves `google-adk 2.2.0` / `google-genai 2.8.0` (the old `aiohttp` pin also broke google-genai 2.8's SSE reader and was refreshed along with the rest). +- **FIX**: Strip `additionalProperties` from client tool schemas before building Gemini function declarations + - CopilotKit / AG-UI frontend tools serialize their parameters with `zodToJsonSchema(..., {$refStrategy: "none"})`, which stamps `additionalProperties: false` on every object (root and nested). `_clean_schema_for_genai` allowlisted it because it is a field on `google.genai.types.Schema`, so it was forwarded verbatim. The Gemini **Developer API** rejects it in `function_declarations` with `400 INVALID_ARGUMENT` ("Unknown name \"additional_properties\" ... Cannot find field"), which surfaced as a `RUN_ERROR` and **no tool call reaching the UI** — e.g. the Human-in-the-Loop dojo demo rendered nothing for the ADK backend, while OpenAI-based backends (which tolerate the field) worked. + - `_clean_schema_for_genai` now strips `additionalProperties` / `additional_properties` at every depth via an explicit `_GENAI_REJECTED_SCHEMA_KEYS` denylist, closing both the dynamic `types.Schema.model_fields` allowlist and the static fallback. Gemini ignores `additionalProperties` for argument generation so no model behavior changes; **Vertex** already accepted the field, making this a no-op there and a fix on the Developer API. + - The middleware never read the value anywhere — it was only ever forwarded. The three #1495 tests that asserted pass-through (they validated `model_validate()` only, never a live request) were updated, and a regression test reproduces the exact dojo HITL tool schema and asserts `additional_properties` appears at no depth. + +- **FIX**: `adk_events_to_messages` now preserves `file_data` parts on user + events (#1771). Previously only the text part was extracted, so image, + audio, video, and document attachments were silently dropped from + `MESSAGES_SNAPSHOT` and disappeared from chat history after a page + refresh. MIME prefix dispatches to `ImageInputContent`, `AudioInputContent`, + `VideoInputContent`, or `DocumentInputContent`; `file_data` parts with no + `file_uri` are filtered out and text-only events still serialize as a + plain string. Thanks to @viktor-matic for the fix. + +## [0.6.5] - 2026-05-28 + +### Fixed + +- **FIX**: Revert the `AGUIToolset.bind()` delegation introduced in 0.6.4 (#1746) + and restore per-run `ClientProxyToolset` replacement (#1786). Thanks to + @jplikesbikes for catching the regression and driving the fix. + - **Impact**: 0.6.4 introduced a cross-user data leak under concurrent runs. + With `max_concurrent_executions=10` (default) and serialization only per + `(thread_id, user_id)`, two overlapping runs would share a single mutable + `_delegate` slot on the construction-time `AGUIToolset` placeholder. + Run A's `TOOL_CALL_START/ARGS/END` events could be emitted onto Run B's + `event_queue` (a confidentiality breach: tool-call arguments generated + from one user's conversation/state would land on another user's stream + and Run A would stall, never having been told about the call). A + secondary failure mode stranded any still-in-flight run with an empty + tool list when the first run's `finally` block unbound the shared + placeholder. Tool *results* (client → agent) were not affected — they + return via a separate `RunAgentInput` matched per `(thread_id, user)`. + - **Root cause of the 0.6.4 regression**: The #1746 rationale — that + ADK 2.0 `Runner.__init__` eagerly caches `get_tools()` results and + therefore the `AGUIToolset` object must be preserved by reference — + does not match the GA behavior. Verified against `google-adk` 1.16.0, + 1.34.1, 2.0.0, and 2.1.0: `Runner.__init__` does *no* tool resolution; + `agent.canonical_tools` reads `self.tools` live per invocation + (`flows/llm_flows/base_llm_flow.py` caches on the per-`run_async` + `InvocationContext`, and the toolset-level cache in + `tools/base_toolset.py` is keyed by `invocation_id`). The actual #1389 + failure mode on the pre-release `google-adk==2.0.0a2` was a separate + well-formed-`BaseToolset` issue: a toolset missing + `_use_invocation_cache` (i.e. not calling `BaseToolset.__init__`) is + silently dropped to `[]` by `llm_agent._convert_tool_union_to_tools`. + That fix — `super().__init__()` on `AGUIToolset` — is retained; only + the unnecessary `bind()` delegation that introduced the concurrency + hazard is reverted. + - **Fix**: `_update_agent_tools_recursive` once again replaces the + placeholder per-run with a fresh `ClientProxyToolset` inside the + per-run shallow-copied agent's own `tools` list. The construction-time + placeholder is never mutated; each run carries its own `input.tools` + and `event_queue`. + - **Tests added** (pass on both `google-adk==1.26.0` and + `google-adk==2.1.0`): + - `tests/test_agui_toolset_concurrency.py` — three tests asserting + per-run isolation, including a real concurrent-`asyncio` + reproduction with a barrier. + - `tests/test_adk_2_0_compat.py::TestAGUIToolsetReplacement::test_swapped_in_toolset_resolves_nonempty_via_get_tools_with_prefix` + — guards the real #1389 silent-drop path (via + `_use_invocation_cache`) so it cannot silently regress. + - **Compatibility note**: Pre-release `google-adk==2.0.0a2` snapshotted + toolset references at `LlmAgent` construction (via `model_post_init` → + `_build_nodes`) and would regress to an empty tool list under per-run + replacement; the supported install range `>=1.16.0,<3.0.0` never + resolves a pre-release. + ## [0.6.4] - 2026-05-26 ### Added diff --git a/integrations/adk-middleware/python/CONFIGURATION.md b/integrations/adk-middleware/python/CONFIGURATION.md index 4898ec169d..68fa703255 100644 --- a/integrations/adk-middleware/python/CONFIGURATION.md +++ b/integrations/adk-middleware/python/CONFIGURATION.md @@ -243,7 +243,7 @@ from google.adk import tools as adk_tools # Add memory tools to the ADK agent (not ADKAgent) my_agent = Agent( name="assistant", - model="gemini-2.0-flash", + model="gemini-3.5-flash", instruction="You are a helpful assistant.", tools=[ AGUIToolset(), # Add the tools provided by the AG-UI client diff --git a/integrations/adk-middleware/python/LICENSE b/integrations/adk-middleware/python/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/adk-middleware/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/adk-middleware/python/README.md b/integrations/adk-middleware/python/README.md index 794c757b98..36c85eafe8 100644 --- a/integrations/adk-middleware/python/README.md +++ b/integrations/adk-middleware/python/README.md @@ -390,7 +390,7 @@ from google.adk.agents import Agent hello_agent = LlmAgent( name='HelloAgent', - model='gemini-2.5-flash', + model='gemini-3.5-flash', description="An agent that greets users", instruction=""" You are a friendly assistant that greets users. @@ -403,7 +403,7 @@ hello_agent = LlmAgent( goodbye_agent = LlmAgent( name='GoodbyeAgent', - model='gemini-2.5-flash', + model='gemini-3.5-flash', description="An agent that says goodbye", instruction=""" You are a friendly assistant that says goodbye to users. @@ -417,7 +417,7 @@ goodbye_agent = LlmAgent( # create an agent agent = LlmAgent( name='QaAgent', - model='gemini-2.5-flash', + model='gemini-3.5-flash', description="The QaAgent helps users by answering their questions.", instruction=""" You are a helpful assistant. Help users by answering their questions and assisting with their needs. diff --git a/integrations/adk-middleware/python/TOOLS.md b/integrations/adk-middleware/python/TOOLS.md index bcfb00703e..19a585578b 100644 --- a/integrations/adk-middleware/python/TOOLS.md +++ b/integrations/adk-middleware/python/TOOLS.md @@ -95,7 +95,7 @@ weather_tool = Tool( # 2. Set up ADK agent with tool support agent = LlmAgent( name="assistant", - model="gemini-2.0-flash", + model="gemini-3.5-flash", instruction="""You are a helpful assistant that can request approvals and perform calculations. Use request_approval for sensitive operations that need human review. Use calculate for math operations and get_weather for weather information.""" diff --git a/integrations/adk-middleware/python/USAGE.md b/integrations/adk-middleware/python/USAGE.md index 99d3989d78..eaaaeebc6f 100644 --- a/integrations/adk-middleware/python/USAGE.md +++ b/integrations/adk-middleware/python/USAGE.md @@ -118,7 +118,7 @@ app = App( name="my_assistant", root_agent=Agent( name="assistant", - model="gemini-2.5-flash", + model="gemini-3.5-flash", instruction="You are a helpful assistant.", tools=[ AGUIToolset(), # Add the tools provided by the AG-UI client @@ -182,7 +182,7 @@ from google.adk import tools as adk_tools # Create agent with memory tools - THIS IS CORRECT my_agent = Agent( name="assistant", - model="gemini-2.0-flash", + model="gemini-3.5-flash", instruction="You are a helpful assistant.", tools=[ AGUIToolset(), # Add the tools provided by the AG-UI client @@ -341,7 +341,7 @@ def context_aware_instructions(ctx: ReadonlyContext) -> str: # Create agent with dynamic instructions my_agent = LlmAgent( name="assistant", - model="gemini-2.0-flash", + model="gemini-3.5-flash", instruction=context_aware_instructions, # Callable, not string ) ``` @@ -436,7 +436,7 @@ from google.adk.agents import LlmAgent agent = LlmAgent( name="writer", - model="gemini-2.0-flash", + model="gemini-3.5-flash", instruction="Use write_document to write documents.", tools=[write_document, AGUIToolset()], ) diff --git a/integrations/adk-middleware/python/examples/pyproject.toml b/integrations/adk-middleware/python/examples/pyproject.toml index aa100c9b1b..73995d65ae 100644 --- a/integrations/adk-middleware/python/examples/pyproject.toml +++ b/integrations/adk-middleware/python/examples/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "python-dotenv>=1.0.0", "pydantic>=2.0.0", "ag_ui_adk", - "google-adk>=1.23.0", + "google-adk>=1.28.1,<3.0.0", ] [project.scripts] diff --git a/integrations/adk-middleware/python/examples/server/__init__.py b/integrations/adk-middleware/python/examples/server/__init__.py index 5ff6b84216..f80c70f5b8 100644 --- a/integrations/adk-middleware/python/examples/server/__init__.py +++ b/integrations/adk-middleware/python/examples/server/__init__.py @@ -26,12 +26,18 @@ shared_state_app, backend_tool_rendering_app, predictive_state_updates_app, + a2ui_dynamic_schema_app, + a2ui_fixed_schema_app, + a2ui_recovery_app, ) app = FastAPI(title='ADK Middleware Demo') # Include routers instead of mounting apps to show routes in docs app.include_router(agentic_chat_app.router, prefix='/chat', tags=['Agentic Chat']) +app.include_router(a2ui_dynamic_schema_app.router, prefix='/adk-a2ui-dynamic-schema', tags=['A2UI Dynamic Schema']) +app.include_router(a2ui_fixed_schema_app.router, prefix='/adk-a2ui-fixed-schema', tags=['A2UI Fixed Schema']) +app.include_router(a2ui_recovery_app.router, prefix='/adk-a2ui-recovery', tags=['A2UI Error Recovery']) app.include_router(agentic_generative_ui_app.router, prefix='/adk-agentic-generative-ui', tags=['Agentic Generative UI']) app.include_router(tool_based_generative_ui_app.router, prefix='/adk-tool-based-generative-ui', tags=['Tool Based Generative UI']) app.include_router(human_in_the_loop_app.router, prefix='/adk-human-in-loop-agent', tags=['Human in the Loop']) @@ -54,6 +60,9 @@ async def root(): "backend_tool_rendering": "/backend_tool_rendering", "predictive_state_updates": "/adk-predictive-state-agent", "agentic_chat_reasoning": "/adk-reasoning-chat", + "a2ui_dynamic_schema": "/adk-a2ui-dynamic-schema", + "a2ui_fixed_schema": "/adk-a2ui-fixed-schema", + "a2ui_recovery": "/adk-a2ui-recovery", "docs": "/docs" } } diff --git a/integrations/adk-middleware/python/examples/server/api/__init__.py b/integrations/adk-middleware/python/examples/server/api/__init__.py index 2fa726b89d..7953df1994 100644 --- a/integrations/adk-middleware/python/examples/server/api/__init__.py +++ b/integrations/adk-middleware/python/examples/server/api/__init__.py @@ -8,6 +8,9 @@ from .predictive_state_updates import app as predictive_state_updates_app from .backend_tool_rendering import app as backend_tool_rendering_app from .agentic_chat_reasoning import app as agentic_chat_reasoning_app +from .a2ui_dynamic_schema import app as a2ui_dynamic_schema_app +from .a2ui_fixed_schema import app as a2ui_fixed_schema_app +from .a2ui_recovery import app as a2ui_recovery_app __all__ = [ "agentic_chat_app", @@ -18,4 +21,7 @@ "shared_state_app", "predictive_state_updates_app", "backend_tool_rendering_app", + "a2ui_dynamic_schema_app", + "a2ui_fixed_schema_app", + "a2ui_recovery_app", ] diff --git a/integrations/adk-middleware/python/examples/server/api/a2ui_dynamic_schema.py b/integrations/adk-middleware/python/examples/server/api/a2ui_dynamic_schema.py new file mode 100644 index 0000000000..33c2bd208e --- /dev/null +++ b/integrations/adk-middleware/python/examples/server/api/a2ui_dynamic_schema.py @@ -0,0 +1,105 @@ +"""A2UI Dynamic Schema feature (OSS-158). + +ADK port of the LangGraph ``a2ui_dynamic_schema`` example, using the adapter's +A2UI **auto-injection**: the ``LlmAgent`` wires no A2UI tool itself. When the +runtime forwards ``injectA2UITool``, the ADKAgent injects ``generate_a2ui`` +onto the agent and infers the sub-agent model from the agent's +``canonical_model``. Inside the tool, a forced ``render_a2ui`` sub-agent +generates a v0.9 A2UI surface and the toolkit's validate->retry recovery loop +runs. The result is wrapped as ``a2ui_operations``, which the A2UI middleware +detects in the tool result and renders automatically. +""" + +from __future__ import annotations + +from fastapi import FastAPI +from google.adk.agents import LlmAgent + +from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint + +# Catalog the dojo renders this demo against (HotelCard / ProductCard / +# TeamMemberCard / Row). The client (dojo page) supplies the catalog via the +# CopilotKit `a2ui` prop; the middleware injects it into the run, and the adapter +# renders it into the sub-agent prompt (Google's render_as_llm_instructions) and +# validates against it (toolkit, structural/lenient). The subagent never picks one. +CUSTOM_CATALOG_ID = "https://a2ui.org/demos/dojo/dynamic_catalog.json" + +# Project-specific composition rules — tells the subagent how to use the +# pre-made domain components shipped in the dojo's dynamic catalog. Kept +# byte-identical to the LangGraph python example so both integrations behave +# the same for a given prompt. +COMPOSITION_GUIDE = """ +## Available Pre-made Components + +You have 4 components. Use Row as the root with structural children to repeat a card per item. + +### Row +Layout container. Use structural children to repeat a card template: + {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}} + +### HotelCard +Props: name, location, rating (number 0-5), pricePerNight, amenities (optional), action +Example: + {"id":"card","component":"HotelCard","name":{"path":"name"},"location":{"path":"location"}, + "rating":{"path":"rating"},"pricePerNight":{"path":"pricePerNight"}, + "action":{"event":{"name":"book","context":{"name":{"path":"name"}}}}} + +### ProductCard +Props: name, price, rating (number 0-5), description (optional), badge (optional), action +Example: + {"id":"card","component":"ProductCard","name":{"path":"name"},"price":{"path":"price"}, + "rating":{"path":"rating"},"description":{"path":"description"}, + "action":{"event":{"name":"select","context":{"name":{"path":"name"}}}}} + +### TeamMemberCard +Props: name, role, department (optional), email (optional), avatarUrl (optional), action +Example: + {"id":"card","component":"TeamMemberCard","name":{"path":"name"},"role":{"path":"role"}, + "department":{"path":"department"},"email":{"path":"email"}, + "action":{"event":{"name":"contact","context":{"name":{"path":"name"}}}}} + +## RULES +- Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"} +- Inside templates, use RELATIVE paths (no leading slash): {"path":"name"} not {"path":"/name"} +- Always provide data in the "data" argument as {"items":[...]} +- Pick the card type that best matches the user's request +- Generate 3-4 realistic items with diverse data +""" + +SYSTEM_PROMPT = """You are a helpful assistant that creates rich visual UI on the fly. + +When the user asks for visual content (product comparisons, dashboards, lists, cards, etc.), +use the generate_a2ui tool to create a dynamic A2UI surface. +When the user asks to MODIFY a surface you already rendered, call generate_a2ui with +intent="update" and target_surface_id set to that surface's id. +IMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.""" + +# gemini-2.5-pro reliably produces valid, in-catalog A2UI for this demo. The +# auto-injected generate_a2ui tool infers its sub-agent model from this agent's +# canonical_model (the registry resolves the string to a Gemini instance). +_MODEL = "gemini-2.5-pro" + +dynamic_schema_agent = LlmAgent( + model=_MODEL, + name="a2ui_dynamic_schema", + instruction=SYSTEM_PROMPT, + # generate_a2ui is auto-injected by the adapter; nothing wired here. +) + +adk_a2ui_dynamic_schema = ADKAgent( + adk_agent=dynamic_schema_agent, + app_name="demo_app", + user_id="demo_user", + session_timeout_seconds=3600, + use_in_memory_services=True, + # Optional A2UI preferences; the runtime's injectA2UITool flag (forwarded by + # the dojo's per-agent A2UIMiddleware) triggers injection and the adapter + # renders these into the sub-agent prompt. + a2ui={ + "default_catalog_id": CUSTOM_CATALOG_ID, + "guidelines": {"composition_guide": COMPOSITION_GUIDE}, + }, +) + +app = FastAPI(title="ADK Middleware A2UI Dynamic Schema") +add_adk_fastapi_endpoint(app, adk_a2ui_dynamic_schema, path="/") diff --git a/integrations/adk-middleware/python/examples/server/api/a2ui_fixed_schema.py b/integrations/adk-middleware/python/examples/server/api/a2ui_fixed_schema.py new file mode 100644 index 0000000000..135821bb6e --- /dev/null +++ b/integrations/adk-middleware/python/examples/server/api/a2ui_fixed_schema.py @@ -0,0 +1,140 @@ +"""A2UI Fixed Schema feature (OSS-158). + +ADK port of the LangGraph ``a2ui_fixed_schema`` example. Unlike the dynamic +demo (which forces a ``render_a2ui`` sub-agent to *generate* a surface), the +fixed-schema demo uses two plain ADK backend tools — ``search_flights`` and +``search_hotels``. The component layout is loaded from JSON files at startup +(``a2ui.load_schema`` equivalent); only the *data* changes per call. Each tool +returns the ``a2ui_operations`` envelope directly (createSurface -> +updateComponents -> updateDataModel), which the A2UI middleware detects in the +tool result and paints. No sub-agent, no generation, no recovery loop. + +The result is returned as a Python ``dict`` (not a JSON string): ADK keeps a +dict tool-return as the function response as-is, and the middleware's +``_serialize_tool_response`` then ``json.dumps`` it into the +``{"a2ui_operations": [...]}`` string the client's A2UIMiddleware looks for. +Returning a string instead would make ADK wrap it as ``{"result": "..."}``, +which the middleware would not recognize. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, List + +from fastapi import FastAPI +from google.adk.agents import LlmAgent + +from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint + +from ag_ui_a2ui_toolkit import ( + A2UI_OPERATIONS_KEY, + create_surface, + update_components, + update_data_model, +) + +# Both surfaces render against the dojo's fixed catalog (Row / FlightCard / +# HotelCard / StarRating). The client (dojo page) supplies the catalog via the +# CopilotKit `a2ui` prop; here we only reference its id in createSurface. +CUSTOM_CATALOG_ID = "https://a2ui.org/demos/dojo/fixed_catalog.json" + +_SCHEMAS_DIR = Path(__file__).parent / "a2ui_fixed_schema_schemas" + + +def _load_schema(name: str) -> list[dict[str, Any]]: + """Load a fixed A2UI component layout from a JSON file.""" + with open(_SCHEMAS_DIR / name) as f: + return json.load(f) + + +FLIGHT_SURFACE_ID = "flight-search-results" +FLIGHT_SCHEMA = _load_schema("flight_schema.json") + +HOTEL_SURFACE_ID = "hotel-search-results" +HOTEL_SCHEMA = _load_schema("hotel_schema.json") + + +def _envelope( + surface_id: str, schema: list[dict[str, Any]], data: dict[str, Any] +) -> dict[str, Any]: + """Build the A2UI operations envelope dict for a fixed-schema surface.""" + return { + A2UI_OPERATIONS_KEY: [ + create_surface(surface_id, catalog_id=CUSTOM_CATALOG_ID), + update_components(surface_id, schema), + update_data_model(surface_id, data), + ] + } + + +def search_flights(flights: List[dict]) -> dict[str, Any]: + """Search for flights and display the results as rich cards. + + Args: + flights: A list of flight objects. Each flight must have: + id, airline (e.g. "United Airlines"), + airlineLogo (Google favicon API: + "https://www.google.com/s2/favicons?domain={airline_domain}&sz=128" + e.g. "https://www.google.com/s2/favicons?domain=united.com&sz=128"), + flightNumber, origin, destination, + date (short readable format like "Tue, Mar 18" — use near-future dates), + departureTime, arrivalTime, + duration (e.g. "4h 25m"), status (e.g. "On Time" or "Delayed"), + statusIcon (colored dot: "https://placehold.co/12/22c55e/22c55e.png" + for On Time, "https://placehold.co/12/eab308/eab308.png" for Delayed), + and price (e.g. "$289"). + """ + return _envelope(FLIGHT_SURFACE_ID, FLIGHT_SCHEMA, {"flights": flights}) + + +def search_hotels(hotels: List[dict]) -> dict[str, Any]: + """Search for hotels and display the results as rich cards with star ratings. + + Args: + hotels: A list of hotel objects. Each hotel must have: + id, name (e.g. "The Plaza"), + location (e.g. "Midtown Manhattan, NYC"), + rating (float 0-5, e.g. 4.5), + and price (per night, e.g. "$350"). + + Generate 3-4 realistic hotel results. + """ + return _envelope(HOTEL_SURFACE_ID, HOTEL_SCHEMA, {"hotels": hotels}) + + +SYSTEM_PROMPT = """You are a helpful travel assistant that can search for flights and hotels. + +When the user asks about flights, use the search_flights tool. +When the user asks about hotels, use the search_hotels tool. +IMPORTANT: After calling a tool, do NOT repeat or summarize the data in your text response. The tool renders a rich UI automatically. Just say something brief like "Here are your results" or ask if they'd like to book. + +For flights, each needs: id, airline, airlineLogo (Google favicon API), flightNumber, origin, destination, +date, departureTime, arrivalTime, duration, status, statusIcon, and price. + +For hotels, each needs: id, name, location, rating (float 0-5), and price (per night). + +Generate 3-5 realistic results.""" + +# gemini-2.5-pro reliably calls the right tool with well-formed data for this +# demo; keep it on the same model as the dynamic demo for parity. +_MODEL = "gemini-2.5-pro" + +fixed_schema_agent = LlmAgent( + model=_MODEL, + name="a2ui_fixed_schema", + instruction=SYSTEM_PROMPT, + tools=[search_flights, search_hotels], +) + +adk_a2ui_fixed_schema = ADKAgent( + adk_agent=fixed_schema_agent, + app_name="demo_app", + user_id="demo_user", + session_timeout_seconds=3600, + use_in_memory_services=True, +) + +app = FastAPI(title="ADK Middleware A2UI Fixed Schema") +add_adk_fastapi_endpoint(app, adk_a2ui_fixed_schema, path="/") diff --git a/integrations/adk-middleware/python/examples/server/api/a2ui_fixed_schema_schemas/flight_schema.json b/integrations/adk-middleware/python/examples/server/api/a2ui_fixed_schema_schemas/flight_schema.json new file mode 100644 index 0000000000..14b10cb11d --- /dev/null +++ b/integrations/adk-middleware/python/examples/server/api/a2ui_fixed_schema_schemas/flight_schema.json @@ -0,0 +1,37 @@ +[ + { + "id": "root", + "component": "Row", + "children": { + "componentId": "flight-card", + "path": "/flights" + }, + "gap": 16 + }, + { + "id": "flight-card", + "component": "FlightCard", + "airline": { "path": "airline" }, + "airlineLogo": { "path": "airlineLogo" }, + "flightNumber": { "path": "flightNumber" }, + "origin": { "path": "origin" }, + "destination": { "path": "destination" }, + "date": { "path": "date" }, + "departureTime": { "path": "departureTime" }, + "arrivalTime": { "path": "arrivalTime" }, + "duration": { "path": "duration" }, + "status": { "path": "status" }, + "price": { "path": "price" }, + "action": { + "event": { + "name": "book_flight", + "context": { + "flightNumber": { "path": "flightNumber" }, + "origin": { "path": "origin" }, + "destination": { "path": "destination" }, + "price": { "path": "price" } + } + } + } + } +] diff --git a/integrations/adk-middleware/python/examples/server/api/a2ui_fixed_schema_schemas/hotel_schema.json b/integrations/adk-middleware/python/examples/server/api/a2ui_fixed_schema_schemas/hotel_schema.json new file mode 100644 index 0000000000..9753adba5b --- /dev/null +++ b/integrations/adk-middleware/python/examples/server/api/a2ui_fixed_schema_schemas/hotel_schema.json @@ -0,0 +1,28 @@ +[ + { + "id": "root", + "component": "Row", + "children": { + "componentId": "hotel-card", + "path": "/hotels" + }, + "gap": 16 + }, + { + "id": "hotel-card", + "component": "HotelCard", + "name": { "path": "name" }, + "location": { "path": "location" }, + "rating": { "path": "rating" }, + "pricePerNight": { "path": "price" }, + "action": { + "event": { + "name": "book_hotel", + "context": { + "hotelName": { "path": "name" }, + "price": { "path": "price" } + } + } + } + } +] diff --git a/integrations/adk-middleware/python/examples/server/api/a2ui_recovery.py b/integrations/adk-middleware/python/examples/server/api/a2ui_recovery.py new file mode 100644 index 0000000000..20e9138c6f --- /dev/null +++ b/integrations/adk-middleware/python/examples/server/api/a2ui_recovery.py @@ -0,0 +1,64 @@ +"""A2UI Error Recovery feature (OSS-158). + +ADK port of the LangGraph ``a2ui_recovery`` example — the same dynamic-schema +setup with the validate->retry recovery loop made explicit. The showcase forces +an invalid->valid (recover) and an always-invalid (exhaust) sequence via aimock +fixtures: a faulty surface never paints (the middleware gate suppresses it), the +errors are fed back, and either a valid surface paints or a tasteful hard-failure +is shown once the attempt cap is hit. +""" + +from __future__ import annotations + +import logging + +from fastapi import FastAPI +from google.adk.agents import LlmAgent +from google.adk.models import Gemini + +from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint, get_a2ui_tool + +from .a2ui_dynamic_schema import COMPOSITION_GUIDE, CUSTOM_CATALOG_ID, SYSTEM_PROMPT + +logger = logging.getLogger(__name__) + +_MODEL = "gemini-2.5-pro" + + +def _log_attempt(record: dict) -> None: + # Dev observability: each attempt (incl. rejected ones) is logged. + logger.info( + "[a2ui recovery] attempt %s: %s %s", + record.get("attempt"), + "valid" if record.get("ok") else "invalid", + record.get("errors"), + ) + + +a2ui_tool = get_a2ui_tool({ + "model": Gemini(model=_MODEL), + "default_catalog_id": CUSTOM_CATALOG_ID, + "guidelines": {"composition_guide": COMPOSITION_GUIDE}, + # Recovery runs by default; set explicitly for the showcase. Each rejected + # attempt's structural validation errors are fed back into the retry prompt. + "recovery": {"maxAttempts": 3}, + "on_a2ui_attempt": _log_attempt, +}) + +recovery_agent = LlmAgent( + model=_MODEL, + name="a2ui_recovery", + instruction=SYSTEM_PROMPT, + tools=[a2ui_tool], +) + +adk_a2ui_recovery = ADKAgent( + adk_agent=recovery_agent, + app_name="demo_app", + user_id="demo_user", + session_timeout_seconds=3600, + use_in_memory_services=True, +) + +app = FastAPI(title="ADK Middleware A2UI Error Recovery") +add_adk_fastapi_endpoint(app, adk_a2ui_recovery, path="/") diff --git a/integrations/adk-middleware/python/examples/server/api/backend_tool_rendering.py b/integrations/adk-middleware/python/examples/server/api/backend_tool_rendering.py index e13f6efc7e..f91fe8ac80 100644 --- a/integrations/adk-middleware/python/examples/server/api/backend_tool_rendering.py +++ b/integrations/adk-middleware/python/examples/server/api/backend_tool_rendering.py @@ -8,6 +8,7 @@ from google.adk import tools as adk_tools import httpx import json +import os # Compatibility shim for PreloadMemoryTool (renamed in newer ADK versions) try: @@ -58,6 +59,23 @@ def get_weather_condition(code: int) -> str: return conditions.get(code, "Unknown") +def _mock_weather(location: str) -> dict[str, str | float]: + """Return deterministic canned weather data for tests. + + Used when ``AG_UI_MOCK_WEATHER`` is set so e2e runs don't depend on the + live open-meteo API (which rate-limits CI's shared egress IPs). + """ + return { + "temperature": 21.0, + "feelsLike": 20.0, + "humidity": 65.0, + "windSpeed": 12.0, + "windGust": 18.0, + "conditions": get_weather_condition(1), + "location": location, + } + + async def get_weather(location: str) -> dict[str, str | float]: """Get current weather for a location. @@ -68,6 +86,9 @@ async def get_weather(location: str) -> dict[str, str | float]: Dictionary with weather information including temperature, feels like, humidity, wind speed, wind gust, conditions, and location name. """ + if os.getenv("AG_UI_MOCK_WEATHER"): + return _mock_weather(location) + async with httpx.AsyncClient() as client: # Geocode the location geocoding_url = ( diff --git a/integrations/adk-middleware/python/examples/uv.lock b/integrations/adk-middleware/python/examples/uv.lock index c9b1bae201..2dd3a21b76 100644 --- a/integrations/adk-middleware/python/examples/uv.lock +++ b/integrations/adk-middleware/python/examples/uv.lock @@ -2,12 +2,46 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.15" resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", + "python_full_version >= '3.13'", "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version < '3.11'", ] +[[package]] +name = "a2a-sdk" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "culsans", marker = "python_full_version < '3.13'" }, + { name = "google-api-core" }, + { name = "googleapis-common-protos" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "json-rpc" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/7e/8ac10bbf8b15b16574355f39b17dbdf617a282c27b41c7ff2116e30336df/a2a_sdk-1.1.0.tar.gz", hash = "sha256:e8102dad1b36709dbdc3d19319e38e6dfa3b3a79c30416030eb2d482576be204", size = 375726, upload-time = "2026-05-29T09:34:43.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/ea/3a5b160cfd51c67759b08748051094d9365ceff18127633d0021950c9860/a2a_sdk-1.1.0-py3-none-any.whl", hash = "sha256:d7f5846caf18033d8bf3108b11ec827dd8dd32f867c98848ede0e39474be93be", size = 241886, upload-time = "2026-05-29T09:34:41.484Z" }, +] + +[[package]] +name = "a2ui-agent-sdk" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "a2a-sdk" }, + { name = "google-adk" }, + { name = "google-genai" }, + { name = "jsonschema" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/ed/0a67c72a3aa56b95cea95cdc921e208dbf501ccf5bf18aba310953932d62/a2ui_agent_sdk-0.2.4.tar.gz", hash = "sha256:6c92363ca028e5c75a541f913e4bb1e6aef0c217e5c7dc693bb12712069b1e23", size = 279673, upload-time = "2026-06-03T23:09:24.628Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/f1/cc3ad505425af8b5495313df3b6842697fcf1edbe879a7ae79dce983cfec/a2ui_agent_sdk-0.2.4-py3-none-any.whl", hash = "sha256:3d768c16b98216df4dbb76930b69e809c256a1d2be159d55461c6bb67b2bedab", size = 85675, upload-time = "2026-06-03T23:09:23.315Z" }, +] + [[package]] name = "adk-middleware-examples" version = "0.1.0" @@ -26,35 +60,50 @@ dependencies = [ requires-dist = [ { name = "ag-ui-adk", editable = "../" }, { name = "fastapi", specifier = ">=0.104.0" }, - { name = "google-adk", specifier = ">=1.23.0" }, + { name = "google-adk", specifier = ">=1.28.1,<3.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, ] +[[package]] +name = "ag-ui-a2ui-toolkit" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b1/ea7ad7f0b3d1b20388d072ffbe4416577b4d4ab5471d45dfc04791a91602/ag_ui_a2ui_toolkit-0.0.3.tar.gz", hash = "sha256:468f25473ac00d098878da54c0069b7fa27dc63b4c1ff61315d4349a324c2fb7", size = 14785, upload-time = "2026-06-09T06:18:18.163Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/75/fc87bdf81bb1bf6d0fac09179e8bb17807d1bc5b3c0e8640f32e843b0857/ag_ui_a2ui_toolkit-0.0.3-py3-none-any.whl", hash = "sha256:e0354bd361c09f342fbe671cf870cbd19fdcb1b27e7a5bb2d8a392a4f00c2ba9", size = 16739, upload-time = "2026-06-09T06:18:17.316Z" }, +] + [[package]] name = "ag-ui-adk" -version = "0.6.0" +version = "0.6.5" source = { editable = "../" } dependencies = [ + { name = "a2ui-agent-sdk" }, + { name = "ag-ui-a2ui-toolkit" }, { name = "ag-ui-protocol" }, { name = "aiohttp" }, { name = "asyncio" }, { name = "fastapi" }, { name = "google-adk" }, { name = "pydantic" }, + { name = "sse-starlette" }, { name = "uvicorn" }, ] [package.metadata] requires-dist = [ + { name = "a2ui-agent-sdk", specifier = ">=0.2.4,<0.3.0" }, + { name = "ag-ui-a2ui-toolkit", specifier = ">=0.0.3" }, { name = "ag-ui-protocol", specifier = ">=0.1.15" }, - { name = "aiohttp", specifier = ">=3.12.0" }, + { name = "aiohttp", specifier = ">=3.14.1" }, { name = "asyncio", specifier = ">=3.4.3" }, { name = "fastapi", specifier = ">=0.115.2" }, - { name = "google-adk", specifier = ">=1.16.0,<2.0.0" }, + { name = "google-adk", specifier = ">=1.28.1,<3.0.0" }, { name = "pydantic", specifier = ">=2.11.7" }, + { name = "sse-starlette", specifier = ">=2.1.0" }, { name = "uvicorn", specifier = ">=0.35.0" }, ] @@ -62,6 +111,7 @@ requires-dist = [ dev = [ { name = "black", specifier = ">=26.3.1" }, { name = "flake8", specifier = ">=7.3.0" }, + { name = "greenlet", specifier = ">=3.0" }, { name = "isort", specifier = ">=6.0.1" }, { name = "mypy", specifier = ">=1.16.1" }, { name = "pluggy", specifier = ">=1.6.0" }, @@ -73,28 +123,28 @@ dev = [ [[package]] name = "ag-ui-protocol" -version = "0.1.15" +version = "0.1.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/71/96c21ae7e2fb9b610c1a90d38bd2de8b6e5b2900a63001f3882f43e519af/ag_ui_protocol-0.1.15.tar.gz", hash = "sha256:5e23c1042c7d4e364d685e68d2fb74d37c16bc83c66d270102d8eaedce56ad82", size = 6269, upload-time = "2026-04-01T15:44:33.136Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720, upload-time = "2026-06-02T17:26:15.627Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/a0/a73398d30bb0f9ad70cd70426151a4a19527a7296e48a3a16a50e1d5db05/ag_ui_protocol-0.1.15-py3-none-any.whl", hash = "sha256:85cde077023ccbc37b5ce2ad953537883c262d210320f201fc2ec4e85408b06a", size = 8661, upload-time = "2026-04-01T15:44:32.079Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0a/bcad8116eb058e4b4a305e3fc37ebd7efc879deeb86b854f1c5b8b6e97dd/ag_ui_protocol-0.1.19-py3-none-any.whl", hash = "sha256:898843b1410d378824da0c6a776486288b9c5828689d0bf563118868e37f390f", size = 13490, upload-time = "2026-06-02T17:26:16.313Z" }, ] [[package]] name = "aiohappyeyeballs" -version = "2.6.1" +version = "2.6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, ] [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -104,112 +154,143 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, - { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, - { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, - { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, - { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, - { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, - { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, - { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, - { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" }, + { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" }, + { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" }, + { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" }, + { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" }, + { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiologic" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/27/206615942005471499f6fbc36621582e24d0686f33c74b2d018fcfd4fe67/aiologic-0.16.0-py3-none-any.whl", hash = "sha256:e00ce5f68c5607c864d26aec99c0a33a83bdf8237aa7312ffbb96805af67d8b6", size = 135193, upload-time = "2025-11-27T23:48:40.099Z" }, ] [[package]] @@ -234,21 +315,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, ] -[[package]] -name = "alembic" -version = "1.16.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -269,17 +335,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -302,32 +367,33 @@ wheels = [ [[package]] name = "attrs" -version = "25.3.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "authlib" -version = "1.6.10" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/e2/2cd626412bfc3c78b17ca5e5ea8d489f8cae31d40b061f4da0a89068d8a3/authlib-1.6.10.tar.gz", hash = "sha256:856a4f54d6ef3361ca6bb6d14a27e8b88f8097cca795fb428ffe13720e2ecde6", size = 165333, upload-time = "2026-04-13T13:30:34.718Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/f6/9093f1ed17b6e2f4ac50d214543d4ec5268902a70e2158a752a06423b5ef/authlib-1.6.10-py2.py3-none-any.whl", hash = "sha256:aa639b43292554539924a3b4aaa9e81cd67ab64d3e28b22428c61f1200240287", size = 244351, upload-time = "2026-04-13T13:30:33.34Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] [[package]] name = "certifi" -version = "2025.8.3" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -414,87 +480,119 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.2.1" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, -] - -[[package]] -name = "cloudpickle" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/39/069100b84d7418bc358d81669d5748efb14b9cceacd2f9c75f550424132f/cloudpickle-3.1.1.tar.gz", hash = "sha256:b216fa8ae4019d5482a8ac3c95d8f6346115d8835911fd4aefd1a445e4242c64", size = 22113, upload-time = "2025-01-14T17:02:05.085Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/e8/64c37fadfc2816a7701fa8a6ed8d87327c7d54eacfbfb6edab14a2f2be75/cloudpickle-3.1.1-py3-none-any.whl", hash = "sha256:c8c5a44295039331ee9dad40ba100a9c7297b6f988e50e87ccdf3765a668350e", size = 20992, upload-time = "2025-01-14T17:02:02.417Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -508,62 +606,75 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, - { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, - { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, + { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, +] + +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, ] [[package]] @@ -575,15 +686,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[package]] -name = "docstring-parser" -version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, -] - [[package]] name = "exceptiongroup" version = "1.3.1" @@ -598,154 +700,163 @@ wheels = [ [[package]] name = "fastapi" -version = "0.123.10" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/ff/e01087de891010089f1620c916c0c13130f3898177955c13e2b02d22ec4a/fastapi-0.123.10.tar.gz", hash = "sha256:624d384d7cda7c096449c889fc776a0571948ba14c3c929fa8e9a78cd0b0a6a8", size = 356360, upload-time = "2025-12-05T21:27:46.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/f0/7cb92c4a720def85240fd63fbbcf147ce19e7a731c8e1032376bb5a486ac/fastapi-0.123.10-py3-none-any.whl", hash = "sha256:0503b7b7bc71bc98f7c90c9117d21fdf6147c0d74703011b87936becc86985c1", size = 111774, upload-time = "2025-12-05T21:27:44.78Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [[package]] name = "frozenlist" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, - { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, - { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, - { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, - { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, - { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, - { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, - { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, - { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, - { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, - { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, - { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, - { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, - { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, - { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, - { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, - { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, - { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, - { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, - { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, - { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, - { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, - { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, - { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, - { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, - { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, - { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, - { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, - { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, - { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, - { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, - { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, - { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, - { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, - { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, - { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, - { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, - { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, - { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, - { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, - { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, - { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, - { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, - { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, - { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, - { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, - { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, - { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "google-adk" -version = "1.23.0" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, - { name = "anyio" }, { name = "authlib" }, { name = "click" }, { name = "fastapi" }, - { name = "google-api-python-client" }, - { name = "google-auth" }, - { name = "google-cloud-aiplatform", extra = ["agent-engines"] }, - { name = "google-cloud-bigquery" }, - { name = "google-cloud-bigquery-storage" }, - { name = "google-cloud-bigtable" }, - { name = "google-cloud-discoveryengine" }, - { name = "google-cloud-pubsub" }, - { name = "google-cloud-secret-manager" }, - { name = "google-cloud-spanner" }, - { name = "google-cloud-speech" }, - { name = "google-cloud-storage" }, + { name = "google-auth", extra = ["pyopenssl"] }, { name = "google-genai" }, { name = "graphviz" }, + { name = "httpx" }, { name = "jsonschema" }, - { name = "mcp" }, { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-gcp-logging" }, - { name = "opentelemetry-exporter-gcp-monitoring" }, - { name = "opentelemetry-exporter-gcp-trace" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-resourcedetector-gcp" }, { name = "opentelemetry-sdk" }, - { name = "pyarrow" }, + { name = "packaging" }, { name = "pydantic" }, - { name = "python-dateutil" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "pyyaml" }, { name = "requests" }, - { name = "sqlalchemy" }, - { name = "sqlalchemy-spanner" }, { name = "starlette" }, { name = "tenacity" }, { name = "typing-extensions" }, @@ -754,14 +865,14 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/25/a8c7058812ae3a6046c1c909da31b4c95a6534f555ec50730fe215b2592c/google_adk-1.23.0.tar.gz", hash = "sha256:07829b3198d412ecddb8b102c6bc9511607a234989b7659be102d806e4c92258", size = 2072294, upload-time = "2026-01-22T23:26:52.352Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/65/3ff3f50b10dac3323ddecd694515e9f9ed345886e0eaf666d0e42c90748b/google_adk-2.2.0.tar.gz", hash = "sha256:04cb6318aba8829fe7c941ee1b456ccb4745253898c13595708c9eb07b4582ff", size = 3391545, upload-time = "2026-06-04T22:15:12.9Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/36/2abbcaad2bd4691863ac05189070c1e9f8d12ec16194f41a975c984883af/google_adk-1.23.0-py3-none-any.whl", hash = "sha256:94b77c9afa39042e77a35c2b3dad7e122d940e065cb5a9ba9e7b5de73786f993", size = 2418796, upload-time = "2026-01-22T23:26:50.289Z" }, + { url = "https://files.pythonhosted.org/packages/64/f5/44a3b20b17bac130497f2d1dde8b93c90cfc026983cd94f24488d540ea70/google_adk-2.2.0-py3-none-any.whl", hash = "sha256:ebdf3d931dc2b9c5b30d995358fc2ae99d59594c48a4aaf7496869ccd2c5f245", size = 3912613, upload-time = "2026-06-04T22:15:15.411Z" }, ] [[package]] name = "google-api-core" -version = "2.25.1" +version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -770,424 +881,35 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/21/e9d043e88222317afdbdb567165fdbc3b0aad90064c7e0c9eb0ad9955ad8/google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8", size = 165443, upload-time = "2025-06-12T20:52:20.439Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/4b/ead00905132820b623732b175d66354e9d3e69fcf2a5dcdab780664e7896/google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7", size = 160807, upload-time = "2025-06-12T20:52:19.334Z" }, -] - -[package.optional-dependencies] -grpc = [ - { name = "grpcio", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "grpcio-status" }, -] - -[[package]] -name = "google-api-python-client" -version = "2.181.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-auth-httplib2" }, - { name = "httplib2" }, - { name = "uritemplate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/96/5561a5d7e37781c880ca90975a70d61940ec1648b2b12e991311a9e39f83/google_api_python_client-2.181.0.tar.gz", hash = "sha256:d7060962a274a16a2c6f8fb4b1569324dbff11bfbca8eb050b88ead1dd32261c", size = 13545438, upload-time = "2025-09-02T15:41:33.852Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/03/72b7acf374a2cde9255df161686f00d8370117ac33e2bdd8fdadfe30272a/google_api_python_client-2.181.0-py3-none-any.whl", hash = "sha256:348730e3ece46434a01415f3d516d7a0885c8e624ce799f50f2d4d86c2475fb7", size = 14111793, upload-time = "2025-09-02T15:41:31.322Z" }, + { url = "https://files.pythonhosted.org/packages/86/40/9bdbb60b03a332bd45acb8703da08bbc27d991d35286b62e42acc86d243a/google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab", size = 173102, upload-time = "2026-06-03T14:51:26.729Z" }, ] [[package]] name = "google-auth" -version = "2.48.0" +version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, - { name = "rsa" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, -] - -[package.optional-dependencies] -requests = [ - { name = "requests" }, -] - -[[package]] -name = "google-auth-httplib2" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "httplib2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/be/217a598a818567b28e859ff087f347475c807a5649296fb5a817c58dacef/google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05", size = 10842, upload-time = "2023-12-12T17:40:30.722Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d", size = 9253, upload-time = "2023-12-12T17:40:13.055Z" }, -] - -[[package]] -name = "google-cloud-aiplatform" -version = "1.134.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docstring-parser" }, - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-bigquery" }, - { name = "google-cloud-resource-manager" }, - { name = "google-cloud-storage" }, - { name = "google-genai" }, - { name = "packaging" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/24/de4f21d0728d640b57bf7bbcd7460827a4daf9eaca61cb5b91be608c40bc/google_cloud_aiplatform-1.134.0.tar.gz", hash = "sha256:964cea117ca1ffc71742970e1091985adac72dfe76e1a1614a02a8cda50d6992", size = 9931075, upload-time = "2026-01-20T19:19:58.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/f4/6863f3951eb07afd790fe6f8f1a5984224f7df836546a34ed29ab0cfe9af/google_cloud_aiplatform-1.134.0-py2.py3-none-any.whl", hash = "sha256:f249ae67d622deca486310e0021093764892ac357fb744b9e79548f490017ddc", size = 8189190, upload-time = "2026-01-20T19:19:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" }, ] [package.optional-dependencies] -agent-engines = [ - { name = "cloudpickle" }, - { name = "google-cloud-iam" }, - { name = "google-cloud-logging" }, - { name = "google-cloud-trace" }, - { name = "opentelemetry-exporter-gcp-logging" }, - { name = "opentelemetry-exporter-gcp-trace" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "typing-extensions" }, -] - -[[package]] -name = "google-cloud-appengine-logging" -version = "1.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e7/ea/85da73d4f162b29d24ad591c4ce02688b44094ee5f3d6c0cc533c2b23b23/google_cloud_appengine_logging-1.6.2.tar.gz", hash = "sha256:4890928464c98da9eecc7bf4e0542eba2551512c0265462c10f3a3d2a6424b90", size = 16587, upload-time = "2025-06-11T22:38:53.525Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/9e/dc1fd7f838dcaf608c465171b1a25d8ce63f9987e2d5c73bda98792097a9/google_cloud_appengine_logging-1.6.2-py3-none-any.whl", hash = "sha256:2b28ed715e92b67e334c6fcfe1deb523f001919560257b25fc8fcda95fd63938", size = 16889, upload-time = "2025-06-11T22:38:52.26Z" }, -] - -[[package]] -name = "google-cloud-audit-log" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/85/af/53b4ef636e492d136b3c217e52a07bee569430dda07b8e515d5f2b701b1e/google_cloud_audit_log-0.3.2.tar.gz", hash = "sha256:2598f1533a7d7cdd6c7bf448c12e5519c1d53162d78784e10bcdd1df67791bc3", size = 33377, upload-time = "2025-03-17T11:27:59.808Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/74/38a70339e706b174b3c1117ad931aaa0ff0565b599869317a220d1967e1b/google_cloud_audit_log-0.3.2-py3-none-any.whl", hash = "sha256:daaedfb947a0d77f524e1bd2b560242ab4836fe1afd6b06b92f152b9658554ed", size = 32472, upload-time = "2025-03-17T11:27:58.51Z" }, -] - -[[package]] -name = "google-cloud-bigquery" -version = "3.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-resumable-media" }, - { name = "packaging" }, - { name = "python-dateutil" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/01/3e1b7858817ba8f9555ae10f5269719f5d1d6e0a384ea0105c0228c0ce22/google_cloud_bigquery-3.37.0.tar.gz", hash = "sha256:4f8fe63f5b8d43abc99ce60b660d3ef3f63f22aabf69f4fe24a1b450ef82ed97", size = 502826, upload-time = "2025-09-09T17:24:16.652Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/90/f0f7db64ee5b96e30434b45ead3452565d0f65f6c0d85ec9ef6e059fb748/google_cloud_bigquery-3.37.0-py3-none-any.whl", hash = "sha256:f006611bcc83b3c071964a723953e918b699e574eb8614ba564ae3cdef148ee1", size = 258889, upload-time = "2025-09-09T17:24:15.249Z" }, -] - -[[package]] -name = "google-cloud-bigquery-storage" -version = "2.36.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/72/b5dbf3487ea320a87c6d1ba8bb7680fafdb3147343a06d928b4209abcdba/google_cloud_bigquery_storage-2.36.0.tar.gz", hash = "sha256:d3c1ce9d2d3a4d7116259889dcbe3c7c70506f71f6ce6bbe54aa0a68bbba8f8f", size = 306959, upload-time = "2025-12-18T18:01:45.916Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/50/70e4bc2d52b52145b6e70008fbf806cef850e809dd3e30b4493d91c069ea/google_cloud_bigquery_storage-2.36.0-py3-none-any.whl", hash = "sha256:1769e568070db672302771d2aec18341de10712aa9c4a8c549f417503e0149f0", size = 303731, upload-time = "2025-12-18T18:01:44.598Z" }, -] - -[[package]] -name = "google-cloud-bigtable" -version = "2.32.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "grpc-google-iam-v1" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/18/52eaef1e08b1570a56a74bb909345bfae082b6915e482df10de1fb0b341d/google_cloud_bigtable-2.32.0.tar.gz", hash = "sha256:1dcf8a9fae5801164dc184558cd8e9e930485424655faae254e2c7350fa66946", size = 746803, upload-time = "2025-08-06T17:28:54.589Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/89/2e3607c3c6f85954c3351078f3b891e5a2ec6dec9b964e260731818dcaec/google_cloud_bigtable-2.32.0-py3-none-any.whl", hash = "sha256:39881c36a4009703fa046337cf3259da4dd2cbcabe7b95ee5b0b0a8f19c3234e", size = 520438, upload-time = "2025-08-06T17:28:53.27Z" }, -] - -[[package]] -name = "google-cloud-core" -version = "2.4.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d6/b8/2b53838d2acd6ec6168fd284a990c76695e84c65deee79c9f3a4276f6b4f/google_cloud_core-2.4.3.tar.gz", hash = "sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53", size = 35861, upload-time = "2025-03-10T21:05:38.948Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/86/bda7241a8da2d28a754aad2ba0f6776e35b67e37c36ae0c45d49370f1014/google_cloud_core-2.4.3-py2.py3-none-any.whl", hash = "sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e", size = 29348, upload-time = "2025-03-10T21:05:37.785Z" }, -] - -[[package]] -name = "google-cloud-discoveryengine" -version = "0.13.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/cd/b33bbc4b096d937abee5ebfad3908b2bdc65acd1582191aa33beaa2b70a5/google_cloud_discoveryengine-0.13.12.tar.gz", hash = "sha256:d6b9f8fadd8ad0d2f4438231c5eb7772a317e9f59cafbcbadc19b5d54c609419", size = 3582382, upload-time = "2025-09-22T16:51:14.052Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/70/607f6011648f603d35e60a16c34aee68a0b39510e4268d4859f3268684f9/google_cloud_discoveryengine-0.13.12-py3-none-any.whl", hash = "sha256:295f8c6df3fb26b90fb82c2cd6fbcf4b477661addcb19a94eea16463a5c4e041", size = 3337248, upload-time = "2025-09-22T16:50:57.375Z" }, -] - -[[package]] -name = "google-cloud-iam" -version = "2.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/0b/037b1e1eb601646d6f49bc06d62094c1d0996b373dcbf70c426c6c51572e/google_cloud_iam-2.21.0.tar.gz", hash = "sha256:fc560527e22b97c6cbfba0797d867cf956c727ba687b586b9aa44d78e92281a3", size = 499038, upload-time = "2026-01-15T13:15:08.243Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/44/02ac4e147ea034a3d641c11b54c9d8d0b80fc1ea6a8b7d6c1588d208d42a/google_cloud_iam-2.21.0-py3-none-any.whl", hash = "sha256:1b4a21302b186a31f3a516ccff303779638308b7c801fb61a2406b6a0c6293c4", size = 458958, upload-time = "2026-01-15T13:13:40.671Z" }, -] - -[[package]] -name = "google-cloud-logging" -version = "3.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-appengine-logging" }, - { name = "google-cloud-audit-log" }, - { name = "google-cloud-core" }, - { name = "grpc-google-iam-v1" }, - { name = "opentelemetry-api" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/9c/d42ecc94f795a6545930e5f846a7ae59ff685ded8bc086648dd2bee31a1a/google_cloud_logging-3.12.1.tar.gz", hash = "sha256:36efc823985055b203904e83e1c8f9f999b3c64270bcda39d57386ca4effd678", size = 289569, upload-time = "2025-04-22T20:50:24.71Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/41/f8a3197d39b773a91f335dee36c92ef26a8ec96efe78d64baad89d367df4/google_cloud_logging-3.12.1-py2.py3-none-any.whl", hash = "sha256:6817878af76ec4e7568976772839ab2c43ddfd18fbbf2ce32b13ef549cd5a862", size = 229466, upload-time = "2025-04-22T20:50:23.294Z" }, -] - -[[package]] -name = "google-cloud-monitoring" -version = "2.29.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/a1/a1a0c678569f2a7b1fa65ef71ff528650231a298fc2b89ad49c9991eab94/google_cloud_monitoring-2.29.0.tar.gz", hash = "sha256:eedb8afd1c4e80e8c62435f05c448e9e65be907250a66d81e6af5909778267b6", size = 404769, upload-time = "2026-01-15T13:04:01.597Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/63/b1f6e86ddde8548a0cade2edf3c8ec2183e57f002ea4301b3890a6717190/google_cloud_monitoring-2.29.0-py3-none-any.whl", hash = "sha256:93aa264da0f57f3de2900b0250a37ca27068984f6d94e54175d27aea12a4637f", size = 387988, upload-time = "2026-01-15T13:03:23.528Z" }, -] - -[[package]] -name = "google-cloud-pubsub" -version = "2.34.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "grpcio-status" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/87/b0/7073a2d17074f0d4a53038c6141115db19f310a2f96bd3911690f15bd701/google_cloud_pubsub-2.34.0.tar.gz", hash = "sha256:25f98c3ba16a69871f9ebbad7aece3fe63c8afe7ba392aad2094be730d545976", size = 396526, upload-time = "2025-12-16T22:44:22.319Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/d3/9c06e5ccd3e5b0f4b3bc6d223cb21556e597571797851e9f8cc38b7e2c0b/google_cloud_pubsub-2.34.0-py3-none-any.whl", hash = "sha256:aa11b2471c6d509058b42a103ed1b3643f01048311a34fd38501a16663267206", size = 320110, upload-time = "2025-12-16T22:44:20.349Z" }, -] - -[[package]] -name = "google-cloud-resource-manager" -version = "1.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6e/ca/a4648f5038cb94af4b3942815942a03aa9398f9fb0bef55b3f1585b9940d/google_cloud_resource_manager-1.14.2.tar.gz", hash = "sha256:962e2d904c550d7bac48372607904ff7bb3277e3bb4a36d80cc9a37e28e6eb74", size = 446370, upload-time = "2025-03-17T11:35:56.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/ea/a92631c358da377af34d3a9682c97af83185c2d66363d5939ab4a1169a7f/google_cloud_resource_manager-1.14.2-py3-none-any.whl", hash = "sha256:d0fa954dedd1d2b8e13feae9099c01b8aac515b648e612834f9942d2795a9900", size = 394344, upload-time = "2025-03-17T11:35:54.722Z" }, -] - -[[package]] -name = "google-cloud-secret-manager" -version = "2.24.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/7a/2fa6735ec693d822fe08a76709c4d95d9b5b4c02e83e720497355039d2ee/google_cloud_secret_manager-2.24.0.tar.gz", hash = "sha256:ce573d40ffc2fb7d01719243a94ee17aa243ea642a6ae6c337501e58fbf642b5", size = 269516, upload-time = "2025-06-05T22:22:22.965Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/af/db1217cae1809e69a4527ee6293b82a9af2a1fb2313ad110c775e8f3c820/google_cloud_secret_manager-2.24.0-py3-none-any.whl", hash = "sha256:9bea1254827ecc14874bc86c63b899489f8f50bfe1442bfb2517530b30b3a89b", size = 218050, upload-time = "2025-06-10T02:02:19.88Z" }, -] - -[[package]] -name = "google-cloud-spanner" -version = "3.57.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-cloud-core" }, - { name = "grpc-google-iam-v1" }, - { name = "grpc-interceptor" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "sqlparse" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/e8/e008f9ffa2dcf596718d2533d96924735110378853c55f730d2527a19e04/google_cloud_spanner-3.57.0.tar.gz", hash = "sha256:73f52f58617449fcff7073274a7f7a798f4f7b2788eda26de3b7f98ad857ab99", size = 701574, upload-time = "2025-08-14T15:24:59.18Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/9f/66fe9118bc0e593b65ade612775e397f596b0bcd75daa3ea63dbe1020f95/google_cloud_spanner-3.57.0-py3-none-any.whl", hash = "sha256:5b10b40bc646091f1b4cbb2e7e2e82ec66bcce52c7105f86b65070d34d6df86f", size = 501380, upload-time = "2025-08-14T15:24:57.683Z" }, -] - -[[package]] -name = "google-cloud-speech" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9a/74/9c5a556f8af19cab461058aa15e1409e7afa453ca2383473a24a12801ef7/google_cloud_speech-2.33.0.tar.gz", hash = "sha256:fd08511b5124fdaa768d71a4054e84a5d8eb02531cb6f84f311c0387ea1314ed", size = 389072, upload-time = "2025-06-11T23:56:37.231Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/1d/880342b2541b4bad888ad8ab2ac77d4b5dad25b32a2a1c5f21140c14c8e3/google_cloud_speech-2.33.0-py3-none-any.whl", hash = "sha256:4ba16c8517c24a6abcde877289b0f40b719090504bf06b1adea248198ccd50a5", size = 335681, upload-time = "2025-06-11T23:56:36.026Z" }, +pyopenssl = [ + { name = "pyopenssl" }, ] - -[[package]] -name = "google-cloud-storage" -version = "2.19.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "google-resumable-media" }, +requests = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/76/4d965702e96bb67976e755bed9828fa50306dca003dbee08b67f41dd265e/google_cloud_storage-2.19.0.tar.gz", hash = "sha256:cd05e9e7191ba6cb68934d8eb76054d9be4562aa89dbc4236feee4d7d51342b2", size = 5535488, upload-time = "2024-12-05T01:35:06.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/94/6db383d8ee1adf45dc6c73477152b82731fa4c4a46d9c1932cc8757e0fd4/google_cloud_storage-2.19.0-py2.py3-none-any.whl", hash = "sha256:aeb971b5c29cf8ab98445082cbfe7b161a1f48ed275822f59ed3f1524ea54fba", size = 131787, upload-time = "2024-12-05T01:35:04.736Z" }, -] - -[[package]] -name = "google-cloud-trace" -version = "1.16.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c5/ea/0e42e2196fb2bc8c7b25f081a0b46b5053d160b34d5322e7eac2d5f7a742/google_cloud_trace-1.16.2.tar.gz", hash = "sha256:89bef223a512465951eb49335be6d60bee0396d576602dbf56368439d303cab4", size = 97826, upload-time = "2025-06-12T00:53:02.12Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/96/7a8d271e91effa9ccc2fd7cfd5cf287a2d7900080a475477c2ac0c7a331d/google_cloud_trace-1.16.2-py3-none-any.whl", hash = "sha256:40fb74607752e4ee0f3d7e5fc6b8f6eb1803982254a1507ba918172484131456", size = 103755, upload-time = "2025-06-12T00:53:00.672Z" }, -] - -[[package]] -name = "google-crc32c" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/ae/87802e6d9f9d69adfaedfcfd599266bf386a54d0be058b532d04c794f76d/google_crc32c-1.7.1.tar.gz", hash = "sha256:2bff2305f98846f3e825dbeec9ee406f89da7962accdb29356e4eadc251bd472", size = 14495, upload-time = "2025-03-26T14:29:13.32Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/69/b1b05cf415df0d86691d6a8b4b7e60ab3a6fb6efb783ee5cd3ed1382bfd3/google_crc32c-1.7.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:b07d48faf8292b4db7c3d64ab86f950c2e94e93a11fd47271c28ba458e4a0d76", size = 30467, upload-time = "2025-03-26T14:31:11.92Z" }, - { url = "https://files.pythonhosted.org/packages/44/3d/92f8928ecd671bd5b071756596971c79d252d09b835cdca5a44177fa87aa/google_crc32c-1.7.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:7cc81b3a2fbd932a4313eb53cc7d9dde424088ca3a0337160f35d91826880c1d", size = 30311, upload-time = "2025-03-26T14:53:14.161Z" }, - { url = "https://files.pythonhosted.org/packages/33/42/c2d15a73df79d45ed6b430b9e801d0bd8e28ac139a9012d7d58af50a385d/google_crc32c-1.7.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1c67ca0a1f5b56162951a9dae987988679a7db682d6f97ce0f6381ebf0fbea4c", size = 37889, upload-time = "2025-03-26T14:41:27.83Z" }, - { url = "https://files.pythonhosted.org/packages/57/ea/ac59c86a3c694afd117bb669bde32aaf17d0de4305d01d706495f09cbf19/google_crc32c-1.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc5319db92daa516b653600794d5b9f9439a9a121f3e162f94b0e1891c7933cb", size = 33028, upload-time = "2025-03-26T14:41:29.141Z" }, - { url = "https://files.pythonhosted.org/packages/60/44/87e77e8476767a4a93f6cf271157c6d948eacec63688c093580af13b04be/google_crc32c-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcdf5a64adb747610140572ed18d011896e3b9ae5195f2514b7ff678c80f1603", size = 38026, upload-time = "2025-03-26T14:41:29.921Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bf/21ac7bb305cd7c1a6de9c52f71db0868e104a5b573a4977cd9d0ff830f82/google_crc32c-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:754561c6c66e89d55754106739e22fdaa93fafa8da7221b29c8b8e8270c6ec8a", size = 33476, upload-time = "2025-03-26T14:29:09.086Z" }, - { url = "https://files.pythonhosted.org/packages/f7/94/220139ea87822b6fdfdab4fb9ba81b3fff7ea2c82e2af34adc726085bffc/google_crc32c-1.7.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6fbab4b935989e2c3610371963ba1b86afb09537fd0c633049be82afe153ac06", size = 30468, upload-time = "2025-03-26T14:32:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/789b23bdeeb9d15dc2904660463ad539d0318286d7633fe2760c10ed0c1c/google_crc32c-1.7.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:ed66cbe1ed9cbaaad9392b5259b3eba4a9e565420d734e6238813c428c3336c9", size = 30313, upload-time = "2025-03-26T14:57:38.758Z" }, - { url = "https://files.pythonhosted.org/packages/81/b8/976a2b843610c211e7ccb3e248996a61e87dbb2c09b1499847e295080aec/google_crc32c-1.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee6547b657621b6cbed3562ea7826c3e11cab01cd33b74e1f677690652883e77", size = 33048, upload-time = "2025-03-26T14:41:30.679Z" }, - { url = "https://files.pythonhosted.org/packages/c9/16/a3842c2cf591093b111d4a5e2bfb478ac6692d02f1b386d2a33283a19dc9/google_crc32c-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d68e17bad8f7dd9a49181a1f5a8f4b251c6dbc8cc96fb79f1d321dfd57d66f53", size = 32669, upload-time = "2025-03-26T14:41:31.432Z" }, - { url = "https://files.pythonhosted.org/packages/04/17/ed9aba495916fcf5fe4ecb2267ceb851fc5f273c4e4625ae453350cfd564/google_crc32c-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:6335de12921f06e1f774d0dd1fbea6bf610abe0887a1638f64d694013138be5d", size = 33476, upload-time = "2025-03-26T14:29:10.211Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b7/787e2453cf8639c94b3d06c9d61f512234a82e1d12d13d18584bd3049904/google_crc32c-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2d73a68a653c57281401871dd4aeebbb6af3191dcac751a76ce430df4d403194", size = 30470, upload-time = "2025-03-26T14:34:31.655Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b4/6042c2b0cbac3ec3a69bb4c49b28d2f517b7a0f4a0232603c42c58e22b44/google_crc32c-1.7.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:22beacf83baaf59f9d3ab2bbb4db0fb018da8e5aebdce07ef9f09fce8220285e", size = 30315, upload-time = "2025-03-26T15:01:54.634Z" }, - { url = "https://files.pythonhosted.org/packages/29/ad/01e7a61a5d059bc57b702d9ff6a18b2585ad97f720bd0a0dbe215df1ab0e/google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19eafa0e4af11b0a4eb3974483d55d2d77ad1911e6cf6f832e1574f6781fd337", size = 33180, upload-time = "2025-03-26T14:41:32.168Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a5/7279055cf004561894ed3a7bfdf5bf90a53f28fadd01af7cd166e88ddf16/google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d86616faaea68101195c6bdc40c494e4d76f41e07a37ffdef270879c15fb65", size = 32794, upload-time = "2025-03-26T14:41:33.264Z" }, - { url = "https://files.pythonhosted.org/packages/0f/d6/77060dbd140c624e42ae3ece3df53b9d811000729a5c821b9fd671ceaac6/google_crc32c-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:b7491bdc0c7564fcf48c0179d2048ab2f7c7ba36b84ccd3a3e1c3f7a72d3bba6", size = 33477, upload-time = "2025-03-26T14:29:10.94Z" }, - { url = "https://files.pythonhosted.org/packages/8b/72/b8d785e9184ba6297a8620c8a37cf6e39b81a8ca01bb0796d7cbb28b3386/google_crc32c-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:df8b38bdaf1629d62d51be8bdd04888f37c451564c2042d36e5812da9eff3c35", size = 30467, upload-time = "2025-03-26T14:36:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/34/25/5f18076968212067c4e8ea95bf3b69669f9fc698476e5f5eb97d5b37999f/google_crc32c-1.7.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:e42e20a83a29aa2709a0cf271c7f8aefaa23b7ab52e53b322585297bb94d4638", size = 30309, upload-time = "2025-03-26T15:06:15.318Z" }, - { url = "https://files.pythonhosted.org/packages/92/83/9228fe65bf70e93e419f38bdf6c5ca5083fc6d32886ee79b450ceefd1dbd/google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:905a385140bf492ac300026717af339790921f411c0dfd9aa5a9e69a08ed32eb", size = 33133, upload-time = "2025-03-26T14:41:34.388Z" }, - { url = "https://files.pythonhosted.org/packages/c3/ca/1ea2fd13ff9f8955b85e7956872fdb7050c4ace8a2306a6d177edb9cf7fe/google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b211ddaf20f7ebeec5c333448582c224a7c90a9d98826fbab82c0ddc11348e6", size = 32773, upload-time = "2025-03-26T14:41:35.19Z" }, - { url = "https://files.pythonhosted.org/packages/89/32/a22a281806e3ef21b72db16f948cad22ec68e4bdd384139291e00ff82fe2/google_crc32c-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:0f99eaa09a9a7e642a61e06742856eec8b19fc0037832e03f941fe7cf0c8e4db", size = 33475, upload-time = "2025-03-26T14:29:11.771Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c5/002975aff514e57fc084ba155697a049b3f9b52225ec3bc0f542871dd524/google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32d1da0d74ec5634a05f53ef7df18fc646666a25efaaca9fc7dcfd4caf1d98c3", size = 33243, upload-time = "2025-03-26T14:41:35.975Z" }, - { url = "https://files.pythonhosted.org/packages/61/cb/c585282a03a0cea70fcaa1bf55d5d702d0f2351094d663ec3be1c6c67c52/google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e10554d4abc5238823112c2ad7e4560f96c7bf3820b202660373d769d9e6e4c9", size = 32870, upload-time = "2025-03-26T14:41:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/31e57ce04530794917dfe25243860ec141de9fadf4aa9783dffe7dac7c39/google_crc32c-1.7.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8e9afc74168b0b2232fb32dd202c93e46b7d5e4bf03e66ba5dc273bb3559589", size = 28242, upload-time = "2025-03-26T14:41:42.858Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f3/8b84cd4e0ad111e63e30eb89453f8dd308e3ad36f42305cf8c202461cdf0/google_crc32c-1.7.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa8136cc14dd27f34a3221c0f16fd42d8a40e4778273e61a3c19aedaa44daf6b", size = 28049, upload-time = "2025-03-26T14:41:44.651Z" }, - { url = "https://files.pythonhosted.org/packages/16/1b/1693372bf423ada422f80fd88260dbfd140754adb15cbc4d7e9a68b1cb8e/google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85fef7fae11494e747c9fd1359a527e5970fc9603c90764843caabd3a16a0a48", size = 28241, upload-time = "2025-03-26T14:41:45.898Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3c/2a19a60a473de48717b4efb19398c3f914795b64a96cf3fbe82588044f78/google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6efb97eb4369d52593ad6f75e7e10d053cf00c48983f7a973105bc70b0ac4d82", size = 28048, upload-time = "2025-03-26T14:41:46.696Z" }, -] [[package]] name = "google-genai" -version = "1.60.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1201,39 +923,21 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/3f/a753be0dcee352b7d63bc6d1ba14a72591d63b6391dac0cdff7ac168c530/google_genai-1.60.0.tar.gz", hash = "sha256:9768061775fddfaecfefb0d6d7a6cabefb3952ebd246cd5f65247151c07d33d1", size = 487721, upload-time = "2026-01-21T22:17:30.398Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/e5/384b1f383917b5f0ae92e28f47bc27b16e3d26cd9bacb25e9f8ecab3c8fe/google_genai-1.60.0-py3-none-any.whl", hash = "sha256:967338378ffecebec19a8ed90cf8797b26818bacbefd7846a9280beb1099f7f3", size = 719431, upload-time = "2026-01-21T22:17:28.086Z" }, -] - -[[package]] -name = "google-resumable-media" -version = "2.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-crc32c" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/5a/0efdc02665dca14e0837b62c8a1a93132c264bd02054a15abb2218afe0ae/google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0", size = 2163099, upload-time = "2024-08-07T22:20:38.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/52/0244e310812f3063d09d60b30ae29ab7df9343bd005744cd5eeaa6ba39b4/google_genai-2.8.0.tar.gz", hash = "sha256:37a9b3cb127d763e7f4ca47452ae3562c87728773bd1b149f7b559c239da2bc1", size = 564955, upload-time = "2026-06-03T22:55:38.397Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/35/b8d3baf8c46695858cb9d8835a53baa1eeb9906ddaf2f728a5f5b640fd1e/google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa", size = 81251, upload-time = "2024-08-07T22:20:36.409Z" }, + { url = "https://files.pythonhosted.org/packages/e2/de/747ad1aa49e902da9a4699081c282a1ed8ceed3b4d295fd99a6d286e09e4/google_genai-2.8.0-py3-none-any.whl", hash = "sha256:4da0a223a100f4b37f609a68b835e3326ab0fa313314dc0fd9d34e76ee293844", size = 832497, upload-time = "2026-06-03T22:55:36.598Z" }, ] [[package]] name = "googleapis-common-protos" -version = "1.70.0" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, -] - -[package.optional-dependencies] -grpc = [ - { name = "grpcio", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] [[package]] @@ -1245,225 +949,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, ] -[[package]] -name = "greenlet" -version = "3.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" }, - { url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" }, - { url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" }, - { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, - { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, - { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, - { url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, - { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, - { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, - { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, - { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, - { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, - { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, - { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, - { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, - { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, - { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, - { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, - { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, - { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, - { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, - { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, - { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, - { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, - { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, - { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, - { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, - { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, - { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, - { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, -] - -[[package]] -name = "grpc-google-iam-v1" -version = "0.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos", extra = ["grpc"] }, - { name = "grpcio", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/4e/8d0ca3b035e41fe0b3f31ebbb638356af720335e5a11154c330169b40777/grpc_google_iam_v1-0.14.2.tar.gz", hash = "sha256:b3e1fc387a1a329e41672197d0ace9de22c78dd7d215048c4c78712073f7bd20", size = 16259, upload-time = "2025-03-17T11:40:23.586Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/6f/dd9b178aee7835b96c2e63715aba6516a9d50f6bebbd1cc1d32c82a2a6c3/grpc_google_iam_v1-0.14.2-py3-none-any.whl", hash = "sha256:a3171468459770907926d56a440b2bb643eec1d7ba215f48f3ecece42b4d8351", size = 19242, upload-time = "2025-03-17T11:40:22.648Z" }, -] - -[[package]] -name = "grpc-interceptor" -version = "0.15.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "grpcio", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/28/57449d5567adf4c1d3e216aaca545913fbc21a915f2da6790d6734aac76e/grpc-interceptor-0.15.4.tar.gz", hash = "sha256:1f45c0bcb58b6f332f37c637632247c9b02bc6af0fdceb7ba7ce8d2ebbfb0926", size = 19322, upload-time = "2023-11-16T02:05:42.459Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/ac/8d53f230a7443401ce81791ec50a3b0e54924bf615ad287654fa4a2f5cdc/grpc_interceptor-0.15.4-py3-none-any.whl", hash = "sha256:0035f33228693ed3767ee49d937bac424318db173fef4d2d0170b3215f254d9d", size = 20848, upload-time = "2023-11-16T02:05:40.913Z" }, -] - -[[package]] -name = "grpcio" -version = "1.75.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", - "python_full_version < '3.11'", -] -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/88/fe2844eefd3d2188bc0d7a2768c6375b46dfd96469ea52d8aeee8587d7e0/grpcio-1.75.0.tar.gz", hash = "sha256:b989e8b09489478c2d19fecc744a298930f40d8b27c3638afbfe84d22f36ce4e", size = 12722485, upload-time = "2025-09-16T09:20:21.731Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/90/91f780f6cb8b2aa1bc8b8f8561a4e9d3bfe5dea10a4532843f2b044e18ac/grpcio-1.75.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:1ec9cbaec18d9597c718b1ed452e61748ac0b36ba350d558f9ded1a94cc15ec7", size = 5696373, upload-time = "2025-09-16T09:18:07.971Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c6/eaf9065ff15d0994e1674e71e1ca9542ee47f832b4df0fde1b35e5641fa1/grpcio-1.75.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7ee5ee42bfae8238b66a275f9ebcf6f295724375f2fa6f3b52188008b6380faf", size = 11465905, upload-time = "2025-09-16T09:18:12.383Z" }, - { url = "https://files.pythonhosted.org/packages/8a/21/ae33e514cb7c3f936b378d1c7aab6d8e986814b3489500c5cc860c48ce88/grpcio-1.75.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9146e40378f551eed66c887332afc807fcce593c43c698e21266a4227d4e20d2", size = 6282149, upload-time = "2025-09-16T09:18:15.427Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/dff6344e6f3e81707bc87bba796592036606aca04b6e9b79ceec51902b80/grpcio-1.75.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0c40f368541945bb664857ecd7400acb901053a1abbcf9f7896361b2cfa66798", size = 6940277, upload-time = "2025-09-16T09:18:17.564Z" }, - { url = "https://files.pythonhosted.org/packages/9a/5f/e52cb2c16e097d950c36e7bb2ef46a3b2e4c7ae6b37acb57d88538182b85/grpcio-1.75.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50a6e43a9adc6938e2a16c9d9f8a2da9dd557ddd9284b73b07bd03d0e098d1e9", size = 6460422, upload-time = "2025-09-16T09:18:19.657Z" }, - { url = "https://files.pythonhosted.org/packages/fd/16/527533f0bd9cace7cd800b7dae903e273cc987fc472a398a4bb6747fec9b/grpcio-1.75.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dce15597ca11913b78e1203c042d5723e3ea7f59e7095a1abd0621be0e05b895", size = 7089969, upload-time = "2025-09-16T09:18:21.73Z" }, - { url = "https://files.pythonhosted.org/packages/88/4f/1d448820bc88a2be7045aac817a59ba06870e1ebad7ed19525af7ac079e7/grpcio-1.75.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:851194eec47755101962da423f575ea223c9dd7f487828fe5693920e8745227e", size = 8033548, upload-time = "2025-09-16T09:18:23.819Z" }, - { url = "https://files.pythonhosted.org/packages/37/00/19e87ab12c8b0d73a252eef48664030de198514a4e30bdf337fa58bcd4dd/grpcio-1.75.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ca123db0813eef80625a4242a0c37563cb30a3edddebe5ee65373854cf187215", size = 7487161, upload-time = "2025-09-16T09:18:25.934Z" }, - { url = "https://files.pythonhosted.org/packages/37/d0/f7b9deaa6ccca9997fa70b4e143cf976eaec9476ecf4d05f7440ac400635/grpcio-1.75.0-cp310-cp310-win32.whl", hash = "sha256:222b0851e20c04900c63f60153503e918b08a5a0fad8198401c0b1be13c6815b", size = 3946254, upload-time = "2025-09-16T09:18:28.42Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/8d04744c7dc720cc9805a27f879cbf7043bb5c78dce972f6afb8613860de/grpcio-1.75.0-cp310-cp310-win_amd64.whl", hash = "sha256:bb58e38a50baed9b21492c4b3f3263462e4e37270b7ea152fc10124b4bd1c318", size = 4640072, upload-time = "2025-09-16T09:18:30.426Z" }, - { url = "https://files.pythonhosted.org/packages/95/b7/a6f42596fc367656970f5811e5d2d9912ca937aa90621d5468a11680ef47/grpcio-1.75.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:7f89d6d0cd43170a80ebb4605cad54c7d462d21dc054f47688912e8bf08164af", size = 5699769, upload-time = "2025-09-16T09:18:32.536Z" }, - { url = "https://files.pythonhosted.org/packages/c2/42/284c463a311cd2c5f804fd4fdbd418805460bd5d702359148dd062c1685d/grpcio-1.75.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cb6c5b075c2d092f81138646a755f0dad94e4622300ebef089f94e6308155d82", size = 11480362, upload-time = "2025-09-16T09:18:35.562Z" }, - { url = "https://files.pythonhosted.org/packages/0b/10/60d54d5a03062c3ae91bddb6e3acefe71264307a419885f453526d9203ff/grpcio-1.75.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:494dcbade5606128cb9f530ce00331a90ecf5e7c5b243d373aebdb18e503c346", size = 6284753, upload-time = "2025-09-16T09:18:38.055Z" }, - { url = "https://files.pythonhosted.org/packages/cf/af/381a4bfb04de5e2527819452583e694df075c7a931e9bf1b2a603b593ab2/grpcio-1.75.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:050760fd29c8508844a720f06c5827bb00de8f5e02f58587eb21a4444ad706e5", size = 6944103, upload-time = "2025-09-16T09:18:40.844Z" }, - { url = "https://files.pythonhosted.org/packages/16/18/c80dd7e1828bd6700ce242c1616871927eef933ed0c2cee5c636a880e47b/grpcio-1.75.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:266fa6209b68a537b2728bb2552f970e7e78c77fe43c6e9cbbe1f476e9e5c35f", size = 6464036, upload-time = "2025-09-16T09:18:43.351Z" }, - { url = "https://files.pythonhosted.org/packages/79/3f/78520c7ed9ccea16d402530bc87958bbeb48c42a2ec8032738a7864d38f8/grpcio-1.75.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:06d22e1d8645e37bc110f4c589cb22c283fd3de76523065f821d6e81de33f5d4", size = 7097455, upload-time = "2025-09-16T09:18:45.465Z" }, - { url = "https://files.pythonhosted.org/packages/ad/69/3cebe4901a865eb07aefc3ee03a02a632e152e9198dadf482a7faf926f31/grpcio-1.75.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9880c323595d851292785966cadb6c708100b34b163cab114e3933f5773cba2d", size = 8037203, upload-time = "2025-09-16T09:18:47.878Z" }, - { url = "https://files.pythonhosted.org/packages/04/ed/1e483d1eba5032642c10caf28acf07ca8de0508244648947764956db346a/grpcio-1.75.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:55a2d5ae79cd0f68783fb6ec95509be23746e3c239290b2ee69c69a38daa961a", size = 7492085, upload-time = "2025-09-16T09:18:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/6ef676aa7dbd9578dfca990bb44d41a49a1e36344ca7d79de6b59733ba96/grpcio-1.75.0-cp311-cp311-win32.whl", hash = "sha256:352dbdf25495eef584c8de809db280582093bc3961d95a9d78f0dfb7274023a2", size = 3944697, upload-time = "2025-09-16T09:18:53.427Z" }, - { url = "https://files.pythonhosted.org/packages/0d/83/b753373098b81ec5cb01f71c21dfd7aafb5eb48a1566d503e9fd3c1254fe/grpcio-1.75.0-cp311-cp311-win_amd64.whl", hash = "sha256:678b649171f229fb16bda1a2473e820330aa3002500c4f9fd3a74b786578e90f", size = 4642235, upload-time = "2025-09-16T09:18:56.095Z" }, - { url = "https://files.pythonhosted.org/packages/0d/93/a1b29c2452d15cecc4a39700fbf54721a3341f2ddbd1bd883f8ec0004e6e/grpcio-1.75.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:fa35ccd9501ffdd82b861809cbfc4b5b13f4b4c5dc3434d2d9170b9ed38a9054", size = 5661861, upload-time = "2025-09-16T09:18:58.748Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ce/7280df197e602d14594e61d1e60e89dfa734bb59a884ba86cdd39686aadb/grpcio-1.75.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:0fcb77f2d718c1e58cc04ef6d3b51e0fa3b26cf926446e86c7eba105727b6cd4", size = 11459982, upload-time = "2025-09-16T09:19:01.211Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9b/37e61349771f89b543a0a0bbc960741115ea8656a2414bfb24c4de6f3dd7/grpcio-1.75.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36764a4ad9dc1eb891042fab51e8cdf7cc014ad82cee807c10796fb708455041", size = 6239680, upload-time = "2025-09-16T09:19:04.443Z" }, - { url = "https://files.pythonhosted.org/packages/a6/66/f645d9d5b22ca307f76e71abc83ab0e574b5dfef3ebde4ec8b865dd7e93e/grpcio-1.75.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:725e67c010f63ef17fc052b261004942763c0b18dcd84841e6578ddacf1f9d10", size = 6908511, upload-time = "2025-09-16T09:19:07.884Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9a/34b11cd62d03c01b99068e257595804c695c3c119596c7077f4923295e19/grpcio-1.75.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91fbfc43f605c5ee015c9056d580a70dd35df78a7bad97e05426795ceacdb59f", size = 6429105, upload-time = "2025-09-16T09:19:10.085Z" }, - { url = "https://files.pythonhosted.org/packages/1a/46/76eaceaad1f42c1e7e6a5b49a61aac40fc5c9bee4b14a1630f056ac3a57e/grpcio-1.75.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a9337ac4ce61c388e02019d27fa837496c4b7837cbbcec71b05934337e51531", size = 7060578, upload-time = "2025-09-16T09:19:12.283Z" }, - { url = "https://files.pythonhosted.org/packages/3d/82/181a0e3f1397b6d43239e95becbeb448563f236c0db11ce990f073b08d01/grpcio-1.75.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ee16e232e3d0974750ab5f4da0ab92b59d6473872690b5e40dcec9a22927f22e", size = 8003283, upload-time = "2025-09-16T09:19:15.601Z" }, - { url = "https://files.pythonhosted.org/packages/de/09/a335bca211f37a3239be4b485e3c12bf3da68d18b1f723affdff2b9e9680/grpcio-1.75.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55dfb9122973cc69520b23d39867726722cafb32e541435707dc10249a1bdbc6", size = 7460319, upload-time = "2025-09-16T09:19:18.409Z" }, - { url = "https://files.pythonhosted.org/packages/aa/59/6330105cdd6bc4405e74c96838cd7e148c3653ae3996e540be6118220c79/grpcio-1.75.0-cp312-cp312-win32.whl", hash = "sha256:fb64dd62face3d687a7b56cd881e2ea39417af80f75e8b36f0f81dfd93071651", size = 3934011, upload-time = "2025-09-16T09:19:21.013Z" }, - { url = "https://files.pythonhosted.org/packages/ff/14/e1309a570b7ebdd1c8ca24c4df6b8d6690009fa8e0d997cb2c026ce850c9/grpcio-1.75.0-cp312-cp312-win_amd64.whl", hash = "sha256:6b365f37a9c9543a9e91c6b4103d68d38d5bcb9965b11d5092b3c157bd6a5ee7", size = 4637934, upload-time = "2025-09-16T09:19:23.19Z" }, - { url = "https://files.pythonhosted.org/packages/00/64/dbce0ffb6edaca2b292d90999dd32a3bd6bc24b5b77618ca28440525634d/grpcio-1.75.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:1bb78d052948d8272c820bb928753f16a614bb2c42fbf56ad56636991b427518", size = 5666860, upload-time = "2025-09-16T09:19:25.417Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e6/da02c8fa882ad3a7f868d380bb3da2c24d35dd983dd12afdc6975907a352/grpcio-1.75.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:9dc4a02796394dd04de0b9673cb79a78901b90bb16bf99ed8cb528c61ed9372e", size = 11455148, upload-time = "2025-09-16T09:19:28.615Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a0/84f87f6c2cf2a533cfce43b2b620eb53a51428ec0c8fe63e5dd21d167a70/grpcio-1.75.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:437eeb16091d31498585d73b133b825dc80a8db43311e332c08facf820d36894", size = 6243865, upload-time = "2025-09-16T09:19:31.342Z" }, - { url = "https://files.pythonhosted.org/packages/be/12/53da07aa701a4839dd70d16e61ce21ecfcc9e929058acb2f56e9b2dd8165/grpcio-1.75.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c2c39984e846bd5da45c5f7bcea8fafbe47c98e1ff2b6f40e57921b0c23a52d0", size = 6915102, upload-time = "2025-09-16T09:19:33.658Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c0/7eaceafd31f52ec4bf128bbcf36993b4bc71f64480f3687992ddd1a6e315/grpcio-1.75.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38d665f44b980acdbb2f0e1abf67605ba1899f4d2443908df9ec8a6f26d2ed88", size = 6432042, upload-time = "2025-09-16T09:19:36.583Z" }, - { url = "https://files.pythonhosted.org/packages/6b/12/a2ce89a9f4fc52a16ed92951f1b05f53c17c4028b3db6a4db7f08332bee8/grpcio-1.75.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e8e752ab5cc0a9c5b949808c000ca7586223be4f877b729f034b912364c3964", size = 7062984, upload-time = "2025-09-16T09:19:39.163Z" }, - { url = "https://files.pythonhosted.org/packages/55/a6/2642a9b491e24482d5685c0f45c658c495a5499b43394846677abed2c966/grpcio-1.75.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3a6788b30aa8e6f207c417874effe3f79c2aa154e91e78e477c4825e8b431ce0", size = 8001212, upload-time = "2025-09-16T09:19:41.726Z" }, - { url = "https://files.pythonhosted.org/packages/19/20/530d4428750e9ed6ad4254f652b869a20a40a276c1f6817b8c12d561f5ef/grpcio-1.75.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc33e67cab6141c54e75d85acd5dec616c5095a957ff997b4330a6395aa9b51", size = 7457207, upload-time = "2025-09-16T09:19:44.368Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6f/843670007e0790af332a21468d10059ea9fdf97557485ae633b88bd70efc/grpcio-1.75.0-cp313-cp313-win32.whl", hash = "sha256:c8cfc780b7a15e06253aae5f228e1e84c0d3c4daa90faf5bc26b751174da4bf9", size = 3934235, upload-time = "2025-09-16T09:19:46.815Z" }, - { url = "https://files.pythonhosted.org/packages/4b/92/c846b01b38fdf9e2646a682b12e30a70dc7c87dfe68bd5e009ee1501c14b/grpcio-1.75.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c91d5b16eff3cbbe76b7a1eaaf3d91e7a954501e9d4f915554f87c470475c3d", size = 4637558, upload-time = "2025-09-16T09:19:49.698Z" }, -] - -[[package]] -name = "grpcio" -version = "1.76.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", -] -dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/17/ff4795dc9a34b6aee6ec379f1b66438a3789cd1315aac0cbab60d92f74b3/grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc", size = 5840037, upload-time = "2025-10-21T16:20:25.069Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ff/35f9b96e3fa2f12e1dcd58a4513a2e2294a001d64dec81677361b7040c9a/grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde", size = 11836482, upload-time = "2025-10-21T16:20:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1c/8374990f9545e99462caacea5413ed783014b3b66ace49e35c533f07507b/grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3", size = 6407178, upload-time = "2025-10-21T16:20:32.733Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/36fd7d7c75a6c12542c90a6d647a27935a1ecaad03e0ffdb7c42db6b04d2/grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990", size = 7075684, upload-time = "2025-10-21T16:20:35.435Z" }, - { url = "https://files.pythonhosted.org/packages/38/f7/e3cdb252492278e004722306c5a8935eae91e64ea11f0af3437a7de2e2b7/grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af", size = 6611133, upload-time = "2025-10-21T16:20:37.541Z" }, - { url = "https://files.pythonhosted.org/packages/7e/20/340db7af162ccd20a0893b5f3c4a5d676af7b71105517e62279b5b61d95a/grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2", size = 7195507, upload-time = "2025-10-21T16:20:39.643Z" }, - { url = "https://files.pythonhosted.org/packages/10/f0/b2160addc1487bd8fa4810857a27132fb4ce35c1b330c2f3ac45d697b106/grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6", size = 8160651, upload-time = "2025-10-21T16:20:42.492Z" }, - { url = "https://files.pythonhosted.org/packages/2c/2c/ac6f98aa113c6ef111b3f347854e99ebb7fb9d8f7bb3af1491d438f62af4/grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3", size = 7620568, upload-time = "2025-10-21T16:20:45.995Z" }, - { url = "https://files.pythonhosted.org/packages/90/84/7852f7e087285e3ac17a2703bc4129fafee52d77c6c82af97d905566857e/grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b", size = 3998879, upload-time = "2025-10-21T16:20:48.592Z" }, - { url = "https://files.pythonhosted.org/packages/10/30/d3d2adcbb6dd3ff59d6ac3df6ef830e02b437fb5c90990429fd180e52f30/grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b", size = 4706892, upload-time = "2025-10-21T16:20:50.697Z" }, - { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, - { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, - { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, - { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, - { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, - { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, - { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, -] - -[[package]] -name = "grpcio-status" -version = "1.75.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/8a/2e45ec0512d4ce9afa136c6e4186d063721b5b4c192eec7536ce6b7ba615/grpcio_status-1.75.0.tar.gz", hash = "sha256:69d5b91be1b8b926f086c1c483519a968c14640773a0ccdd6c04282515dbedf7", size = 13646, upload-time = "2025-09-16T09:24:51.069Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/24/d536f0a0fda3a3eeb334893e5fb9d567c2777de6a5384413f71b35cfd0e5/grpcio_status-1.75.0-py3-none-any.whl", hash = "sha256:de62557ef97b7e19c3ce6da19793a12c5f6c1fbbb918d233d9671aba9d9e1d78", size = 14424, upload-time = "2025-09-16T09:23:33.843Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -1486,52 +971,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] -[[package]] -name = "httplib2" -version = "0.31.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/77/6653db69c1f7ecfe5e3f9726fdadc981794656fcd7d98c4209fecfea9993/httplib2-0.31.0.tar.gz", hash = "sha256:ac7ab497c50975147d4f7b1ade44becc7df2f8954d42b38b3d69c515f531135c", size = 250759, upload-time = "2025-09-11T12:16:03.403Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/a2/0d269db0f6163be503775dc8b6a6fa15820cc9fdc866f6ba608d86b721f2/httplib2-0.31.0-py3-none-any.whl", hash = "sha256:b9cd78abea9b4e43a7714c6e0f8b6b8561a6fc1e95d5dbd367f5bf0ef35f5d24", size = 91148, upload-time = "2025-09-11T12:16:01.803Z" }, -] - [[package]] name = "httptools" -version = "0.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780, upload-time = "2024-10-16T19:44:06.882Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da", size = 103297, upload-time = "2024-10-16T19:44:08.129Z" }, - { url = "https://files.pythonhosted.org/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1", size = 443130, upload-time = "2024-10-16T19:44:09.45Z" }, - { url = "https://files.pythonhosted.org/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50", size = 442148, upload-time = "2024-10-16T19:44:11.539Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959", size = 415949, upload-time = "2024-10-16T19:44:13.388Z" }, - { url = "https://files.pythonhosted.org/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4", size = 417591, upload-time = "2024-10-16T19:44:15.258Z" }, - { url = "https://files.pythonhosted.org/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c", size = 88344, upload-time = "2024-10-16T19:44:16.54Z" }, - { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029, upload-time = "2024-10-16T19:44:18.427Z" }, - { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492, upload-time = "2024-10-16T19:44:19.515Z" }, - { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891, upload-time = "2024-10-16T19:44:21.067Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788, upload-time = "2024-10-16T19:44:22.958Z" }, - { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214, upload-time = "2024-10-16T19:44:24.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120, upload-time = "2024-10-16T19:44:26.295Z" }, - { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565, upload-time = "2024-10-16T19:44:29.188Z" }, - { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" }, - { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" }, - { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" }, - { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" }, - { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, - { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, - { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, - { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] [[package]] @@ -1551,47 +1038,69 @@ wheels = [ [[package]] name = "httpx-sse" -version = "0.4.1" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "joserfc" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/90/25cb27518750218e4f850be63d8bbb2343efaad1c01c3571aaa4b3c33bd7/joserfc-1.7.1.tar.gz", hash = "sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81", size = 233181, upload-time = "2026-06-08T07:21:33.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/00/fa62404c3e347f946faa13aa21085205f9cc06ad17671e37f81a51662ae8/joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164", size = 70423, upload-time = "2026-06-08T07:21:32.001Z" }, +] + +[[package]] +name = "json-rpc" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" }, ] [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, - { name = "rpds-py" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] @@ -1606,453 +1115,331 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] -[[package]] -name = "mako" -version = "1.3.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, -] - -[[package]] -name = "mcp" -version = "1.25.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, -] - [[package]] name = "multidict" -version = "6.6.4" +version = "6.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6b/86f353088c1358e76fd30b0146947fddecee812703b604ee901e85cd2a80/multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f", size = 77054, upload-time = "2025-08-11T12:06:02.99Z" }, - { url = "https://files.pythonhosted.org/packages/19/5d/c01dc3d3788bb877bd7f5753ea6eb23c1beeca8044902a8f5bfb54430f63/multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb", size = 44914, upload-time = "2025-08-11T12:06:05.264Z" }, - { url = "https://files.pythonhosted.org/packages/46/44/964dae19ea42f7d3e166474d8205f14bb811020e28bc423d46123ddda763/multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495", size = 44601, upload-time = "2025-08-11T12:06:06.627Z" }, - { url = "https://files.pythonhosted.org/packages/31/20/0616348a1dfb36cb2ab33fc9521de1f27235a397bf3f59338e583afadd17/multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8", size = 224821, upload-time = "2025-08-11T12:06:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/14/26/5d8923c69c110ff51861af05bd27ca6783011b96725d59ccae6d9daeb627/multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7", size = 242608, upload-time = "2025-08-11T12:06:09.697Z" }, - { url = "https://files.pythonhosted.org/packages/5c/cc/e2ad3ba9459aa34fa65cf1f82a5c4a820a2ce615aacfb5143b8817f76504/multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796", size = 222324, upload-time = "2025-08-11T12:06:10.905Z" }, - { url = "https://files.pythonhosted.org/packages/19/db/4ed0f65701afbc2cb0c140d2d02928bb0fe38dd044af76e58ad7c54fd21f/multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db", size = 253234, upload-time = "2025-08-11T12:06:12.658Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5160c9813269e39ae14b73debb907bfaaa1beee1762da8c4fb95df4764ed/multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0", size = 251613, upload-time = "2025-08-11T12:06:13.97Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/48d1bd111fc2f8fb98b2ed7f9a115c55a9355358432a19f53c0b74d8425d/multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877", size = 241649, upload-time = "2025-08-11T12:06:15.204Z" }, - { url = "https://files.pythonhosted.org/packages/85/2a/f7d743df0019408768af8a70d2037546a2be7b81fbb65f040d76caafd4c5/multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace", size = 239238, upload-time = "2025-08-11T12:06:16.467Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b8/4f4bb13323c2d647323f7919201493cf48ebe7ded971717bfb0f1a79b6bf/multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6", size = 233517, upload-time = "2025-08-11T12:06:18.107Z" }, - { url = "https://files.pythonhosted.org/packages/33/29/4293c26029ebfbba4f574febd2ed01b6f619cfa0d2e344217d53eef34192/multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb", size = 243122, upload-time = "2025-08-11T12:06:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/20/60/a1c53628168aa22447bfde3a8730096ac28086704a0d8c590f3b63388d0c/multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb", size = 248992, upload-time = "2025-08-11T12:06:20.661Z" }, - { url = "https://files.pythonhosted.org/packages/a3/3b/55443a0c372f33cae5d9ec37a6a973802884fa0ab3586659b197cf8cc5e9/multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987", size = 243708, upload-time = "2025-08-11T12:06:21.891Z" }, - { url = "https://files.pythonhosted.org/packages/7c/60/a18c6900086769312560b2626b18e8cca22d9e85b1186ba77f4755b11266/multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f", size = 237498, upload-time = "2025-08-11T12:06:23.206Z" }, - { url = "https://files.pythonhosted.org/packages/11/3d/8bdd8bcaff2951ce2affccca107a404925a2beafedd5aef0b5e4a71120a6/multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f", size = 41415, upload-time = "2025-08-11T12:06:24.77Z" }, - { url = "https://files.pythonhosted.org/packages/c0/53/cab1ad80356a4cd1b685a254b680167059b433b573e53872fab245e9fc95/multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0", size = 46046, upload-time = "2025-08-11T12:06:25.893Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9a/874212b6f5c1c2d870d0a7adc5bb4cfe9b0624fa15cdf5cf757c0f5087ae/multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729", size = 43147, upload-time = "2025-08-11T12:06:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472, upload-time = "2025-08-11T12:06:29.006Z" }, - { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634, upload-time = "2025-08-11T12:06:30.374Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282, upload-time = "2025-08-11T12:06:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/03/35/436a5da8702b06866189b69f655ffdb8f70796252a8772a77815f1812679/multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded", size = 229696, upload-time = "2025-08-11T12:06:33.087Z" }, - { url = "https://files.pythonhosted.org/packages/b6/0e/915160be8fecf1fca35f790c08fb74ca684d752fcba62c11daaf3d92c216/multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683", size = 246665, upload-time = "2025-08-11T12:06:34.448Z" }, - { url = "https://files.pythonhosted.org/packages/08/ee/2f464330acd83f77dcc346f0b1a0eaae10230291450887f96b204b8ac4d3/multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a", size = 225485, upload-time = "2025-08-11T12:06:35.672Z" }, - { url = "https://files.pythonhosted.org/packages/71/cc/9a117f828b4d7fbaec6adeed2204f211e9caf0a012692a1ee32169f846ae/multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9", size = 257318, upload-time = "2025-08-11T12:06:36.98Z" }, - { url = "https://files.pythonhosted.org/packages/25/77/62752d3dbd70e27fdd68e86626c1ae6bccfebe2bb1f84ae226363e112f5a/multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50", size = 254689, upload-time = "2025-08-11T12:06:38.233Z" }, - { url = "https://files.pythonhosted.org/packages/00/6e/fac58b1072a6fc59af5e7acb245e8754d3e1f97f4f808a6559951f72a0d4/multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52", size = 246709, upload-time = "2025-08-11T12:06:39.517Z" }, - { url = "https://files.pythonhosted.org/packages/01/ef/4698d6842ef5e797c6db7744b0081e36fb5de3d00002cc4c58071097fac3/multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6", size = 243185, upload-time = "2025-08-11T12:06:40.796Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c9/d82e95ae1d6e4ef396934e9b0e942dfc428775f9554acf04393cce66b157/multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e", size = 237838, upload-time = "2025-08-11T12:06:42.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/cf/f94af5c36baaa75d44fab9f02e2a6bcfa0cd90acb44d4976a80960759dbc/multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3", size = 246368, upload-time = "2025-08-11T12:06:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/4a/fe/29f23460c3d995f6a4b678cb2e9730e7277231b981f0b234702f0177818a/multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c", size = 253339, upload-time = "2025-08-11T12:06:45.597Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/fd59449204426187b82bf8a75f629310f68c6adc9559dc922d5abe34797b/multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b", size = 246933, upload-time = "2025-08-11T12:06:46.841Z" }, - { url = "https://files.pythonhosted.org/packages/19/52/d5d6b344f176a5ac3606f7a61fb44dc746e04550e1a13834dff722b8d7d6/multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f", size = 242225, upload-time = "2025-08-11T12:06:48.588Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d3/5b2281ed89ff4d5318d82478a2a2450fcdfc3300da48ff15c1778280ad26/multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2", size = 41306, upload-time = "2025-08-11T12:06:49.95Z" }, - { url = "https://files.pythonhosted.org/packages/74/7d/36b045c23a1ab98507aefd44fd8b264ee1dd5e5010543c6fccf82141ccef/multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e", size = 46029, upload-time = "2025-08-11T12:06:51.082Z" }, - { url = "https://files.pythonhosted.org/packages/0f/5e/553d67d24432c5cd52b49047f2d248821843743ee6d29a704594f656d182/multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf", size = 43017, upload-time = "2025-08-11T12:06:52.243Z" }, - { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, - { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, - { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, - { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, - { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, - { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, - { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, - { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, - { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, - { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, - { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, - { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, - { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, - { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, - { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, - { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, - { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, - { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, - { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, - { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, - { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, - { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, - { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, - { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, - { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, - { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, - { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] name = "opentelemetry-api" -version = "1.37.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/04/05040d7ce33a907a2a02257e601992f0cdf11c73b33f13c4492bf6c3d6d5/opentelemetry_api-1.37.0.tar.gz", hash = "sha256:540735b120355bd5112738ea53621f8d5edb35ebcd6fe21ada3ab1c61d1cd9a7", size = 64923, upload-time = "2025-09-11T10:29:01.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/48/28ed9e55dcf2f453128df738210a980e09f4e468a456fa3c763dbc8be70a/opentelemetry_api-1.37.0-py3-none-any.whl", hash = "sha256:accf2024d3e89faec14302213bc39550ec0f4095d1cf5ca688e1bfb1c8612f47", size = 65732, upload-time = "2025-09-11T10:28:41.826Z" }, -] - -[[package]] -name = "opentelemetry-exporter-gcp-logging" -version = "1.11.0a0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-cloud-logging" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/2d/6aa7063b009768d8f9415b36a29ae9b3eb1e2c5eff70f58ca15e104c245f/opentelemetry_exporter_gcp_logging-1.11.0a0.tar.gz", hash = "sha256:58496f11b930c84570060ffbd4343cd0b597ea13c7bc5c879df01163dd552f14", size = 22400, upload-time = "2025-11-04T19:32:13.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/b7/2d3df53fa39bfd52f88c78a60367d45a7b1adbf8a756cce62d6ac149d49a/opentelemetry_exporter_gcp_logging-1.11.0a0-py3-none-any.whl", hash = "sha256:f8357c552947cb9c0101c4575a7702b8d3268e28bdeefdd1405cf838e128c6ef", size = 14168, upload-time = "2025-11-04T19:32:07.073Z" }, -] - -[[package]] -name = "opentelemetry-exporter-gcp-monitoring" -version = "1.11.0a0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-cloud-monitoring" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/48/d1c7d2380bb1754d1eb6a011a2e0de08c6868cb6c0f34bcda0444fa0d614/opentelemetry_exporter_gcp_monitoring-1.11.0a0.tar.gz", hash = "sha256:386276eddbbd978a6f30fafd3397975beeb02a1302bdad554185242a8e2c343c", size = 20828, upload-time = "2025-11-04T19:32:14.522Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/8c/03a6e73e270a9c890dbd6cc1c47c83d86b8a8a974a9168d92e043c6277cc/opentelemetry_exporter_gcp_monitoring-1.11.0a0-py3-none-any.whl", hash = "sha256:b6740cba61b2f9555274829fe87a58447b64d0378f1067a4faebb4f5b364ca22", size = 13611, upload-time = "2025-11-04T19:32:08.212Z" }, -] - -[[package]] -name = "opentelemetry-exporter-gcp-trace" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-cloud-trace" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/15/7556d54b01fb894497f69a98d57faa9caa45ffa59896e0bba6847a7f0d15/opentelemetry_exporter_gcp_trace-1.9.0.tar.gz", hash = "sha256:c3fc090342f6ee32a0cc41a5716a6bb716b4422d19facefcb22dc4c6b683ece8", size = 18568, upload-time = "2025-02-04T19:45:08.185Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/cd/6d7fbad05771eb3c2bace20f6360ce5dac5ca751c6f2122853e43830c32e/opentelemetry_exporter_gcp_trace-1.9.0-py3-none-any.whl", hash = "sha256:0a8396e8b39f636eeddc3f0ae08ddb40c40f288bc8c5544727c3581545e77254", size = 13973, upload-time = "2025-02-04T19:44:59.148Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-proto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/6c/10018cbcc1e6fff23aac67d7fd977c3d692dbe5f9ef9bb4db5c1268726cc/opentelemetry_exporter_otlp_proto_common-1.37.0.tar.gz", hash = "sha256:c87a1bdd9f41fdc408d9cc9367bb53f8d2602829659f2b90be9f9d79d0bfe62c", size = 20430, upload-time = "2025-09-11T10:29:03.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/13/b4ef09837409a777f3c0af2a5b4ba9b7af34872bc43609dda0c209e4060d/opentelemetry_exporter_otlp_proto_common-1.37.0-py3-none-any.whl", hash = "sha256:53038428449c559b0c564b8d718df3314da387109c4d36bd1b94c9a641b0292e", size = 18359, upload-time = "2025-09-11T10:28:44.939Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5d/e3/6e320aeb24f951449e73867e53c55542bebbaf24faeee7623ef677d66736/opentelemetry_exporter_otlp_proto_http-1.37.0.tar.gz", hash = "sha256:e52e8600f1720d6de298419a802108a8f5afa63c96809ff83becb03f874e44ac", size = 17281, upload-time = "2025-09-11T10:29:04.844Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/e9/70d74a664d83976556cec395d6bfedd9b85ec1498b778367d5f93e373397/opentelemetry_exporter_otlp_proto_http-1.37.0-py3-none-any.whl", hash = "sha256:54c42b39945a6cc9d9a2a33decb876eabb9547e0dcb49df090122773447f1aef", size = 19576, upload-time = "2025-09-11T10:28:46.726Z" }, -] - -[[package]] -name = "opentelemetry-proto" -version = "1.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dd/ea/a75f36b463a36f3c5a10c0b5292c58b31dbdde74f6f905d3d0ab2313987b/opentelemetry_proto-1.37.0.tar.gz", hash = "sha256:30f5c494faf66f77faeaefa35ed4443c5edb3b0aa46dad073ed7210e1a789538", size = 46151, upload-time = "2025-09-11T10:29:11.04Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/25/f89ea66c59bd7687e218361826c969443c4fa15dfe89733f3bf1e2a9e971/opentelemetry_proto-1.37.0-py3-none-any.whl", hash = "sha256:8ed8c066ae8828bbf0c39229979bdf583a126981142378a9cbe9d6fd5701c6e2", size = 72534, upload-time = "2025-09-11T10:28:56.831Z" }, -] - -[[package]] -name = "opentelemetry-resourcedetector-gcp" -version = "1.9.0a0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/86/f0693998817779802525a5bcc885a3cdb68d05b636bc6faae5c9ade4bee4/opentelemetry_resourcedetector_gcp-1.9.0a0.tar.gz", hash = "sha256:6860a6649d1e3b9b7b7f09f3918cc16b72aa0c0c590d2a72ea6e42b67c9a42e7", size = 20730, upload-time = "2025-02-04T19:45:10.693Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/04/7e33228c88422a5518e1774a836c9ec68f10f51bde0f1d5dd5f3054e612a/opentelemetry_resourcedetector_gcp-1.9.0a0-py3-none-any.whl", hash = "sha256:4e5a0822b0f0d7647b7ceb282d7aa921dd7f45466540bd0a24f954f90db8fde8", size = 20378, upload-time = "2025-02-04T19:45:03.898Z" }, + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.37.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/62/2e0ca80d7fe94f0b193135375da92c640d15fe81f636658d2acf373086bc/opentelemetry_sdk-1.37.0.tar.gz", hash = "sha256:cc8e089c10953ded765b5ab5669b198bbe0af1b3f89f1007d19acd32dc46dda5", size = 170404, upload-time = "2025-09-11T10:29:11.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/62/9f4ad6a54126fb00f7ed4bb5034964c6e4f00fcd5a905e115bd22707e20d/opentelemetry_sdk-1.37.0-py3-none-any.whl", hash = "sha256:8f3c3c22063e52475c5dbced7209495c2c16723d016d39287dfc215d1771257c", size = 131941, upload-time = "2025-09-11T10:28:57.83Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.58b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/1b/90701d91e6300d9f2fb352153fb1721ed99ed1f6ea14fa992c756016e63a/opentelemetry_semantic_conventions-0.58b0.tar.gz", hash = "sha256:6bd46f51264279c433755767bb44ad00f1c9e2367e1b42af563372c5a6fa0c25", size = 129867, upload-time = "2025-09-11T10:29:12.597Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/90/68152b7465f50285d3ce2481b3aec2f82822e3f52e5152eeeaf516bab841/opentelemetry_semantic_conventions-0.58b0-py3-none-any.whl", hash = "sha256:5564905ab1458b96684db1340232729fce3b5375a06e140e8904c78e4f815b28", size = 207954, upload-time = "2025-09-11T10:28:59.218Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, ] [[package]] name = "packaging" -version = "25.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "propcache" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, - { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, - { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, - { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, - { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, - { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, - { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, - { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, - { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, - { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, - { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, - { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, - { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, - { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, - { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, - { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, - { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, - { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, - { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, - { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, - { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, - { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, - { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, - { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, - { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, - { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, - { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] name = "proto-plus" -version = "1.26.1" +version = "1.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" }, + { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, ] [[package]] @@ -2070,63 +1457,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] -[[package]] -name = "pyarrow" -version = "23.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/33/ffd9c3eb087fa41dd79c3cf20c4c0ae3cdb877c4f8e1107a446006344924/pyarrow-23.0.0.tar.gz", hash = "sha256:180e3150e7edfcd182d3d9afba72f7cf19839a497cc76555a8dce998a8f67615", size = 1167185, upload-time = "2026-01-18T16:19:42.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/2f/23e042a5aa99bcb15e794e14030e8d065e00827e846e53a66faec73c7cd6/pyarrow-23.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:cbdc2bf5947aa4d462adcf8453cf04aee2f7932653cb67a27acd96e5e8528a67", size = 34281861, upload-time = "2026-01-18T16:13:34.332Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/1651933f504b335ec9cd8f99463718421eb08d883ed84f0abd2835a16cad/pyarrow-23.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:4d38c836930ce15cd31dce20114b21ba082da231c884bdc0a7b53e1477fe7f07", size = 35825067, upload-time = "2026-01-18T16:13:42.549Z" }, - { url = "https://files.pythonhosted.org/packages/84/ec/d6fceaec050c893f4e35c0556b77d4cc9973fcc24b0a358a5781b1234582/pyarrow-23.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:4222ff8f76919ecf6c716175a0e5fddb5599faeed4c56d9ea41a2c42be4998b2", size = 44458539, upload-time = "2026-01-18T16:13:52.975Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d9/369f134d652b21db62fe3ec1c5c2357e695f79eb67394b8a93f3a2b2cffa/pyarrow-23.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:87f06159cbe38125852657716889296c83c37b4d09a5e58f3d10245fd1f69795", size = 47535889, upload-time = "2026-01-18T16:14:03.693Z" }, - { url = "https://files.pythonhosted.org/packages/a3/95/f37b6a252fdbf247a67a78fb3f61a529fe0600e304c4d07741763d3522b1/pyarrow-23.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1675c374570d8b91ea6d4edd4608fa55951acd44e0c31bd146e091b4005de24f", size = 48157777, upload-time = "2026-01-18T16:14:12.483Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ab/fb94923108c9c6415dab677cf1f066d3307798eafc03f9a65ab4abc61056/pyarrow-23.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:247374428fde4f668f138b04031a7e7077ba5fa0b5b1722fdf89a017bf0b7ee0", size = 50580441, upload-time = "2026-01-18T16:14:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/ae/78/897ba6337b517fc8e914891e1bd918da1c4eb8e936a553e95862e67b80f6/pyarrow-23.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:de53b1bd3b88a2ee93c9af412c903e57e738c083be4f6392288294513cd8b2c1", size = 27530028, upload-time = "2026-01-18T16:14:27.353Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c0/57fe251102ca834fee0ef69a84ad33cc0ff9d5dfc50f50b466846356ecd7/pyarrow-23.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5574d541923efcbfdf1294a2746ae3b8c2498a2dc6cd477882f6f4e7b1ac08d3", size = 34276762, upload-time = "2026-01-18T16:14:34.128Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4e/24130286548a5bc250cbed0b6bbf289a2775378a6e0e6f086ae8c68fc098/pyarrow-23.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:2ef0075c2488932e9d3c2eb3482f9459c4be629aa673b725d5e3cf18f777f8e4", size = 35821420, upload-time = "2026-01-18T16:14:40.699Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/a869e8529d487aa2e842d6c8865eb1e2c9ec33ce2786eb91104d2c3e3f10/pyarrow-23.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:65666fc269669af1ef1c14478c52222a2aa5c907f28b68fb50a203c777e4f60c", size = 44457412, upload-time = "2026-01-18T16:14:49.051Z" }, - { url = "https://files.pythonhosted.org/packages/36/81/1de4f0edfa9a483bbdf0082a05790bd6a20ed2169ea12a65039753be3a01/pyarrow-23.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4d85cb6177198f3812db4788e394b757223f60d9a9f5ad6634b3e32be1525803", size = 47534285, upload-time = "2026-01-18T16:14:56.748Z" }, - { url = "https://files.pythonhosted.org/packages/f2/04/464a052d673b5ece074518f27377861662449f3c1fdb39ce740d646fd098/pyarrow-23.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1a9ff6fa4141c24a03a1a434c63c8fa97ce70f8f36bccabc18ebba905ddf0f17", size = 48157913, upload-time = "2026-01-18T16:15:05.114Z" }, - { url = "https://files.pythonhosted.org/packages/f4/1b/32a4de9856ee6688c670ca2def588382e573cce45241a965af04c2f61687/pyarrow-23.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:84839d060a54ae734eb60a756aeacb62885244aaa282f3c968f5972ecc7b1ecc", size = 50582529, upload-time = "2026-01-18T16:15:12.846Z" }, - { url = "https://files.pythonhosted.org/packages/db/c7/d6581f03e9b9e44ea60b52d1750ee1a7678c484c06f939f45365a45f7eef/pyarrow-23.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:a149a647dbfe928ce8830a713612aa0b16e22c64feac9d1761529778e4d4eaa5", size = 27542646, upload-time = "2026-01-18T16:15:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bd/c861d020831ee57609b73ea721a617985ece817684dc82415b0bc3e03ac3/pyarrow-23.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5961a9f646c232697c24f54d3419e69b4261ba8a8b66b0ac54a1851faffcbab8", size = 34189116, upload-time = "2026-01-18T16:15:28.054Z" }, - { url = "https://files.pythonhosted.org/packages/8c/23/7725ad6cdcbaf6346221391e7b3eecd113684c805b0a95f32014e6fa0736/pyarrow-23.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:632b3e7c3d232f41d64e1a4a043fb82d44f8a349f339a1188c6a0dd9d2d47d8a", size = 35803831, upload-time = "2026-01-18T16:15:33.798Z" }, - { url = "https://files.pythonhosted.org/packages/57/06/684a421543455cdc2944d6a0c2cc3425b028a4c6b90e34b35580c4899743/pyarrow-23.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:76242c846db1411f1d6c2cc3823be6b86b40567ee24493344f8226ba34a81333", size = 44436452, upload-time = "2026-01-18T16:15:41.598Z" }, - { url = "https://files.pythonhosted.org/packages/c6/6f/8f9eb40c2328d66e8b097777ddcf38494115ff9f1b5bc9754ba46991191e/pyarrow-23.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b73519f8b52ae28127000986bf228fda781e81d3095cd2d3ece76eb5cf760e1b", size = 47557396, upload-time = "2026-01-18T16:15:51.252Z" }, - { url = "https://files.pythonhosted.org/packages/10/6e/f08075f1472e5159553501fde2cc7bc6700944bdabe49a03f8a035ee6ccd/pyarrow-23.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:068701f6823449b1b6469120f399a1239766b117d211c5d2519d4ed5861f75de", size = 48147129, upload-time = "2026-01-18T16:16:00.299Z" }, - { url = "https://files.pythonhosted.org/packages/7d/82/d5a680cd507deed62d141cc7f07f7944a6766fc51019f7f118e4d8ad0fb8/pyarrow-23.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1801ba947015d10e23bca9dd6ef5d0e9064a81569a89b6e9a63b59224fd060df", size = 50596642, upload-time = "2026-01-18T16:16:08.502Z" }, - { url = "https://files.pythonhosted.org/packages/a9/26/4f29c61b3dce9fa7780303b86895ec6a0917c9af927101daaaf118fbe462/pyarrow-23.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:52265266201ec25b6839bf6bd4ea918ca6d50f31d13e1cf200b4261cd11dc25c", size = 27660628, upload-time = "2026-01-18T16:16:15.28Z" }, - { url = "https://files.pythonhosted.org/packages/66/34/564db447d083ec7ff93e0a883a597d2f214e552823bfc178a2d0b1f2c257/pyarrow-23.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:ad96a597547af7827342ffb3c503c8316e5043bb09b47a84885ce39394c96e00", size = 34184630, upload-time = "2026-01-18T16:16:22.141Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3a/3999daebcb5e6119690c92a621c4d78eef2ffba7a0a1b56386d2875fcd77/pyarrow-23.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:b9edf990df77c2901e79608f08c13fbde60202334a4fcadb15c1f57bf7afee43", size = 35796820, upload-time = "2026-01-18T16:16:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ee/39195233056c6a8d0976d7d1ac1cd4fe21fb0ec534eca76bc23ef3f60e11/pyarrow-23.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:36d1b5bc6ddcaff0083ceec7e2561ed61a51f49cce8be079ee8ed406acb6fdef", size = 44438735, upload-time = "2026-01-18T16:16:38.79Z" }, - { url = "https://files.pythonhosted.org/packages/2c/41/6a7328ee493527e7afc0c88d105ecca69a3580e29f2faaeac29308369fd7/pyarrow-23.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4292b889cd224f403304ddda8b63a36e60f92911f89927ec8d98021845ea21be", size = 47557263, upload-time = "2026-01-18T16:16:46.248Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ee/34e95b21ee84db494eae60083ddb4383477b31fb1fd19fd866d794881696/pyarrow-23.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dfd9e133e60eaa847fd80530a1b89a052f09f695d0b9c34c235ea6b2e0924cf7", size = 48153529, upload-time = "2026-01-18T16:16:53.412Z" }, - { url = "https://files.pythonhosted.org/packages/52/88/8a8d83cea30f4563efa1b7bf51d241331ee5cd1b185a7e063f5634eca415/pyarrow-23.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832141cc09fac6aab1cd3719951d23301396968de87080c57c9a7634e0ecd068", size = 50598851, upload-time = "2026-01-18T16:17:01.133Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4c/2929c4be88723ba025e7b3453047dc67e491c9422965c141d24bab6b5962/pyarrow-23.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:7a7d067c9a88faca655c71bcc30ee2782038d59c802d57950826a07f60d83c4c", size = 27577747, upload-time = "2026-01-18T16:18:02.413Z" }, - { url = "https://files.pythonhosted.org/packages/64/52/564a61b0b82d72bd68ec3aef1adda1e3eba776f89134b9ebcb5af4b13cb6/pyarrow-23.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:ce9486e0535a843cf85d990e2ec5820a47918235183a5c7b8b97ed7e92c2d47d", size = 34446038, upload-time = "2026-01-18T16:17:07.861Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c9/232d4f9855fd1de0067c8a7808a363230d223c83aeee75e0fe6eab851ba9/pyarrow-23.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:075c29aeaa685fd1182992a9ed2499c66f084ee54eea47da3eb76e125e06064c", size = 35921142, upload-time = "2026-01-18T16:17:15.401Z" }, - { url = "https://files.pythonhosted.org/packages/96/f2/60af606a3748367b906bb82d41f0032e059f075444445d47e32a7ff1df62/pyarrow-23.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:799965a5379589510d888be3094c2296efd186a17ca1cef5b77703d4d5121f53", size = 44490374, upload-time = "2026-01-18T16:17:23.93Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2d/7731543050a678ea3a413955a2d5d80d2a642f270aa57a3cb7d5a86e3f46/pyarrow-23.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ef7cac8fe6fccd8b9e7617bfac785b0371a7fe26af59463074e4882747145d40", size = 47527896, upload-time = "2026-01-18T16:17:33.393Z" }, - { url = "https://files.pythonhosted.org/packages/5a/90/f3342553b7ac9879413aed46500f1637296f3c8222107523a43a1c08b42a/pyarrow-23.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15a414f710dc927132dd67c361f78c194447479555af57317066ee5116b90e9e", size = 48210401, upload-time = "2026-01-18T16:17:42.012Z" }, - { url = "https://files.pythonhosted.org/packages/f3/da/9862ade205ecc46c172b6ce5038a74b5151c7401e36255f15975a45878b2/pyarrow-23.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e0d2e6915eca7d786be6a77bf227fbc06d825a75b5b5fe9bcbef121dec32685", size = 50579677, upload-time = "2026-01-18T16:17:50.241Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4c/f11f371f5d4740a5dafc2e11c76bcf42d03dfdb2d68696da97de420b6963/pyarrow-23.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4b317ea6e800b5704e5e5929acb6e2dc13e9276b708ea97a39eb8b345aa2658b", size = 27631889, upload-time = "2026-01-18T16:17:56.55Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/15aec78bcf43a0c004067bd33eb5352836a29a49db8581fc56f2b6ca88b7/pyarrow-23.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:20b187ed9550d233a872074159f765f52f9d92973191cd4b93f293a19efbe377", size = 34213265, upload-time = "2026-01-18T16:18:07.904Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/deb2c594bbba41c37c5d9aa82f510376998352aa69dfcb886cb4b18ad80f/pyarrow-23.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:18ec84e839b493c3886b9b5e06861962ab4adfaeb79b81c76afbd8d84c7d5fda", size = 35819211, upload-time = "2026-01-18T16:18:13.94Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/ee82af693cb7b5b2b74f6524cdfede0e6ace779d7720ebca24d68b57c36b/pyarrow-23.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e438dd3f33894e34fd02b26bd12a32d30d006f5852315f611aa4add6c7fab4bc", size = 44502313, upload-time = "2026-01-18T16:18:20.367Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/95c61ad82236495f3c31987e85135926ba3ec7f3819296b70a68d8066b49/pyarrow-23.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a244279f240c81f135631be91146d7fa0e9e840e1dfed2aba8483eba25cd98e6", size = 47585886, upload-time = "2026-01-18T16:18:27.544Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6e/a72d901f305201802f016d015de1e05def7706fff68a1dedefef5dc7eff7/pyarrow-23.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c4692e83e42438dba512a570c6eaa42be2f8b6c0f492aea27dec54bdc495103a", size = 48207055, upload-time = "2026-01-18T16:18:35.425Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/5de029c537630ca18828db45c30e2a78da03675a70ac6c3528203c416fe3/pyarrow-23.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae7f30f898dfe44ea69654a35c93e8da4cef6606dc4c72394068fd95f8e9f54a", size = 50619812, upload-time = "2026-01-18T16:18:43.553Z" }, - { url = "https://files.pythonhosted.org/packages/59/8d/2af846cd2412e67a087f5bda4a8e23dfd4ebd570f777db2e8686615dafc1/pyarrow-23.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:5b86bb649e4112fb0614294b7d0a175c7513738876b89655605ebb87c804f861", size = 28263851, upload-time = "2026-01-18T16:19:38.567Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7f/caab863e587041156f6786c52e64151b7386742c8c27140f637176e9230e/pyarrow-23.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ebc017d765d71d80a3f8584ca0566b53e40464586585ac64176115baa0ada7d3", size = 34463240, upload-time = "2026-01-18T16:18:49.755Z" }, - { url = "https://files.pythonhosted.org/packages/c9/fa/3a5b8c86c958e83622b40865e11af0857c48ec763c11d472c87cd518283d/pyarrow-23.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:0800cc58a6d17d159df823f87ad66cefebf105b982493d4bad03ee7fab84b993", size = 35935712, upload-time = "2026-01-18T16:18:55.626Z" }, - { url = "https://files.pythonhosted.org/packages/c5/08/17a62078fc1a53decb34a9aa79cf9009efc74d63d2422e5ade9fed2f99e3/pyarrow-23.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3a7c68c722da9bb5b0f8c10e3eae71d9825a4b429b40b32709df5d1fa55beb3d", size = 44503523, upload-time = "2026-01-18T16:19:03.958Z" }, - { url = "https://files.pythonhosted.org/packages/cc/70/84d45c74341e798aae0323d33b7c39194e23b1abc439ceaf60a68a7a969a/pyarrow-23.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:bd5556c24622df90551063ea41f559b714aa63ca953db884cfb958559087a14e", size = 47542490, upload-time = "2026-01-18T16:19:11.208Z" }, - { url = "https://files.pythonhosted.org/packages/61/d9/d1274b0e6f19e235de17441e53224f4716574b2ca837022d55702f24d71d/pyarrow-23.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54810f6e6afc4ffee7c2e0051b61722fbea9a4961b46192dcfae8ea12fa09059", size = 48233605, upload-time = "2026-01-18T16:19:19.544Z" }, - { url = "https://files.pythonhosted.org/packages/39/07/e4e2d568cb57543d84482f61e510732820cddb0f47c4bb7df629abfed852/pyarrow-23.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:14de7d48052cf4b0ed174533eafa3cfe0711b8076ad70bede32cf59f744f0d7c", size = 50603979, upload-time = "2026-01-18T16:19:26.717Z" }, - { url = "https://files.pythonhosted.org/packages/72/9c/47693463894b610f8439b2e970b82ef81e9599c757bf2049365e40ff963c/pyarrow-23.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:427deac1f535830a744a4f04a6ac183a64fcac4341b3f618e693c41b7b98d2b0", size = 28338905, upload-time = "2026-01-18T16:19:32.93Z" }, -] - [[package]] name = "pyasn1" version = "0.6.3" @@ -2150,16 +1480,16 @@ wheels = [ [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] name = "pydantic" -version = "2.11.9" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2167,251 +1497,240 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, -] - -[[package]] -name = "pyjwt" -version = "2.12.1" +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, -] - -[package.optional-dependencies] -crypto = [ { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] - -[[package]] -name = "pyparsing" -version = "3.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/c9/b4594e6a81371dfa9eb7a2c110ad682acf985d96115ae8b25a1d63b4bf3b/pyparsing-3.2.4.tar.gz", hash = "sha256:fff89494f45559d0f2ce46613b419f632bbb6afbdaed49696d322bcf98a58e99", size = 1098809, upload-time = "2025-09-13T05:47:19.732Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/b8/fbab973592e23ae313042d450fc26fa24282ebffba21ba373786e1ce63b4/pyparsing-3.2.4-py3-none-any.whl", hash = "sha256:91d0fcde680d42cd031daf3a6ba20da3107e08a75de50da58360e7d94ab24d36", size = 113869, upload-time = "2025-09-13T05:47:17.863Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, ] [[package]] name = "python-dotenv" -version = "1.1.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" -version = "0.0.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, -] - -[[package]] -name = "pywin32" -version = "311" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, - { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "rpds-py" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2419,165 +1738,246 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "rpds-py" -version = "0.27.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/ed/3aef893e2dd30e77e35d20d4ddb45ca459db59cead748cad9796ad479411/rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef", size = 371606, upload-time = "2025-08-27T12:12:25.189Z" }, - { url = "https://files.pythonhosted.org/packages/6d/82/9818b443e5d3eb4c83c3994561387f116aae9833b35c484474769c4a8faf/rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be", size = 353452, upload-time = "2025-08-27T12:12:27.433Z" }, - { url = "https://files.pythonhosted.org/packages/99/c7/d2a110ffaaa397fc6793a83c7bd3545d9ab22658b7cdff05a24a4535cc45/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61", size = 381519, upload-time = "2025-08-27T12:12:28.719Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bc/e89581d1f9d1be7d0247eaef602566869fdc0d084008ba139e27e775366c/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb", size = 394424, upload-time = "2025-08-27T12:12:30.207Z" }, - { url = "https://files.pythonhosted.org/packages/ac/2e/36a6861f797530e74bb6ed53495f8741f1ef95939eed01d761e73d559067/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657", size = 523467, upload-time = "2025-08-27T12:12:31.808Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/c1bc2be32564fa499f988f0a5c6505c2f4746ef96e58e4d7de5cf923d77e/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013", size = 402660, upload-time = "2025-08-27T12:12:33.444Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ec/ef8bf895f0628dd0a59e54d81caed6891663cb9c54a0f4bb7da918cb88cf/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a", size = 384062, upload-time = "2025-08-27T12:12:34.857Z" }, - { url = "https://files.pythonhosted.org/packages/69/f7/f47ff154be8d9a5e691c083a920bba89cef88d5247c241c10b9898f595a1/rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1", size = 401289, upload-time = "2025-08-27T12:12:36.085Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d9/ca410363efd0615814ae579f6829cafb39225cd63e5ea5ed1404cb345293/rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10", size = 417718, upload-time = "2025-08-27T12:12:37.401Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a0/8cb5c2ff38340f221cc067cc093d1270e10658ba4e8d263df923daa18e86/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808", size = 558333, upload-time = "2025-08-27T12:12:38.672Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8c/1b0de79177c5d5103843774ce12b84caa7164dfc6cd66378768d37db11bf/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8", size = 589127, upload-time = "2025-08-27T12:12:41.48Z" }, - { url = "https://files.pythonhosted.org/packages/c8/5e/26abb098d5e01266b0f3a2488d299d19ccc26849735d9d2b95c39397e945/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9", size = 554899, upload-time = "2025-08-27T12:12:42.925Z" }, - { url = "https://files.pythonhosted.org/packages/de/41/905cc90ced13550db017f8f20c6d8e8470066c5738ba480d7ba63e3d136b/rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4", size = 217450, upload-time = "2025-08-27T12:12:44.813Z" }, - { url = "https://files.pythonhosted.org/packages/75/3d/6bef47b0e253616ccdf67c283e25f2d16e18ccddd38f92af81d5a3420206/rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1", size = 228447, upload-time = "2025-08-27T12:12:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c1/7907329fbef97cbd49db6f7303893bd1dd5a4a3eae415839ffdfb0762cae/rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881", size = 371063, upload-time = "2025-08-27T12:12:47.856Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/2aab4bc86228bcf7c48760990273653a4900de89c7537ffe1b0d6097ed39/rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5", size = 353210, upload-time = "2025-08-27T12:12:49.187Z" }, - { url = "https://files.pythonhosted.org/packages/3a/57/f5eb3ecf434342f4f1a46009530e93fd201a0b5b83379034ebdb1d7c1a58/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e", size = 381636, upload-time = "2025-08-27T12:12:50.492Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f4/ef95c5945e2ceb5119571b184dd5a1cc4b8541bbdf67461998cfeac9cb1e/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c", size = 394341, upload-time = "2025-08-27T12:12:52.024Z" }, - { url = "https://files.pythonhosted.org/packages/5a/7e/4bd610754bf492d398b61725eb9598ddd5eb86b07d7d9483dbcd810e20bc/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195", size = 523428, upload-time = "2025-08-27T12:12:53.779Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e5/059b9f65a8c9149361a8b75094864ab83b94718344db511fd6117936ed2a/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52", size = 402923, upload-time = "2025-08-27T12:12:55.15Z" }, - { url = "https://files.pythonhosted.org/packages/f5/48/64cabb7daced2968dd08e8a1b7988bf358d7bd5bcd5dc89a652f4668543c/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed", size = 384094, upload-time = "2025-08-27T12:12:57.194Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e1/dc9094d6ff566bff87add8a510c89b9e158ad2ecd97ee26e677da29a9e1b/rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a", size = 401093, upload-time = "2025-08-27T12:12:58.985Z" }, - { url = "https://files.pythonhosted.org/packages/37/8e/ac8577e3ecdd5593e283d46907d7011618994e1d7ab992711ae0f78b9937/rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde", size = 417969, upload-time = "2025-08-27T12:13:00.367Z" }, - { url = "https://files.pythonhosted.org/packages/66/6d/87507430a8f74a93556fe55c6485ba9c259949a853ce407b1e23fea5ba31/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21", size = 558302, upload-time = "2025-08-27T12:13:01.737Z" }, - { url = "https://files.pythonhosted.org/packages/3a/bb/1db4781ce1dda3eecc735e3152659a27b90a02ca62bfeea17aee45cc0fbc/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9", size = 589259, upload-time = "2025-08-27T12:13:03.127Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/ae1c8943d11a814d01b482e1f8da903f88047a962dff9bbdadf3bd6e6fd1/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948", size = 554983, upload-time = "2025-08-27T12:13:04.516Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d5/0b2a55415931db4f112bdab072443ff76131b5ac4f4dc98d10d2d357eb03/rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39", size = 217154, upload-time = "2025-08-27T12:13:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/24/75/3b7ffe0d50dc86a6a964af0d1cc3a4a2cdf437cb7b099a4747bbb96d1819/rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15", size = 228627, upload-time = "2025-08-27T12:13:07.625Z" }, - { url = "https://files.pythonhosted.org/packages/8d/3f/4fd04c32abc02c710f09a72a30c9a55ea3cc154ef8099078fd50a0596f8e/rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746", size = 220998, upload-time = "2025-08-27T12:13:08.972Z" }, - { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, - { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, - { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, - { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, - { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, - { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, - { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, - { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, - { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, - { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, - { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, - { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, - { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, - { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, - { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, - { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, - { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, - { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, - { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, - { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, - { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, - { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, - { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, - { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, - { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, - { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, - { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, - { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, - { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, - { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, - { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, - { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, - { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, - { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, - { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, - { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, - { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, - { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, - { url = "https://files.pythonhosted.org/packages/d5/63/b7cc415c345625d5e62f694ea356c58fb964861409008118f1245f8c3347/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf", size = 371360, upload-time = "2025-08-27T12:15:29.218Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8c/12e1b24b560cf378b8ffbdb9dc73abd529e1adcfcf82727dfd29c4a7b88d/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3", size = 353933, upload-time = "2025-08-27T12:15:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/9b/85/1bb2210c1f7a1b99e91fea486b9f0f894aa5da3a5ec7097cbad7dec6d40f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636", size = 382962, upload-time = "2025-08-27T12:15:32.348Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c9/a839b9f219cf80ed65f27a7f5ddbb2809c1b85c966020ae2dff490e0b18e/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8", size = 394412, upload-time = "2025-08-27T12:15:33.839Z" }, - { url = "https://files.pythonhosted.org/packages/02/2d/b1d7f928b0b1f4fc2e0133e8051d199b01d7384875adc63b6ddadf3de7e5/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc", size = 523972, upload-time = "2025-08-27T12:15:35.377Z" }, - { url = "https://files.pythonhosted.org/packages/a9/af/2cbf56edd2d07716df1aec8a726b3159deb47cb5c27e1e42b71d705a7c2f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8", size = 403273, upload-time = "2025-08-27T12:15:37.051Z" }, - { url = "https://files.pythonhosted.org/packages/c0/93/425e32200158d44ff01da5d9612c3b6711fe69f606f06e3895511f17473b/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc", size = 385278, upload-time = "2025-08-27T12:15:38.571Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1a/1a04a915ecd0551bfa9e77b7672d1937b4b72a0fc204a17deef76001cfb2/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71", size = 402084, upload-time = "2025-08-27T12:15:40.529Z" }, - { url = "https://files.pythonhosted.org/packages/51/f7/66585c0fe5714368b62951d2513b684e5215beaceab2c6629549ddb15036/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad", size = 419041, upload-time = "2025-08-27T12:15:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7e/83a508f6b8e219bba2d4af077c35ba0e0cdd35a751a3be6a7cba5a55ad71/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab", size = 560084, upload-time = "2025-08-27T12:15:43.839Z" }, - { url = "https://files.pythonhosted.org/packages/66/66/bb945683b958a1b19eb0fe715594630d0f36396ebdef4d9b89c2fa09aa56/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059", size = 590115, upload-time = "2025-08-27T12:15:46.647Z" }, - { url = "https://files.pythonhosted.org/packages/12/00/ccfaafaf7db7e7adace915e5c2f2c2410e16402561801e9c7f96683002d3/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b", size = 556561, upload-time = "2025-08-27T12:15:48.219Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b7/92b6ed9aad103bfe1c45df98453dfae40969eef2cb6c6239c58d7e96f1b3/rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819", size = 229125, upload-time = "2025-08-27T12:15:49.956Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ed/e1fba02de17f4f76318b834425257c8ea297e415e12c68b4361f63e8ae92/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df", size = 371402, upload-time = "2025-08-27T12:15:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/af/7c/e16b959b316048b55585a697e94add55a4ae0d984434d279ea83442e460d/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3", size = 354084, upload-time = "2025-08-27T12:15:53.219Z" }, - { url = "https://files.pythonhosted.org/packages/de/c1/ade645f55de76799fdd08682d51ae6724cb46f318573f18be49b1e040428/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9", size = 383090, upload-time = "2025-08-27T12:15:55.158Z" }, - { url = "https://files.pythonhosted.org/packages/1f/27/89070ca9b856e52960da1472efcb6c20ba27cfe902f4f23ed095b9cfc61d/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc", size = 394519, upload-time = "2025-08-27T12:15:57.238Z" }, - { url = "https://files.pythonhosted.org/packages/b3/28/be120586874ef906aa5aeeae95ae8df4184bc757e5b6bd1c729ccff45ed5/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4", size = 523817, upload-time = "2025-08-27T12:15:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/70cc197bc11cfcde02a86f36ac1eed15c56667c2ebddbdb76a47e90306da/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66", size = 403240, upload-time = "2025-08-27T12:16:00.923Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/46936cca449f7f518f2f4996e0e8344db4b57e2081e752441154089d2a5f/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e", size = 385194, upload-time = "2025-08-27T12:16:02.802Z" }, - { url = "https://files.pythonhosted.org/packages/e1/62/29c0d3e5125c3270b51415af7cbff1ec587379c84f55a5761cc9efa8cd06/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c", size = 402086, upload-time = "2025-08-27T12:16:04.806Z" }, - { url = "https://files.pythonhosted.org/packages/8f/66/03e1087679227785474466fdd04157fb793b3b76e3fcf01cbf4c693c1949/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf", size = 419272, upload-time = "2025-08-27T12:16:06.471Z" }, - { url = "https://files.pythonhosted.org/packages/6a/24/e3e72d265121e00b063aef3e3501e5b2473cf1b23511d56e529531acf01e/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf", size = 560003, upload-time = "2025-08-27T12:16:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/26/ca/f5a344c534214cc2d41118c0699fffbdc2c1bc7046f2a2b9609765ab9c92/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6", size = 590482, upload-time = "2025-08-27T12:16:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" }, -] - -[[package]] -name = "rsa" -version = "4.9.1" +version = "0.30.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, +resolution-markers = [ + "python_full_version < '3.11'", ] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] [[package]] -name = "six" -version = "1.17.0" +name = "rpds-py" +version = "2026.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, ] [[package]] @@ -2589,155 +1989,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "sqlalchemy" -version = "2.0.43" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/4e/985f7da36f09592c5ade99321c72c15101d23c0bb7eecfd1daaca5714422/sqlalchemy-2.0.43-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069", size = 2133162, upload-time = "2025-08-11T15:52:17.854Z" }, - { url = "https://files.pythonhosted.org/packages/37/34/798af8db3cae069461e3bc0898a1610dc469386a97048471d364dc8aae1c/sqlalchemy-2.0.43-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154", size = 2123082, upload-time = "2025-08-11T15:52:19.181Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0f/79cf4d9dad42f61ec5af1e022c92f66c2d110b93bb1dc9b033892971abfa/sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612", size = 3208871, upload-time = "2025-08-11T15:50:30.656Z" }, - { url = "https://files.pythonhosted.org/packages/56/b3/59befa58fb0e1a9802c87df02344548e6d007e77e87e6084e2131c29e033/sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019", size = 3209583, upload-time = "2025-08-11T15:57:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/29/d2/124b50c0eb8146e8f0fe16d01026c1a073844f0b454436d8544fe9b33bd7/sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20", size = 3148177, upload-time = "2025-08-11T15:50:32.078Z" }, - { url = "https://files.pythonhosted.org/packages/83/f5/e369cd46aa84278107624617034a5825fedfc5c958b2836310ced4d2eadf/sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18", size = 3172276, upload-time = "2025-08-11T15:57:49.477Z" }, - { url = "https://files.pythonhosted.org/packages/de/2b/4602bf4c3477fa4c837c9774e6dd22e0389fc52310c4c4dfb7e7ba05e90d/sqlalchemy-2.0.43-cp310-cp310-win32.whl", hash = "sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00", size = 2101491, upload-time = "2025-08-11T15:54:59.191Z" }, - { url = "https://files.pythonhosted.org/packages/38/2d/bfc6b6143adef553a08295490ddc52607ee435b9c751c714620c1b3dd44d/sqlalchemy-2.0.43-cp310-cp310-win_amd64.whl", hash = "sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b", size = 2125148, upload-time = "2025-08-11T15:55:00.593Z" }, - { url = "https://files.pythonhosted.org/packages/9d/77/fa7189fe44114658002566c6fe443d3ed0ec1fa782feb72af6ef7fbe98e7/sqlalchemy-2.0.43-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29", size = 2136472, upload-time = "2025-08-11T15:52:21.789Z" }, - { url = "https://files.pythonhosted.org/packages/99/ea/92ac27f2fbc2e6c1766bb807084ca455265707e041ba027c09c17d697867/sqlalchemy-2.0.43-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631", size = 2126535, upload-time = "2025-08-11T15:52:23.109Z" }, - { url = "https://files.pythonhosted.org/packages/94/12/536ede80163e295dc57fff69724caf68f91bb40578b6ac6583a293534849/sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685", size = 3297521, upload-time = "2025-08-11T15:50:33.536Z" }, - { url = "https://files.pythonhosted.org/packages/03/b5/cacf432e6f1fc9d156eca0560ac61d4355d2181e751ba8c0cd9cb232c8c1/sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca", size = 3297343, upload-time = "2025-08-11T15:57:51.186Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ba/d4c9b526f18457667de4c024ffbc3a0920c34237b9e9dd298e44c7c00ee5/sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d", size = 3232113, upload-time = "2025-08-11T15:50:34.949Z" }, - { url = "https://files.pythonhosted.org/packages/aa/79/c0121b12b1b114e2c8a10ea297a8a6d5367bc59081b2be896815154b1163/sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3", size = 3258240, upload-time = "2025-08-11T15:57:52.983Z" }, - { url = "https://files.pythonhosted.org/packages/79/99/a2f9be96fb382f3ba027ad42f00dbe30fdb6ba28cda5f11412eee346bec5/sqlalchemy-2.0.43-cp311-cp311-win32.whl", hash = "sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921", size = 2101248, upload-time = "2025-08-11T15:55:01.855Z" }, - { url = "https://files.pythonhosted.org/packages/ee/13/744a32ebe3b4a7a9c7ea4e57babae7aa22070d47acf330d8e5a1359607f1/sqlalchemy-2.0.43-cp311-cp311-win_amd64.whl", hash = "sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8", size = 2126109, upload-time = "2025-08-11T15:55:04.092Z" }, - { url = "https://files.pythonhosted.org/packages/61/db/20c78f1081446095450bdc6ee6cc10045fce67a8e003a5876b6eaafc5cc4/sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24", size = 2134891, upload-time = "2025-08-11T15:51:13.019Z" }, - { url = "https://files.pythonhosted.org/packages/45/0a/3d89034ae62b200b4396f0f95319f7d86e9945ee64d2343dcad857150fa2/sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83", size = 2123061, upload-time = "2025-08-11T15:51:14.319Z" }, - { url = "https://files.pythonhosted.org/packages/cb/10/2711f7ff1805919221ad5bee205971254845c069ee2e7036847103ca1e4c/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9", size = 3320384, upload-time = "2025-08-11T15:52:35.088Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0e/3d155e264d2ed2778484006ef04647bc63f55b3e2d12e6a4f787747b5900/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48", size = 3329648, upload-time = "2025-08-11T15:56:34.153Z" }, - { url = "https://files.pythonhosted.org/packages/5b/81/635100fb19725c931622c673900da5efb1595c96ff5b441e07e3dd61f2be/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687", size = 3258030, upload-time = "2025-08-11T15:52:36.933Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ed/a99302716d62b4965fded12520c1cbb189f99b17a6d8cf77611d21442e47/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe", size = 3294469, upload-time = "2025-08-11T15:56:35.553Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a2/3a11b06715149bf3310b55a98b5c1e84a42cfb949a7b800bc75cb4e33abc/sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d", size = 2098906, upload-time = "2025-08-11T15:55:00.645Z" }, - { url = "https://files.pythonhosted.org/packages/bc/09/405c915a974814b90aa591280623adc6ad6b322f61fd5cff80aeaef216c9/sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a", size = 2126260, upload-time = "2025-08-11T15:55:02.965Z" }, - { url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload-time = "2025-08-11T15:51:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload-time = "2025-08-11T15:51:17.256Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload-time = "2025-08-11T15:52:38.444Z" }, - { url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload-time = "2025-08-11T15:56:37.348Z" }, - { url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload-time = "2025-08-11T15:52:39.865Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload-time = "2025-08-11T15:56:39.11Z" }, - { url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload-time = "2025-08-11T15:55:05.349Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload-time = "2025-08-11T15:55:07.932Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" }, -] - -[[package]] -name = "sqlalchemy-spanner" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "alembic" }, - { name = "google-cloud-spanner" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bf/6c/d9a2e05d839ec4d00d11887f18e66de331f696b162159dc2655e3910bb55/sqlalchemy_spanner-1.16.0.tar.gz", hash = "sha256:5143d5d092f2f1fef66b332163291dc7913a58292580733a601ff5fae160515a", size = 82748, upload-time = "2025-09-02T08:26:00.645Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/74/a9c88abddfeca46c253000e87aad923014c1907953e06b39a0cbec229a86/sqlalchemy_spanner-1.16.0-py3-none-any.whl", hash = "sha256:e53cadb2b973e88936c0a9874e133ee9a0829ea3261f328b4ca40bdedf2016c1", size = 32069, upload-time = "2025-09-02T08:25:59.264Z" }, -] - -[[package]] -name = "sqlparse" -version = "0.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, -] - [[package]] name = "sse-starlette" -version = "3.0.2" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, ] [[package]] name = "starlette" -version = "0.50.0" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] [[package]] name = "tenacity" -version = "9.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, -] - -[[package]] -name = "tomli" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]] @@ -2751,23 +2035,23 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" -version = "2025.2" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] @@ -2782,36 +2066,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] -[[package]] -name = "uritemplate" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, -] - [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "uvicorn" -version = "0.35.0" +version = "0.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] [package.optional-dependencies] @@ -2827,34 +2102,46 @@ standard = [ [[package]] name = "uvloop" -version = "0.21.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019, upload-time = "2024-10-14T23:37:20.068Z" }, - { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898, upload-time = "2024-10-14T23:37:22.663Z" }, - { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735, upload-time = "2024-10-14T23:37:25.129Z" }, - { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126, upload-time = "2024-10-14T23:37:27.59Z" }, - { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789, upload-time = "2024-10-14T23:37:29.385Z" }, - { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523, upload-time = "2024-10-14T23:37:32.048Z" }, - { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload-time = "2024-10-14T23:37:33.612Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload-time = "2024-10-14T23:37:36.11Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload-time = "2024-10-14T23:37:37.683Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload-time = "2024-10-14T23:37:40.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload-time = "2024-10-14T23:37:42.839Z" }, - { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload-time = "2024-10-14T23:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, - { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, - { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, - { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, - { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, - { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, - { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, - { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] @@ -2891,102 +2178,108 @@ wheels = [ [[package]] name = "watchfiles" -version = "1.1.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/dd/579d1dc57f0f895426a1211c4ef3b0cb37eb9e642bb04bdcd962b5df206a/watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc", size = 405757, upload-time = "2025-06-15T19:04:51.058Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a0/7a0318cd874393344d48c34d53b3dd419466adf59a29ba5b51c88dd18b86/watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df", size = 397511, upload-time = "2025-06-15T19:04:52.79Z" }, - { url = "https://files.pythonhosted.org/packages/06/be/503514656d0555ec2195f60d810eca29b938772e9bfb112d5cd5ad6f6a9e/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68", size = 450739, upload-time = "2025-06-15T19:04:54.203Z" }, - { url = "https://files.pythonhosted.org/packages/4e/0d/a05dd9e5f136cdc29751816d0890d084ab99f8c17b86f25697288ca09bc7/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc", size = 458106, upload-time = "2025-06-15T19:04:55.607Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fa/9cd16e4dfdb831072b7ac39e7bea986e52128526251038eb481effe9f48e/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97", size = 484264, upload-time = "2025-06-15T19:04:57.009Z" }, - { url = "https://files.pythonhosted.org/packages/32/04/1da8a637c7e2b70e750a0308e9c8e662ada0cca46211fa9ef24a23937e0b/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c", size = 597612, upload-time = "2025-06-15T19:04:58.409Z" }, - { url = "https://files.pythonhosted.org/packages/30/01/109f2762e968d3e58c95731a206e5d7d2a7abaed4299dd8a94597250153c/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5", size = 477242, upload-time = "2025-06-15T19:04:59.786Z" }, - { url = "https://files.pythonhosted.org/packages/b5/b8/46f58cf4969d3b7bc3ca35a98e739fa4085b0657a1540ccc29a1a0bc016f/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9", size = 453148, upload-time = "2025-06-15T19:05:01.103Z" }, - { url = "https://files.pythonhosted.org/packages/a5/cd/8267594263b1770f1eb76914940d7b2d03ee55eca212302329608208e061/watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72", size = 626574, upload-time = "2025-06-15T19:05:02.582Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2f/7f2722e85899bed337cba715723e19185e288ef361360718973f891805be/watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc", size = 624378, upload-time = "2025-06-15T19:05:03.719Z" }, - { url = "https://files.pythonhosted.org/packages/bf/20/64c88ec43d90a568234d021ab4b2a6f42a5230d772b987c3f9c00cc27b8b/watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587", size = 279829, upload-time = "2025-06-15T19:05:04.822Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/a9c1ed33de7af80935e4eac09570de679c6e21c07070aa99f74b4431f4d6/watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82", size = 292192, upload-time = "2025-06-15T19:05:06.348Z" }, - { url = "https://files.pythonhosted.org/packages/8b/78/7401154b78ab484ccaaeef970dc2af0cb88b5ba8a1b415383da444cdd8d3/watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2", size = 405751, upload-time = "2025-06-15T19:05:07.679Z" }, - { url = "https://files.pythonhosted.org/packages/76/63/e6c3dbc1f78d001589b75e56a288c47723de28c580ad715eb116639152b5/watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c", size = 397313, upload-time = "2025-06-15T19:05:08.764Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a2/8afa359ff52e99af1632f90cbf359da46184207e893a5f179301b0c8d6df/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d", size = 450792, upload-time = "2025-06-15T19:05:09.869Z" }, - { url = "https://files.pythonhosted.org/packages/1d/bf/7446b401667f5c64972a57a0233be1104157fc3abf72c4ef2666c1bd09b2/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7", size = 458196, upload-time = "2025-06-15T19:05:11.91Z" }, - { url = "https://files.pythonhosted.org/packages/58/2f/501ddbdfa3fa874ea5597c77eeea3d413579c29af26c1091b08d0c792280/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c", size = 484788, upload-time = "2025-06-15T19:05:13.373Z" }, - { url = "https://files.pythonhosted.org/packages/61/1e/9c18eb2eb5c953c96bc0e5f626f0e53cfef4bd19bd50d71d1a049c63a575/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575", size = 597879, upload-time = "2025-06-15T19:05:14.725Z" }, - { url = "https://files.pythonhosted.org/packages/8b/6c/1467402e5185d89388b4486745af1e0325007af0017c3384cc786fff0542/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8", size = 477447, upload-time = "2025-06-15T19:05:15.775Z" }, - { url = "https://files.pythonhosted.org/packages/2b/a1/ec0a606bde4853d6c4a578f9391eeb3684a9aea736a8eb217e3e00aa89a1/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f", size = 453145, upload-time = "2025-06-15T19:05:17.17Z" }, - { url = "https://files.pythonhosted.org/packages/90/b9/ef6f0c247a6a35d689fc970dc7f6734f9257451aefb30def5d100d6246a5/watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4", size = 626539, upload-time = "2025-06-15T19:05:18.557Z" }, - { url = "https://files.pythonhosted.org/packages/34/44/6ffda5537085106ff5aaa762b0d130ac6c75a08015dd1621376f708c94de/watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d", size = 624472, upload-time = "2025-06-15T19:05:19.588Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e3/71170985c48028fa3f0a50946916a14055e741db11c2e7bc2f3b61f4d0e3/watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2", size = 279348, upload-time = "2025-06-15T19:05:20.856Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/3e39c68b68a7a171070f81fc2561d23ce8d6859659406842a0e4bebf3bba/watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12", size = 292607, upload-time = "2025-06-15T19:05:21.937Z" }, - { url = "https://files.pythonhosted.org/packages/61/9f/2973b7539f2bdb6ea86d2c87f70f615a71a1fc2dba2911795cea25968aea/watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a", size = 285056, upload-time = "2025-06-15T19:05:23.12Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" }, - { url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" }, - { url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" }, - { url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" }, - { url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" }, - { url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" }, - { url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" }, - { url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" }, - { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" }, - { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" }, - { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" }, - { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" }, - { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" }, - { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" }, - { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" }, - { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" }, - { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" }, - { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" }, - { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" }, - { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" }, - { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" }, - { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" }, - { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" }, - { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" }, - { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" }, - { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" }, - { url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114, upload-time = "2025-06-15T19:06:06.186Z" }, - { url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879, upload-time = "2025-06-15T19:06:07.369Z" }, - { url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026, upload-time = "2025-06-15T19:06:08.476Z" }, - { url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917, upload-time = "2025-06-15T19:06:09.988Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602, upload-time = "2025-06-15T19:06:11.088Z" }, - { url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758, upload-time = "2025-06-15T19:06:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601, upload-time = "2025-06-15T19:06:13.391Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936, upload-time = "2025-06-15T19:06:14.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243, upload-time = "2025-06-15T19:06:16.232Z" }, - { url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073, upload-time = "2025-06-15T19:06:17.457Z" }, - { url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872, upload-time = "2025-06-15T19:06:18.57Z" }, - { url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877, upload-time = "2025-06-15T19:06:19.55Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645, upload-time = "2025-06-15T19:06:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424, upload-time = "2025-06-15T19:06:21.712Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584, upload-time = "2025-06-15T19:06:22.777Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675, upload-time = "2025-06-15T19:06:24.226Z" }, - { url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363, upload-time = "2025-06-15T19:06:25.42Z" }, - { url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" }, - { url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" }, - { url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" }, - { url = "https://files.pythonhosted.org/packages/be/7c/a3d7c55cfa377c2f62c4ae3c6502b997186bc5e38156bafcb9b653de9a6d/watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5", size = 406748, upload-time = "2025-06-15T19:06:44.2Z" }, - { url = "https://files.pythonhosted.org/packages/38/d0/c46f1b2c0ca47f3667b144de6f0515f6d1c670d72f2ca29861cac78abaa1/watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d", size = 398801, upload-time = "2025-06-15T19:06:45.774Z" }, - { url = "https://files.pythonhosted.org/packages/70/9c/9a6a42e97f92eeed77c3485a43ea96723900aefa3ac739a8c73f4bff2cd7/watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea", size = 451528, upload-time = "2025-06-15T19:06:46.791Z" }, - { url = "https://files.pythonhosted.org/packages/51/7b/98c7f4f7ce7ff03023cf971cd84a3ee3b790021ae7584ffffa0eb2554b96/watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6", size = 454095, upload-time = "2025-06-15T19:06:48.211Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6b/686dcf5d3525ad17b384fd94708e95193529b460a1b7bf40851f1328ec6e/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3", size = 406910, upload-time = "2025-06-15T19:06:49.335Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d3/71c2dcf81dc1edcf8af9f4d8d63b1316fb0a2dd90cbfd427e8d9dd584a90/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c", size = 398816, upload-time = "2025-06-15T19:06:50.433Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/12269467b2fc006f8fce4cd6c3acfa77491dd0777d2a747415f28ccc8c60/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432", size = 451584, upload-time = "2025-06-15T19:06:51.834Z" }, - { url = "https://files.pythonhosted.org/packages/bd/d3/254cea30f918f489db09d6a8435a7de7047f8cb68584477a515f160541d6/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792", size = 454009, upload-time = "2025-06-15T19:06:52.896Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] [[package]] @@ -3048,110 +2341,213 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/8b/84bc1ea68b620fe0e2696a8cff07e82f4b962d952ab14efee8955997bb70/wrapt-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0f68f478004475d97906686e702ddbddeaf717c0b68ad2794384308f2dc713ae", size = 80093, upload-time = "2026-05-22T14:47:27.074Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/64ec81194a0bc708d9720174c998c8a32116e82b5b32c04e20a7fe01176c/wrapt-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e422b2d647a65d6b080cad5accd09055d3809bdff00c76fba8dca00ca935572a", size = 81183, upload-time = "2026-05-22T14:47:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/3d186944aae923631d1def58f4c4ff8f0b6309906afc0b6978de3e69b3e0/wrapt-2.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:036dfb40128819a751c6f451c6b9c10172c49e4c401aebcdb8ecf2aec1683598", size = 152494, upload-time = "2026-05-22T14:47:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/01/d1/6b3d0ea995b867d2862aad5619bd5e17de09a9d64a821f46832dcd272d40/wrapt-2.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ac16c081bebfd15d8e4dfa5bdc805990bbd52249ecff22530da7a129d6120b", size = 154310, upload-time = "2026-05-22T14:47:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4b/37ecb90a8c3753e580327fb40731a984b754e3df65d2ef932bf359fe4adc/wrapt-2.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07be671fa8875971222b0ba9059ed8b4dc738631122feba17c93aa36b4213e9a", size = 149002, upload-time = "2026-05-22T14:47:34.021Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/918884d9dfa84d0d135b42a51c00910f5c5447fe7a5e211a8e16ac324dd4/wrapt-2.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93fc2bf40cd7f4a0256010dce073d44eeb4a351b9bca94d0477ce2b6e62532b3", size = 153185, upload-time = "2026-05-22T14:47:35.722Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/382299d8ced610b29b59b099a89eda821e8c489aa152b7183748ac83f32a/wrapt-2.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba519b2d765df9871a25879e6f7fa78948ea59a2a31f9c1a257e34b651994afc", size = 148040, upload-time = "2026-05-22T14:47:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/62a79b79e35bbebb1207ca5d15b81192f37f20cc5659cf4e3ce955b7fcc8/wrapt-2.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9011395be8db1827d106c6449b4bb6dd17e331ff6ec521f227e4588f1c78e46f", size = 151773, upload-time = "2026-05-22T14:47:38.713Z" }, + { url = "https://files.pythonhosted.org/packages/a1/db/95c152151d206d4b430516c89725306e92484072f38e65492afde63f6d19/wrapt-2.2.1-cp310-cp310-win32.whl", hash = "sha256:a8f7176b83664af44567e9cc06e0d3827823fcc1a5e52307ebb8ac3aa95860b9", size = 77393, upload-time = "2026-05-22T14:47:40.061Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/882d50452c6fbd13f24fe5d2644b97cdad2565a7e1522cbb6312de8a52cf/wrapt-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:d7f513d3185e6fec82d0c3518f2e6365d8b4e49f5f45f29640d5162d56a23b54", size = 80350, upload-time = "2026-05-22T14:47:41.194Z" }, + { url = "https://files.pythonhosted.org/packages/58/0f/148376523b4e370692286a9ba14d5715cf3c5b86da3bd3630926367b6b73/wrapt-2.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:44255c84bc57554fed822e83e70036b51afa9edb56fc7ca56c54410ece7898c9", size = 79149, upload-time = "2026-05-22T14:47:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + [[package]] name = "yarl" -version = "1.20.1" +version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, - { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, - { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, - { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, - { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, - { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, - { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, - { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, - { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, - { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, - { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, - { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, - { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, - { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, - { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, - { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, - { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, - { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, - { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, - { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, - { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, - { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, - { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, - { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, - { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, - { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, - { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, - { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, - { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, - { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, - { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, - { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, - { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, - { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, - { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, - { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, - { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, - { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, - { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/df/f1c7a3de0831cd83194f1a85c5bb431b13f81e6b45079314c86d1c4ef3f2/yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12", size = 129057, upload-time = "2026-05-19T21:27:47.564Z" }, + { url = "https://files.pythonhosted.org/packages/48/41/7daafb32dd7562bf45b1ce56562e7e1a9146f6479b6456873eb8a3413c40/yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0", size = 91545, upload-time = "2026-05-19T21:27:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8f/7b3ec212f1ea0683f55f978e3246bc313c38818664edfc97a9f349a4901e/yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75", size = 91380, upload-time = "2026-05-19T21:27:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1b/8bafab7db23b0567ae9db749099b329d91e3b82bc6028b2050ba583e116c/yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727", size = 105957, upload-time = "2026-05-19T21:27:53.98Z" }, + { url = "https://files.pythonhosted.org/packages/7f/77/21030c2f8d21d21559719beafc772ada2014be933418ed1eaed9cc800e42/yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413", size = 97242, upload-time = "2026-05-19T21:27:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/f9ea63d1b6aa910a866e089d871fff6cbd49caab29b86b35221a62dfa0d5/yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9", size = 114719, upload-time = "2026-05-19T21:27:58.037Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/04e0ee98ac58a249ea7ed75223f5f901ba81a834f0b4921b58e5cec11757/yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2", size = 112140, upload-time = "2026-05-19T21:27:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/ad/0b9cc9f38a7324a7eb1d80f834eaa5283d17e9271bbda3186e598dddaeac/yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90", size = 106721, upload-time = "2026-05-19T21:28:02.586Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/a52478ebfc66ec989e085c6ae038b9f1bfa4190baa193b133b669c709e2f/yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643", size = 106478, upload-time = "2026-05-19T21:28:04.523Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/5508530fea8472542de00013ae280765fc938ee196fc4030c43a498afb36/yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac", size = 105423, upload-time = "2026-05-19T21:28:06.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/f1/ece28505e9628e8b756e11bb4f28864a17cc33b6b44db4d2aaf0622bf630/yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f", size = 99878, upload-time = "2026-05-19T21:28:08.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/52/fb5d34529b46dd84013afcfb30b8d2bc2832ed03d412736f577d604fa393/yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36", size = 114025, upload-time = "2026-05-19T21:28:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/ff9d31aaab024f7a251c0ed308a98ae29bf9f7dc344e78f28b1322431ca2/yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a", size = 105613, upload-time = "2026-05-19T21:28:12.784Z" }, + { url = "https://files.pythonhosted.org/packages/31/7d/3296fb3f3ecd52bf9ae6c16b0895c1cda7e9170a2083861552b683f70264/yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53", size = 111665, upload-time = "2026-05-19T21:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/77aa6ddaca4fbf42e45e675a465c43956dd40702281049975a2aa04eae59/yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342", size = 106914, upload-time = "2026-05-19T21:28:15.893Z" }, + { url = "https://files.pythonhosted.org/packages/d8/02/7611f22cd1d4ed7373eb7f9ee21fde1046edba2e7c0e514880d760352f48/yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4", size = 92658, upload-time = "2026-05-19T21:28:17.471Z" }, + { url = "https://files.pythonhosted.org/packages/91/00/671d0add79938127292839ae44506ce2f7fe8909c72d5a931864f128fd0b/yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39", size = 87887, upload-time = "2026-05-19T21:28:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] [[package]] name = "zipp" -version = "3.23.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] diff --git a/integrations/adk-middleware/python/pyproject.toml b/integrations/adk-middleware/python/pyproject.toml index c4b3ff334f..b6ca3b72e5 100644 --- a/integrations/adk-middleware/python/pyproject.toml +++ b/integrations/adk-middleware/python/pyproject.toml @@ -1,7 +1,8 @@ [project] name = "ag_ui_adk" description = "ADK Middleware for AG-UI Protocol" -version = "0.6.4" +version = "0.6.5" +license-files = ["LICENSE"] readme = "README.md" authors = [ { name = "Mark Fogle", email = "mark@contextable.com" } @@ -9,7 +10,31 @@ authors = [ requires-python = ">=3.10, <3.15" dependencies = [ "ag-ui-protocol>=0.1.15", - "aiohttp>=3.12.0", + # A2UI recovery/error-handling loop (OSS-158) — framework-agnostic validator + + # validate→retry loop shared with the LangGraph adapter and the a2ui-middleware + # paint gate. 0.0.3 carries the A2UIToolParams/guidelines API (OSS-248); published + # on PyPI, so no local source bridge is needed. + "ag-ui-a2ui-toolkit>=0.0.3", + # Google's A2UI Agent SDK (OSS-158): we reuse the two parts that are independent + # of its strict validator and safe to use with the client-supplied catalog as-is — + # catalog/prompt rendering (A2uiSchemaManager + catalog.render_as_llm_instructions, + # which bundles the common-types DEFINITIONS the injected catalog only references) + # and JSON healing (parse_and_fix). The strict A2uiValidator is NOT used: + # client-injected (zod-extracted) catalogs aren't strict-resolvable and a separate + # server catalog drifts, so validation stays with ag-ui-a2ui-toolkit (parity with + # the LangGraph A2UI demos); catalog conformance is a separate upstream item. + # We import ONLY the A2A-free subset (a2ui.schema / a2ui.parser / a2ui.basic_catalog); + # a2ui's top-level __init__ imports only .version and those subpackages import no + # `a2a` (enforced by tests/test_a2ui_import_hygiene.py), so we add NO a2a-sdk pin. + # a2ui-agent-sdk floors google-adk at >=1.28.1, so adding it raises our effective + # adk floor; we keep the full google-adk<3.0 range (do NOT cap at <2.0) so the + # package supports — and we test against — adk 2.x, which consumers use. adk 2.x + # pulls google-genai>=2.4 (2.8.x), whose async streaming calls aiohttp + # StreamReader.readline(max_line_length=) — only present in aiohttp >= 3.14.0 — so + # the aiohttp floor below is raised to keep live Gemini streaming working. (That's + # the real requirement; capping adk would just hide it.) + "a2ui-agent-sdk>=0.2.4,<0.3.0", + "aiohttp>=3.14.1", "asyncio>=3.4.3", "fastapi>=0.115.2", # Compatible with both google-adk 1.x and 2.x. @@ -28,7 +53,14 @@ dependencies = [ # because Workflow._run_impl rehydrates from new_message.parts only). # NodeInterruptedError (2.0-only) is a BaseException subclass, NOT an Exception, so # existing `except Exception:` blocks correctly let HITL interruption propagate. - "google-adk>=1.16.0,<3.0.0", + # Floor = a2ui-agent-sdk's own minimum (1.28.1), which is the TRUE resolver floor — + # the old 1.16.0 was dead weight (a2ui-agent-sdk overrode it). The middleware + # feature-detects the adk shape at runtime (_ADK_OVERRIDES_INVOCATION_ID, + # _adk_supports_streaming_fc_args) and version-gated tests skip where a feature is + # absent, so the whole [1.28.1, 3.0) range is genuinely supported. The lock pins + # 1.35.0 only as the dev/CI resolution — the version where the full suite runs + # unskipped — NOT a declared cap. + "google-adk>=1.28.1,<3.0.0", "pydantic>=2.11.7", # Primary SSE response implementation. Used unconditionally so the FastAPI # floor stays at >=0.115.2 (rather than the >=0.135.0 jump that would be diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/__init__.py b/integrations/adk-middleware/python/src/ag_ui_adk/__init__.py index dd8a676ef3..5c0bd4bce0 100644 --- a/integrations/adk-middleware/python/src/ag_ui_adk/__init__.py +++ b/integrations/adk-middleware/python/src/ag_ui_adk/__init__.py @@ -14,13 +14,26 @@ from .adk_agent import ADKAgent from .event_translator import EventTranslator, adk_events_to_messages from .session_manager import SessionManager, CONTEXT_STATE_KEY, INVOCATION_ID_STATE_KEY -from .endpoint import add_adk_fastapi_endpoint, create_adk_app +from .endpoint import ( + AgentResolver, + add_adk_fastapi_endpoint, + create_adk_app, + resolve_agent_from_message_history, +) from .config import PredictStateMapping, normalize_predict_state from .agui_toolset import AGUIToolset +from .a2ui_tool import ( + get_a2ui_tool, + A2UISubAgentTool, + plan_a2ui_injection, + is_auto_injected_a2ui_tool, +) __all__ = [ 'ADKAgent', + 'AgentResolver', 'add_adk_fastapi_endpoint', 'create_adk_app', + 'resolve_agent_from_message_history', 'EventTranslator', 'SessionManager', 'CONTEXT_STATE_KEY', @@ -29,6 +42,10 @@ 'normalize_predict_state', 'adk_events_to_messages', 'AGUIToolset', + 'get_a2ui_tool', + 'A2UISubAgentTool', + 'plan_a2ui_injection', + 'is_auto_injected_a2ui_tool', ] __version__ = "0.1.0" diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/a2ui_google_sdk.py b/integrations/adk-middleware/python/src/ag_ui_adk/a2ui_google_sdk.py new file mode 100644 index 0000000000..3c9c0f7168 --- /dev/null +++ b/integrations/adk-middleware/python/src/ag_ui_adk/a2ui_google_sdk.py @@ -0,0 +1,192 @@ +"""Google A2UI Agent SDK reuse for the ADK adapter (OSS-158). + +Reuses the two parts of Google's ``a2ui-agent-sdk`` that are independent of its +(strict, conformance-grade) validator and therefore safe to use with the +client-supplied catalog as-is: + + - ``render_catalog_instructions`` — the prompt/catalog rendering half of + ``A2uiSchemaManager.generate_system_prompt`` (``get_selected_catalog`` + + ``catalog.render_as_llm_instructions``). It serializes the catalog — including + the bundled v0.9 server-to-client envelope and the **common-types + definitions** the client-injected catalog only *references* — into a prompt + block, so the sub-agent sees what ``{path: …}`` bindings, ``{event: …}`` actions + and ``DynamicString`` actually are. It does NOT resolve ``$ref``\\s, so it + tolerates the client's (non-conformant) zod-extracted catalog; on any failure + it returns ``None`` and the caller falls back to the raw catalog text. + - ``heal_json_arg`` — ``parse_and_fix`` healing (smart quotes, trailing commas, + single-object wrap) for Gemini's free-form JSON-string ``components``/``data``. + +NOTE: the strict ``A2uiValidator`` is deliberately NOT used. The client-injected +catalog is a zod-extracted representation whose component-rooted ``$ref``\\s don't +resolve under a strict resolver, and authoring a separate conformant catalog +server-side drifts from what the client renders. So validation stays with the +toolkit's structural/lenient validator (parity with the LangGraph A2UI demos); +catalog conformance is tracked as a separate upstream (web_core / CopilotKit) item. + +IMPORT DISCIPLINE: imports ONLY the A2A-free subset of ``a2ui`` (``a2ui.schema``, +``a2ui.parser``, ``a2ui.basic_catalog``) — never ``a2ui.a2a``, ``a2ui.adk``, or +``a2a``. Enforced by ``tests/test_a2ui_import_hygiene.py``. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Optional + +from a2ui.parser.payload_fixer import parse_and_fix +from a2ui.schema.catalog import CatalogConfig +from a2ui.schema.catalog_provider import A2uiCatalogProvider +from a2ui.schema.common_modifiers import remove_strict_validation +from a2ui.schema.constants import VERSION_0_9 +from a2ui.schema.manager import A2uiSchemaManager + +logger = logging.getLogger("ag_ui_adk") + +# Server-to-client messages the adapter emits (toolkit ``assemble_ops`` → +# createSurface / updateComponents / updateDataModel). Pruning the rendered prompt +# catalog to these keeps it lean (drops deleteSurface). v0.9 ``server_to_client.json`` +# ``$defs`` keys. +_PROMPT_ALLOWED_MESSAGES: tuple[str, ...] = ( + "CreateSurfaceMessage", + "UpdateComponentsMessage", + "UpdateDataModelMessage", +) + +_DEFAULT_JSON_SCHEMA = "https://json-schema.org/draft/2020-12/schema" + + +class _InMemoryCatalogProvider(A2uiCatalogProvider): + """Serves a catalog dict already held in memory (the client-injected catalog).""" + + def __init__(self, schema: dict[str, Any]) -> None: + self._schema = schema + + def load(self) -> dict[str, Any]: + return self._schema + + +def normalize_catalog_dict( + source: Any, *, default_catalog_id: Optional[str] +) -> Optional[dict[str, Any]]: + """Coerce a host-supplied catalog into the inline v0.9 catalog dict shape + ``{"catalogId": str, "components": {name: json-schema}}``. + + Accepts a dict carrying ``components``; a JSON string of one; or the legacy + middleware ``A2UIComponentSchema[]`` list ``[{name, props/properties}]``. + ``catalogId`` is filled from ``default_catalog_id`` when absent. Returns + ``None`` for anything unusable (empty components, wrong types, unparseable + string). + """ + if isinstance(source, str): + try: + source = json.loads(source) + except (ValueError, TypeError): + return None + + if isinstance(source, dict): + components = source.get("components") + if not isinstance(components, dict) or not components: + return None + catalog_id = source.get("catalogId") or default_catalog_id + if not catalog_id: + return None + out = dict(source) + out["catalogId"] = catalog_id + out.setdefault("$schema", _DEFAULT_JSON_SCHEMA) + return out + + if isinstance(source, list): + components = {} + for item in source: + if not isinstance(item, dict): + continue + name = item.get("name") + if not isinstance(name, str) or not name: + continue + comp = item.get("properties") or item.get("props") or {} + components[name] = comp if isinstance(comp, dict) else {} + if not components or not default_catalog_id: + return None + return { + "$schema": _DEFAULT_JSON_SCHEMA, + "catalogId": default_catalog_id, + "components": components, + } + + return None + + +# Building the SchemaManager + rendering is non-trivial and the same catalog recurs +# across every run; memoize the rendered text per (canonical source, default id). +_RENDER_CACHE: dict[Any, Optional[str]] = {} + + +def render_catalog_instructions( + source: Any, *, default_catalog_id: Optional[str] +) -> Optional[str]: + """Render a host-supplied catalog into a prompt schema block via Google's + ``render_as_llm_instructions`` (server-to-client envelope + common-types + definitions + catalog components). + + This is render-only: it never resolves ``$ref``\\s, so it tolerates the client's + non-conformant zod-extracted catalog. Returns the rendered text, or ``None`` if + the catalog can't be normalized/built (the caller then falls back to the raw + catalog text — today's behavior). + """ + normalized = normalize_catalog_dict(source, default_catalog_id=default_catalog_id) + if normalized is None: + return None + try: + key = json.dumps(normalized, sort_keys=True) + except (TypeError, ValueError): + key = None + if key is not None and key in _RENDER_CACHE: + return _RENDER_CACHE[key] + + try: + manager = A2uiSchemaManager( + version=VERSION_0_9, + catalogs=[ + CatalogConfig( + name="ag-ui-adk-inline", + provider=_InMemoryCatalogProvider(normalized), + ) + ], + schema_modifiers=[remove_strict_validation], + ) + catalog = manager.get_selected_catalog().with_pruning( + allowed_messages=list(_PROMPT_ALLOWED_MESSAGES) + ) + instructions = catalog.render_as_llm_instructions() + except Exception as e: # noqa: BLE001 — render is best-effort; degrade to raw + logger.warning( + "Could not render the A2UI catalog via the SDK; falling back to the " + "raw catalog text in the prompt: %s", + e, + ) + instructions = None + + if key is not None: + _RENDER_CACHE[key] = instructions + return instructions + + +def heal_json_arg(value: str, *, expect: str) -> Any: + """Heal + parse Gemini's free-form JSON-string ``components``/``data`` arg via + the SDK's ``parse_and_fix`` (smart quotes, trailing commas, single-object→list + wrap). + + ``expect="list"`` returns the healed list; ``expect="dict"`` unwraps + ``parse_and_fix``'s single-element list back to the object it wrapped. Raises + ``ValueError`` on a hard parse failure or when ``expect="dict"`` but the payload + isn't a single JSON object. + """ + parsed = parse_and_fix(value) # always a list (single objects are wrapped) + if expect == "list": + return parsed + if isinstance(parsed, list) and len(parsed) == 1 and isinstance(parsed[0], dict): + return parsed[0] + if isinstance(parsed, dict): # defensive — parse_and_fix returns a list + return parsed + raise ValueError("expected a single JSON object") diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/a2ui_tool.py b/integrations/adk-middleware/python/src/ag_ui_adk/a2ui_tool.py new file mode 100644 index 0000000000..185329d3bb --- /dev/null +++ b/integrations/adk-middleware/python/src/ag_ui_adk/a2ui_tool.py @@ -0,0 +1,689 @@ +"""A2UI subagent tool factory for Google ADK agents (OSS-158). + +Thin adapter over ``ag-ui-a2ui-toolkit`` — the heavy lifting (op builders, +prompt assembly, history walkers, output envelope, and the validate→retry +recovery loop) lives in the toolkit. This adapter owns only the ADK-specific +glue: the ``BaseTool`` decorator, runtime/state access, model bind + invoke, +and — unlike LangGraph, which gets it free via langchain's ``astream_events`` — +explicit emission of the nested ``render_a2ui`` tool-call stream onto the run's +event queue so the middleware paint gate and client see progressive components. + +Mirrors the LangGraph ``get_a2ui_tools`` factory: it takes the shared +``A2UIToolParams`` so a new toolkit knob reaches this adapter with no signature +change. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from typing import Any, Optional + +from google.adk.models.llm_request import LlmRequest +from google.adk.tools import BaseTool +from google.genai import types + +from ag_ui.core import ( + EventType, + RunAgentInput, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallStartEvent, + ToolMessage, +) + +from ag_ui_a2ui_toolkit import ( + A2UI_OPERATIONS_KEY, + A2UIToolParams, + GENERATE_A2UI_TOOL_NAME, + RENDER_A2UI_TOOL_DEF, + build_a2ui_envelope, + prepare_a2ui_request, + resolve_a2ui_tool_params, + run_a2ui_generation_with_recovery, + wrap_error_envelope, +) + +from .a2ui_google_sdk import ( + heal_json_arg, + normalize_catalog_dict, + render_catalog_instructions, +) +from .event_translator import adk_events_to_messages +from .session_manager import CONTEXT_STATE_KEY + +logger = logging.getLogger("ag_ui_adk") + +# The inner structured-output tool the subagent is forced to call. +_RENDER_A2UI_NAME = "render_a2ui" + +#: Default name of the render tool the A2UI middleware injects as a frontend +#: tool (and that auto-injection drops, so the model calls ``generate_a2ui`` +#: directly instead of the bare render proxy). Sourced from the shared toolkit +#: contract so a rename upstream propagates here. +RENDER_A2UI_TOOL_NAME: str = RENDER_A2UI_TOOL_DEF["function"]["name"] + +#: Attribute marking a ``generate_a2ui`` tool this adapter auto-injected, so the +#: per-run wiring can tell its OWN injection apart from a dev-wired tool (which +#: always wins — see the USER-PREVAILS branch in ``plan_a2ui_injection``). +_A2UI_AUTOINJECT_ATTR = "_a2ui_auto_injected" + +# Description the A2UI middleware stamps on the schema context entry. MUST stay +# byte-identical to the middleware's exported A2UI_SCHEMA_CONTEXT_DESCRIPTION +# (middlewares/a2ui-middleware/src/index.ts) and the LangGraph adapter's copy — +# exact-equality match routes the schema into state["ag-ui"]["a2ui_schema"] +# instead of leaking it into generic context. Any drift silently misroutes it. +A2UI_SCHEMA_CONTEXT_DESCRIPTION = ( + "A2UI Component Schema — available components for generating UI surfaces. " + "Use these component names and properties when creating A2UI operations." +) + + +class A2UISubAgentTool(BaseTool): + """ADK tool that delegates A2UI surface generation to a forced-tool-call + subagent invocation and drives the toolkit recovery loop. + + The recovery loop (``run_a2ui_generation_with_recovery``) is synchronous; the + model stream and event-queue emission are async. ``run_async`` bridges the + two by running the loop on a worker thread (``asyncio.to_thread``) whose + synchronous ``invoke_subagent`` callback drives the async per-attempt stream + back on the run's event loop (``run_coroutine_threadsafe``). This keeps the + published toolkit untouched. + """ + + def __init__(self, cfg: dict): + super().__init__( + name=cfg["tool_name"], + description=cfg["tool_description"], + is_long_running=False, + ) + self._cfg = cfg + self._model = cfg["model"] + self._guidelines = cfg["guidelines"] + self._default_surface_id = cfg["default_surface_id"] + self._default_catalog_id = cfg["default_catalog_id"] + self._catalog = cfg["catalog"] + self._recovery = cfg["recovery"] + self._on_a2ui_attempt = cfg["on_a2ui_attempt"] + # Injected per-run by ADKAgent so the tool can emit nested tool-call + # events onto the active run's stream. + self.event_queue = None + + def for_run(self, event_queue: Any) -> "A2UISubAgentTool": + """Return a per-run clone bound to ``event_queue``. + + The construction-time tool is shared across concurrent runs; ADKAgent + swaps in this clone per run so each emits onto its own stream without + mutating the shared instance (mirrors the ClientProxyToolset swap). + """ + clone = A2UISubAgentTool(self._cfg) + clone.event_queue = event_queue + # Preserve the auto-inject marker so a per-run clone of an auto-injected + # tool is still recognized as auto-injected (parity with the dev-wired + # path, which carries no marker). + if getattr(self, _A2UI_AUTOINJECT_ATTR, False): + setattr(clone, _A2UI_AUTOINJECT_ATTR, True) + return clone + + def _get_declaration(self) -> Optional[types.FunctionDeclaration]: + """Declare ``generate_a2ui`` to the parent agent's planner.""" + return types.FunctionDeclaration( + name=self.name, + description=self.description, + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "intent": types.Schema( + type=types.Type.STRING, + description=( + "'create' to render a new surface, or 'update' to " + "modify a surface already rendered in this conversation." + ), + ), + "target_surface_id": types.Schema( + type=types.Type.STRING, + description="Surface id to modify when intent='update'.", + ), + "changes": types.Schema( + type=types.Type.STRING, + description="Natural-language changes to apply on update.", + ), + }, + ), + ) + + async def run_async(self, *, args: dict[str, Any], tool_context: Any) -> Any: + """Generate or edit an A2UI surface, returning the operations envelope.""" + intent = args.get("intent", "create") + target_surface_id = args.get("target_surface_id") + changes = args.get("changes") + + events = self._session_events(tool_context) + # AG-UI messages drive prepare_a2ui_request's prior-surface lookup + # (intent="update"); the genai conversation drives the subagent call. + messages = self._normalize_a2ui_tool_results(adk_events_to_messages(events)) + conversation = self._conversation_contents(events) + state, schema_value = self._state_view(tool_context) + + # Single catalog, client-sourced (no drift): prefer a host-supplied catalog + # param, else the middleware-injected schema. Render it via Google's + # render_as_llm_instructions (server-to-client envelope + common-types + # DEFINITIONS the injected catalog only references + components) into the + # prompt slot — richer than dumping the raw catalog. Render is best-effort + # and tolerates the client's non-conformant catalog; on failure we leave the + # raw schema text in the slot (today's behavior). + catalog_source = self._catalog or schema_value + instructions = render_catalog_instructions( + catalog_source, default_catalog_id=self._default_catalog_id + ) + if instructions is not None: + state.setdefault("ag-ui", {})["a2ui_schema"] = instructions + + prep = prepare_a2ui_request( + intent=intent, + target_surface_id=target_surface_id, + changes=changes, + messages=messages, + state=state, + guidelines=self._guidelines, + ) + if prep.get("error"): + return self._as_tool_return(wrap_error_envelope(prep["error"])) + + # Validate with the toolkit's structural/lenient validator against the SAME + # client catalog (membership; it does not strict-resolve $refs, so the + # non-conformant catalog is fine) — parity with the LangGraph/Declarative + # A2UI demos. None → pure structural validation. + validation_catalog = normalize_catalog_dict( + catalog_source, default_catalog_id=self._default_catalog_id + ) + + # One stable nested tool-call id, reused across every recovery attempt so + # the middleware/client swap the in-progress surface in place rather than + # stacking N tool calls. + surface_tool_call_id = f"a2ui-render-{uuid.uuid4().hex[:8]}" + loop = asyncio.get_running_loop() + + def _invoke_subagent(prompt: str, attempt: int) -> Optional[dict]: + future = asyncio.run_coroutine_threadsafe( + self._stream_one_attempt( + prompt, attempt, surface_tool_call_id, conversation + ), + loop, + ) + return future.result() + + def _build_envelope(generated: dict) -> str: + return build_a2ui_envelope( + args=generated, + is_update=prep["is_update"], + target_surface_id=target_surface_id, + prior=prep.get("prior"), + default_surface_id=self._default_surface_id, + default_catalog_id=self._default_catalog_id, + ) + + result = await asyncio.to_thread( + run_a2ui_generation_with_recovery, + base_prompt=prep["prompt"], + catalog=validation_catalog, + config=self._recovery, + invoke_subagent=_invoke_subagent, + build_envelope=_build_envelope, + on_attempt=self._on_a2ui_attempt, + ) + return self._as_tool_return(result["envelope"]) + + @staticmethod + def _as_tool_return(envelope: str) -> Any: + """Return the toolkit envelope in the shape the A2UI middleware can read. + + The toolkit hands back a JSON *string* (an ``a2ui_operations`` envelope on + success, or an ``a2ui_recovery_exhausted`` / ``error`` envelope otherwise). + ADK wraps a non-dict tool return as ``{"result": }``, which buries + those top-level keys so the middleware's ``tryParseA2UIOperations`` / + ``tryParseRecoveryFailure`` never see them — silently dropping the + hard-failure UI on exhaustion. Returning the parsed dict makes ADK + serialize the bare envelope JSON, matching how LangGraph delivers the + tool result. Valid surfaces still paint via the streamed render_a2ui + events; the middleware dedups the outer result against the inner surface + by tool-call id, so this does not double-paint. + """ + try: + parsed = json.loads(envelope) + except (ValueError, TypeError): + return envelope + return parsed if isinstance(parsed, dict) else envelope + + async def _stream_one_attempt( + self, prompt: str, attempt: int, tool_call_id: str, conversation: list + ) -> Optional[dict]: + """Invoke the subagent once, streaming its ``render_a2ui`` call onto the + run queue as nested ``TOOL_CALL_*`` events; return the generated args.""" + await self.event_queue.put( + ToolCallStartEvent( + type=EventType.TOOL_CALL_START, + tool_call_id=tool_call_id, + tool_call_name=_RENDER_A2UI_NAME, + ) + ) + + llm_request = self._build_llm_request(prompt, conversation) + final_args: Optional[dict] = None + async for response in self._model.generate_content_async( + llm_request, stream=True + ): + fc = self._extract_render_fc(response) + if fc is not None and getattr(fc, "args", None): + final_args = self._coerce_freeform_args(dict(fc.args)) + + # Atomic per-attempt paint: emit the complete args once. (Real per-delta + # streaming for Gemini-3 partial_args is layered on separately.) + if final_args is not None: + await self.event_queue.put( + ToolCallArgsEvent( + type=EventType.TOOL_CALL_ARGS, + tool_call_id=tool_call_id, + delta=json.dumps(final_args), + ) + ) + + await self.event_queue.put( + ToolCallEndEvent( + type=EventType.TOOL_CALL_END, + tool_call_id=tool_call_id, + ) + ) + return final_args + + def _build_llm_request(self, prompt: str, conversation: list) -> LlmRequest: + """Build the forced-``render_a2ui`` request, mirroring the LangGraph + adapter's ``[SystemMessage(prompt), *messages]``: the assembled subagent + prompt rides as ``system_instruction`` and the real conversation turns are + the request ``contents``. + """ + # Free-form payload schema (vs the shared RENDER_A2UI_TOOL_DEF's typed + # `components: array`): Gemini's function-calling fills typed args + # STRICTLY and emits empty `{}` for a property-less array-of-object. So we + # declare components/data as STRING — the model writes the full A2UI JSON + # free-form (guided by the system prompt), exactly the payload shape the + # ADK reference (a2ui rizzcharts) uses. _coerce_freeform_args parses it back + # into the structured dict the toolkit validates. The shared + # RENDER_A2UI_TOOL_DEF stays typed for LangGraph/OpenAI, which fill loose + # schemas from the prose; this string shape is ADK/Gemini-specific glue. + declaration = types.FunctionDeclaration( + name=_RENDER_A2UI_NAME, + description=RENDER_A2UI_TOOL_DEF["function"]["description"], + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "surfaceId": types.Schema( + type=types.Type.STRING, + description="Unique surface identifier.", + ), + "components": types.Schema( + type=types.Type.STRING, + description=( + "The A2UI v0.9 component array as a JSON string, e.g. " + '\'[{"id":"root","component":"Text","text":"Hi"}]\'. ' + "The root component must have id 'root'." + ), + ), + "data": types.Schema( + type=types.Type.STRING, + description=( + "Optional surface data model as a JSON string, e.g. " + "'{\"items\":[...]}'. Use '{}' when there is none." + ), + ), + }, + required=["surfaceId", "components"], + ), + ) + config = types.GenerateContentConfig( + system_instruction=prompt, + tools=[types.Tool(function_declarations=[declaration])], + tool_config=types.ToolConfig( + function_calling_config=types.FunctionCallingConfig( + mode=types.FunctionCallingConfigMode.ANY, + allowed_function_names=[_RENDER_A2UI_NAME], + ) + ), + ) + # Fall back to carrying the prompt as the user turn only when there is no + # conversation (defensive — a real run always has the triggering message). + contents = ( + list(conversation) + if conversation + else [types.Content(role="user", parts=[types.Part(text=prompt)])] + ) + return LlmRequest( + model=getattr(self._model, "model", None), + contents=contents, + config=config, + ) + + @staticmethod + def _coerce_freeform_args(args: dict) -> dict: + """Heal + parse the free-form JSON-string ``components``/``data`` Gemini + returns into the structured list/dict the toolkit validates and emits. + + Uses the Google SDK's ``parse_and_fix`` (smart quotes, trailing commas, + single-object→list wrap) rather than a bare ``json.loads`` — Gemini's + free-form JSON often needs that healing. A model may also return them + already-structured (inline) — those are left untouched. On a hard parse + failure the value is left as the original string, so the toolkit validator + rejects it (non-list / non-dict) and the recovery loop retries rather than + committing garbage.""" + for key, expect in (("components", "list"), ("data", "dict")): + value = args.get(key) + if isinstance(value, str): + try: + args[key] = heal_json_arg(value, expect=expect) + except ValueError: + pass + return args + + @staticmethod + def _extract_render_fc(response: Any) -> Any: + """Return the ``render_a2ui`` FunctionCall part of an LlmResponse, if any.""" + content = getattr(response, "content", None) + if content is None: + return None + for part in getattr(content, "parts", None) or []: + fc = getattr(part, "function_call", None) + if fc is not None and getattr(fc, "name", None) == _RENDER_A2UI_NAME: + return fc + return None + + @staticmethod + def _session_events(tool_context: Any) -> list: + """The ADK session's event list, accessed defensively across context shapes.""" + session = getattr(tool_context, "session", None) + if session is None: + ctx = getattr(tool_context, "_invocation_context", None) + session = getattr(ctx, "session", None) + return list(getattr(session, "events", None) or []) + + @staticmethod + def _extract_envelope(content: str) -> Optional[dict]: + """Pull an ``a2ui_operations`` envelope out of an ADK tool-result string, + unwrapping the layers ADK adds. + + ``run_async`` now returns the envelope as a dict, which the translator + ``json.dumps`` straight into the canonical ``{"a2ui_operations": ...}`` + string. Older sessions (or a string-returning tool) can still have the + envelope nested under ``result`` (ADK wraps a string tool return as + ``{"result": }``) and/or double-encoded — so peel up to a few + layers until an envelope dict surfaces, staying backward compatible.""" + payload: Any = content + for _ in range(3): + if isinstance(payload, str): + try: + payload = json.loads(payload) + except (ValueError, TypeError): + return None + if isinstance(payload, dict): + if A2UI_OPERATIONS_KEY in payload: + return payload + inner = payload.get("result") + if isinstance(inner, (str, dict)): + payload = inner + continue + return None + return None + + @classmethod + def _normalize_a2ui_tool_results(cls, messages: list) -> list: + """Rewrite A2UI tool-result messages so their content is the canonical + envelope JSON string the toolkit's ``find_prior_surface`` expects (it does + ``json.loads(content)`` and looks for ``a2ui_operations``). Non-A2UI tool + results pass through unchanged.""" + out: list = [] + for msg in messages: + role = getattr(msg, "role", None) + content = getattr(msg, "content", None) + if role == "tool" and isinstance(content, str): + envelope = cls._extract_envelope(content) + if envelope is not None: + msg = ToolMessage( + id=getattr(msg, "id", None) or str(uuid.uuid4()), + role="tool", + content=json.dumps(envelope), + tool_call_id=getattr(msg, "tool_call_id", None) or "", + ) + out.append(msg) + return out + + @staticmethod + def _conversation_contents(events: list) -> list: + """The conversational genai ``Content`` turns to forward to the subagent. + + Mirrors LangGraph's ``*messages``: user/model text turns in order, skipping + partial chunks and the tool-call/function-response machinery (the in-flight + generate_a2ui call and any tool results) so the subagent sees the request, + not the plumbing.""" + contents: list = [] + for ev in events: + if getattr(ev, "partial", False): + continue + content = getattr(ev, "content", None) + parts = getattr(content, "parts", None) + if not parts: + continue + has_text = any(getattr(p, "text", None) for p in parts) + has_calls = ( + bool(ev.get_function_calls()) + if hasattr(ev, "get_function_calls") + else False + ) + has_responses = ( + bool(ev.get_function_responses()) + if hasattr(ev, "get_function_responses") + else False + ) + if has_text and not has_calls and not has_responses: + contents.append(content) + return contents + + def _state_view(self, tool_context: Any) -> tuple[dict, Optional[str]]: + """Remap ADK session context into the ``state['ag-ui']`` shape the + toolkit's ``build_context_prompt`` expects, and return the raw A2UI schema + value alongside it. + + The ADK middleware stores AG-UI context (a flat ``{description, value}`` + list) under ``CONTEXT_STATE_KEY``. The A2UI schema entry (matched by its + exact description) is routed to ``ag-ui.a2ui_schema`` so it renders as + the "Available Components" section rather than generic context — mirrors + the LangGraph adapter's remap. The raw schema value is also returned so + ``run_async`` can try to build a Google SDK catalog from it (the hybrid + path overrides ``a2ui_schema`` with the rendered schema block). + """ + state = getattr(tool_context, "state", None) + raw_context: Any = [] + if state is not None: + try: + raw_context = state.get(CONTEXT_STATE_KEY) or [] + except Exception: + raw_context = [] + + regular_context: list = [] + schema_value: Optional[str] = None + for entry in raw_context: + if isinstance(entry, dict): + desc = entry.get("description", "") + value = entry.get("value", "") + else: + desc = getattr(entry, "description", "") + value = getattr(entry, "value", "") + if desc == A2UI_SCHEMA_CONTEXT_DESCRIPTION: + schema_value = value + else: + regular_context.append(entry) + + ag_ui: dict = {"context": regular_context} + if schema_value is not None: + ag_ui["a2ui_schema"] = schema_value + return {"ag-ui": ag_ui}, schema_value + + +def get_a2ui_tool(params: A2UIToolParams) -> BaseTool: + """Build an ADK tool that delegates A2UI surface generation to a subagent. + + Args: + params: Shared ``A2UIToolParams`` (``model`` + behavior knobs). The + toolkit owns the shape and fills defaults via + ``resolve_a2ui_tool_params``; every framework adapter takes this + exact params type, so a new knob reaches this adapter with no + signature change. ``model`` is the ADK ``BaseLlm`` the subagent + invokes for structured A2UI output. + + Returns: + An ADK ``BaseTool`` ready to add to an ``LlmAgent``'s ``tools`` list. + """ + cfg = resolve_a2ui_tool_params(params) + return A2UISubAgentTool(cfg) + + +def is_auto_injected_a2ui_tool(tool: Any) -> bool: + """True if ``tool`` is a ``generate_a2ui`` this adapter auto-injected.""" + return getattr(tool, _A2UI_AUTOINJECT_ATTR, False) is True + + +# --------------------------------------------------------------------------- +# Auto-inject decision +# --------------------------------------------------------------------------- + + +def _resolve_catalog_from_context(input: RunAgentInput) -> Optional[dict]: + """Pull the A2UI catalog the middleware stamped into ``RunAgentInput.context``. + + Matches the schema entry by its exact description (the same byte-identical + contract ``_state_view`` uses) and parses its JSON value. Returns ``None`` + when absent/unparseable — auto-injection then proceeds with a ``None`` + catalog (the tool also resolves the catalog from live session state at + run time, so this is parity glue with the Strands adapter rather than the + sole catalog source). + """ + for entry in input.context or []: + # Entries are pydantic Context models on the validated path, but this is + # exported API — tolerate dict-shaped entries too (mirrors the adapter's + # own context normalization). + if isinstance(entry, dict): + description = entry.get("description") + value = entry.get("value") + else: + description = getattr(entry, "description", None) + value = getattr(entry, "value", None) + if description != A2UI_SCHEMA_CONTEXT_DESCRIPTION: + continue + if not value: + logger.warning( + "A2UI schema context entry has an empty value; " + "catalog-aware recovery disabled." + ) + continue + if isinstance(value, dict): + return value + try: + parsed = json.loads(value) + except (TypeError, ValueError) as err: + logger.warning( + "A2UI schema context entry present but unparseable; " + "catalog-aware recovery disabled: %s", + err, + ) + continue + if isinstance(parsed, dict): + return parsed + logger.warning( + "A2UI schema context entry is valid JSON but not an object; " + "catalog-aware recovery disabled (got %s)", + type(parsed).__name__, + ) + return None + + +def plan_a2ui_injection( + *, + model: Any, + input: RunAgentInput, + existing_tool_names: list, + config: Optional[dict] = None, + log: Optional[logging.Logger] = None, +) -> Optional[dict]: + """Decide whether to auto-inject ``generate_a2ui`` for this run. + + Mirrors the Strands adapter's ``plan_a2ui_injection`` (and the LangGraph + "no injectA2UITool, no injection" contract): + + 1. Off unless the runtime forwarded ``injectA2UITool`` (``True``, or a + string naming the injected RENDER tool to drop) OR a backend + ``config["inject_a2ui_tool"]`` override. + 2. USER PREVAILS — a dev-wired ``generate_a2ui`` (already in + ``existing_tool_names``) is never double-injected. + 3. No inferable model (e.g. a non-LlmAgent orchestrator root) -> warn + skip. + 4. Otherwise build the tool (threading the catalog + guidelines) and report + the injected render proxy to drop from the frontend tools. + + ``model`` is the already-resolved framework model the sub-agent invokes (the + ADKAgent passes the root ``LlmAgent.canonical_model``) — kept out of this + pure decision so it stays framework-agnostic. + + Returns ``{"tool", "tool_name", "drop_tool_names", "catalog"}`` or ``None``. + """ + log = log or logger + config = config or {} + + # `forwarded_props` is Any on the wire; tolerate non-dict shapes. + forwarded = ( + input.forwarded_props if isinstance(input.forwarded_props, dict) else {} + ) + flag = forwarded.get("injectA2UITool") + if flag is None: + # Nullish fallback, mirroring the TS adapter's `??`: an explicit runtime + # `injectA2UITool: false` disables injection even when the backend + # config opts in. + flag = config.get("inject_a2ui_tool") + if not flag: + return None + + tool_name = GENERATE_A2UI_TOOL_NAME + # USER PREVAILS: explicit dev wiring wins — never double-inject. + if tool_name in existing_tool_names: + return None + + if model is None: + log.warning( + "A2UI tool injection requested but no model could be inferred from " + "the agent (a non-LlmAgent orchestrator root has no model). Skipping " + "auto-injection — wire get_a2ui_tool() onto an LlmAgent explicitly." + ) + return None + + render_tool_name = flag if isinstance(flag, str) else RENDER_A2UI_TOOL_NAME + # Nullish (not falsy) fallback, mirroring the TS adapter's `??`. + catalog = config.get("catalog") + if catalog is None: + catalog = _resolve_catalog_from_context(input) + + tool = get_a2ui_tool( + { + "model": model, + "tool_name": tool_name, + "catalog": catalog, + "default_catalog_id": config.get("default_catalog_id"), + "guidelines": config.get("guidelines"), + "recovery": config.get("recovery"), + } + ) + setattr(tool, _A2UI_AUTOINJECT_ATTR, True) + + return { + "tool": tool, + "tool_name": tool_name, + "drop_tool_names": [render_tool_name], + "catalog": catalog, + } diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/adk_agent.py b/integrations/adk-middleware/python/src/ag_ui_adk/adk_agent.py index 37dc612fba..8f0c53a45b 100644 --- a/integrations/adk-middleware/python/src/ag_ui_adk/adk_agent.py +++ b/integrations/adk-middleware/python/src/ag_ui_adk/adk_agent.py @@ -71,6 +71,7 @@ }) from .execution_state import ExecutionState from .client_proxy_toolset import ClientProxyToolset +from .a2ui_tool import A2UISubAgentTool, plan_a2ui_injection from .config import PredictStateMapping from .request_state_service import RequestStateSessionService from .utils.converters import convert_message_content_to_parts @@ -79,29 +80,6 @@ logger = logging.getLogger(__name__) -def _unbind_agui_toolsets_recursive(agent: Any) -> None: - """Walk an agent tree and unbind every ``AGUIToolset`` placeholder. - - Counterpart to ``_update_agent_tools_recursive`` (defined inside - ``ADKAgent._start_background_execution``). Run from - ``_run_adk_in_background``'s ``finally`` block so each run starts with - placeholders in their construction-time state, avoiding cross-run - delegate leakage. - - Tolerant of non-LlmAgent nodes (skip silently) and agents without a - ``tools`` attribute. Catches per-toolset exceptions so one bad - placeholder doesn't break cleanup for the rest of the tree. - """ - if isinstance(agent, LlmAgent) and hasattr(agent, "tools"): - for tool in agent.tools or []: - if isinstance(tool, AGUIToolset): - try: - tool.unbind() - except Exception: - # Best-effort cleanup; never raise out of unbind. - pass - for sub_agent in getattr(agent, "sub_agents", None) or []: - _unbind_agui_toolsets_recursive(sub_agent) class _HitlDeferringQueue(asyncio.Queue): """``asyncio.Queue`` that defers HITL ``ToolCallEndEvent``s. @@ -234,6 +212,9 @@ def __init__( use_thread_id_as_session_id: bool = False, capabilities: Optional[Dict[str, Any]] = None, + + # A2UI auto-injection + a2ui: Optional[Dict[str, Any]] = None, ): """Initialize the ADKAgent. @@ -295,6 +276,26 @@ def __init__( clients to discover agent features before initiating a run. Use the "custom" key for application-specific feature flags (e.g., {"custom": {"predictiveChips": True, "suggestedQuestions": True}}). + a2ui: A2UI auto-injection config — everything A2UI-related in one place + (mirrors ``StrandsAgentConfig.a2ui``). When the CopilotKit runtime + forwards ``injectA2UITool`` (or ``a2ui["inject_a2ui_tool"]`` opts in + on a host that doesn't), the adapter injects a ``generate_a2ui`` + recovery tool onto the root ``LlmAgent`` and infers the sub-agent + model from that agent's ``canonical_model`` — no manual + ``get_a2ui_tool()`` wiring needed. Keys: + + - ``inject_a2ui_tool`` — opt in without the runtime flag; a string + also names the injected render tool to drop from the frontend + tools. + - ``default_catalog_id`` — catalog id stamped into auto-injected + surfaces (must match the host renderer's catalog). + - ``guidelines`` — ``{"composition_guide": ...}`` teaches the + sub-agent the catalog's components; required for a real model to + compose them. + - ``catalog`` — inline catalog override for catalog-aware recovery + (otherwise resolved from the run's schema context / session state). + - ``recovery`` — recovery loop config (camelCase keys per the shared + toolkit contract, e.g. ``{"maxAttempts": 5}``). Note: If delete_session_on_cleanup=False but save_session_to_memory_on_cleanup=True, sessions will accumulate in SessionService but still be saved to memory on cleanup. @@ -411,6 +412,9 @@ def __init__( # Message snapshot configuration self._emit_messages_snapshot = emit_messages_snapshot self._capabilities = capabilities + # A2UI auto-injection config (mirrors StrandsAgentConfig.a2ui). None + # disables auto-injection unless the runtime forwards injectA2UITool. + self._a2ui_config = a2ui # Streaming function call arguments (Gemini 3+ via Vertex AI) if streaming_function_call_arguments and not self._adk_supports_streaming_fc_args(): @@ -635,7 +639,7 @@ def from_app( app = App( name="my_assistant", - root_agent=Agent(name="assistant", model="gemini-2.5-flash", ...), + root_agent=Agent(name="assistant", model="gemini-3.5-flash", ...), plugins=[LoggingPlugin()], ) agent = ADKAgent.from_app(app, user_id="demo_user") @@ -1630,22 +1634,211 @@ async def _handle_tool_result_submission( return try: - # Remove tool calls from pending list and track which ones we processed - processed_tool_ids = [] user_id = self._get_user_id(input) - for tool_result in tool_results: - tool_call_id = tool_result['message'].tool_call_id - has_pending = await self._has_pending_tool_calls(thread_id, user_id) - if has_pending: - # Remove from pending tool calls now that we're processing it - await self._remove_pending_tool_call(thread_id, tool_call_id, user_id) - processed_tool_ids.append(tool_call_id) + # Snapshot the turn's pending long-running calls BEFORE marking any + # of the arriving results answered. ``still_pending_after`` is what + # would remain outstanding once this submission's results apply — + # used by both the guard immediately below and the "all-results" + # buffer gate further down. + pending_before = set( + await self._get_pending_tool_call_ids(thread_id, user_id) or [] + ) + arriving_ids = {tr["message"].tool_call_id for tr in tool_results} + still_pending_after = pending_before - arriving_ids + + # ``pending_tool_calls`` is thread-global, so a leaked/orphaned entry + # from an earlier turn — e.g. a call the model re-issued under a fresh + # id, orphaning the original (observed on main) — would otherwise gate + # EVERY future submission forever: the model silently stops resuming. + # Scope the gate to THIS model turn: a leftover pending call only + # blocks the resume if it shares the arriving results' invocation_id, + # i.e. it is a genuine sibling long-running call of the same turn. + # pending/arriving ids are client-facing while session FunctionCall + # events store ADK-persisted ids, so apply the LRO id remap before + # each lookup. If the backend session or the arriving turn can't be + # resolved, fall back to the unscoped set (preserves the multi-LRO + # gate rather than risking a premature resume). + if still_pending_after: + gate_backend_session_id = self._get_backend_session_id( + thread_id, user_id + ) + gate_session = ( + await self._session_manager.get_session( + gate_backend_session_id, app_name, user_id + ) + if gate_backend_session_id + else None + ) + if gate_session is not None: + gate_remap = await self._get_lro_id_remap( + gate_backend_session_id, app_name, user_id + ) + arriving_invocations = { + self._find_function_call_invocation_id( + gate_session, gate_remap.get(aid, aid) + ) + for aid in arriving_ids + } + arriving_invocations.discard(None) + if arriving_invocations: + same_turn = { + pid + for pid in still_pending_after + if self._find_function_call_invocation_id( + gate_session, gate_remap.get(pid, pid) + ) + in arriving_invocations + } + orphaned = still_pending_after - same_turn + if orphaned: + logger.warning( + "Thread %s: ignoring %d pending tool call(s) %s " + "outside the arriving turn (invocation(s) %s) — " + "likely leaked/orphaned pending state; they will " + "not gate this resume.", + thread_id, + len(orphaned), + sorted(orphaned), + sorted(arriving_invocations), + ) + still_pending_after = same_turn + + # Guard: a trailing user/system message accompanied these results + # while OTHER long-running calls from the same turn are still + # unanswered. We can neither resume nor silently absorb it: + # - Resuming replays a turn whose function-call parts outnumber its + # function-response parts, which the provider 400s (see the + # "All-results" gate below). + # - The pre-fix behavior forwarded that under-answered turn anyway; + # it marked the message processed *before* the model ran, so the + # 400 surfaced as an opaque provider error AND the user's message + # was silently dropped (never re-delivered). + # There is no correct middleware-only merge — the message is wedged + # between unanswered calls and may even be directed at the open + # widget rather than the conversation; that is a client-side concern + # (answer/cancel the pending call before sending text). So fail + # loudly and mutate NOTHING: leave pending_tool_calls and every + # message untouched, returning a clear, dedicated error so the client + # can resolve or cancel the outstanding call(s) and resubmit. Once + # all of the turn's results arrive together the message rides along + # normally (``still_pending_after`` is then empty). A trailing + # message with no other call pending is the legitimate + # "FunctionResponse + follow-up message in one turn" case and is not + # gated. See PR_multi_lro_resume_gating.md ("user message while a + # call is still pending") and google/adk-python discussion #2739. + if still_pending_after and trailing_messages: + logger.warning( + "Rejecting tool-result submission for thread %s: a trailing " + "message arrived while %d long-running call(s) from the same " + "turn are still pending %s. The client must submit their " + "results (or cancel them) before sending a new message.", + thread_id, + len(still_pending_after), + sorted(still_pending_after), + ) + yield RunErrorEvent( + type=EventType.RUN_ERROR, + message=( + "Cannot start a new message while long-running tool " + f"call(s) {sorted(still_pending_after)} from the current " + "turn are still pending. Submit their results or cancel " + "them before sending another message." + ), + code="PENDING_TOOL_CALLS", + ) + return - # Since all tools are long-running, all tool results are standalone - # and should start new executions with the tool results + # "All-results" gate for a turn with multiple long-running calls. + # The client returns each long-running result independently (an + # instant frontend tool resolves before a HITL one, etc.). Resuming + # the model on a partial set would replay a turn whose + # function-call parts outnumber its function-response parts, which + # the provider rejects (Gemini: "number of function response parts + # [must] equal the number of function call parts of the function + # call turn"). So if any long-running call from this turn is still + # unanswered, persist what we just received and stop here without + # resuming; the buffered responses are merged with the remaining + # ones (ADK's _rearrange_events_for_latest_function_response) once + # the final result arrives. (The trailing-message variant of this + # situation was already rejected by the guard above, so reaching here + # with calls still pending implies there was no trailing message.) + # Reuse the turn-scoped snapshot from above. A fresh global re-read + # here would resurrect leaked/orphaned entries the scope check + # already excluded, re-introducing the buffer-forever stall. + if still_pending_after: + logger.info( + "Buffering %d tool result(s) for thread %s; %d long-running " + "call(s) from the same turn still pending %s — deferring " + "model resume until the turn is complete.", + len(tool_results), + thread_id, + len(still_pending_after), + sorted(still_pending_after), + ) + # Persist FIRST, then advance bookkeeping only on success. Until + # the append lands, the arriving calls are still pending and + # their messages still unprocessed, so a persistence failure + # surfaces a dedicated RUN_ERROR and mutates NOTHING — the client + # can simply resubmit. (Doing the pending-removal / mark-processed + # before persisting could leave the turn unable to ever balance + # while the result was silently dropped.) + try: + await self._buffer_tool_results(input, tool_results) + except Exception as buffer_error: + logger.error( + "Failed to buffer tool result(s) for thread %s: %s", + thread_id, + buffer_error, + exc_info=True, + ) + yield RunErrorEvent( + type=EventType.RUN_ERROR, + message=( + "Failed to persist tool result(s) while waiting for " + f"the rest of the turn: {buffer_error}. No state was " + "changed; resubmit the result(s)." + ), + code="TOOL_RESULT_BUFFER_ERROR", + ) + return + # Persisted: now it is safe to remove the arriving calls from the + # pending set and mark their messages processed so they aren't + # re-extracted when the turn finally resumes. + for tool_result in tool_results: + tool_call_id = tool_result["message"].tool_call_id + if await self._has_pending_tool_calls(thread_id, user_id): + await self._remove_pending_tool_call( + thread_id, tool_call_id, user_id + ) + buffered_message_ids = self._collect_message_ids( + [tr["message"] for tr in tool_results] + ) + if buffered_message_ids: + self._session_manager.mark_messages_processed( + app_name, thread_id, buffered_message_ids + ) + yield RunStartedEvent( + type=EventType.RUN_STARTED, + thread_id=thread_id, + run_id=input.run_id, + ) + yield RunFinishedEvent( + type=EventType.RUN_FINISHED, + thread_id=thread_id, + run_id=input.run_id, + ) + return + + # All of this turn's long-running calls are answered: remove them + # from the pending set, then resume the model with the results. Use + # trailing_messages if provided, otherwise fall back to + # candidate_messages. + for tool_result in tool_results: + tool_call_id = tool_result["message"].tool_call_id + if await self._has_pending_tool_calls(thread_id, user_id): + await self._remove_pending_tool_call(thread_id, tool_call_id, user_id) - # Use trailing_messages if provided, otherwise fall back to candidate_messages message_batch = trailing_messages if trailing_messages else (candidate_messages if include_message_batch else None) async for event in self._start_new_execution( @@ -1663,6 +1856,150 @@ async def _handle_tool_result_submission( code="TOOL_RESULT_PROCESSING_ERROR" ) + def _build_function_response_parts( + self, + tool_results: List[Dict], + lro_id_remap: Dict[str, str], + ) -> List[types.Part]: + """Convert AG-UI tool-result messages into ADK FunctionResponse parts. + + Shared by the resume path (``_run_async_impl``) and the buffer path + (``_buffer_tool_results``). Applies the client->ADK LRO id remap and + parses each result's content as JSON when possible, falling back to + wrapping the raw string; empty content becomes an empty success. + """ + function_response_parts: List[types.Part] = [] + for tool_result in tool_results: + tool_call_id = tool_result["message"].tool_call_id + # Apply LRO ID remap: convert client-facing ID to ADK-persisted ID. + tool_call_id = lro_id_remap.get(tool_call_id, tool_call_id) + content = tool_result["message"].content + + logger.debug( + f"Received tool result for call {tool_call_id}: " + f"content='{content}', type={type(content)}" + ) + + # Parse content - try JSON first, fall back to plain string. + try: + if content and content.strip(): + try: + result = json.loads(content) + except json.JSONDecodeError: + # Not valid JSON - treat as plain string result. + result = {"success": True, "result": content, "status": "completed"} + logger.debug( + f"Tool result for {tool_call_id} is plain string, " + "wrapped in result object" + ) + else: + # Handle empty content as a success with empty result. + result = {"success": True, "result": None, "status": "completed"} + logger.warning( + f"Empty tool result content for tool call {tool_call_id}, " + "using empty success result" + ) + except Exception as e: + # Handle any other error. + result = {"success": True, "result": str(content) if content else None, "status": "completed"} + logger.warning( + f"Error processing tool result for {tool_call_id}: {e}, " + "using string fallback" + ) + + function_response_parts.append( + types.Part( + function_response=types.FunctionResponse( + id=tool_call_id, + name=tool_result["tool_name"], + response=result, + ) + ) + ) + return function_response_parts + + async def _buffer_tool_results( + self, + input: RunAgentInput, + tool_results: List[Dict], + ) -> None: + """Persist FunctionResponse(s) for resolved long-running calls WITHOUT + resuming the model. + + Used by the "all-results" gate in ``_handle_tool_result_submission`` + when a model turn emitted multiple long-running tool calls and only some + have results so far. The responses are appended to the ADK session — + tagged with the originating FunctionCall's invocation_id, exactly like + the resume path — so they persist and are merged with the remaining + responses when the turn completes, instead of running the model on a + partially-answered turn. + """ + user_id = self._get_user_id(input) + app_name = self._get_app_name(input) + backend_session_id = self._get_backend_session_id(input.thread_id, user_id) + session = ( + await self._session_manager.get_session( + backend_session_id, app_name, user_id + ) + if backend_session_id + else None + ) + if session is None: + # Raise rather than silently no-op. The caller (the buffer gate) + # advances pending/processed bookkeeping only AFTER this returns, so + # a silent drop here would wedge the turn — it could never balance — + # while the result vanished. Surfacing it lets the caller emit a + # RUN_ERROR and leave state untouched for a clean resubmit. + raise RuntimeError( + f"Cannot buffer tool results for thread {input.thread_id}: " + "no backend session." + ) + + # Same client->ADK id remap the resume path uses: with SSE streaming the + # partial and final events can carry different function-call ids. + lro_id_remap = await self._get_lro_id_remap( + backend_session_id, app_name, user_id + ) + + # Mirror the resume path's parsing (JSON when possible, else wrap the + # raw string; empty content becomes an empty success). + function_response_parts = self._build_function_response_parts( + tool_results, lro_id_remap + ) + + # Tag with the originating FunctionCall event's invocation_id so ADK + # pairs this response with its call (and DatabaseSessionService receives + # a non-null invocation_id — see #957). + invocation_id = ( + self._find_function_call_invocation_id( + session, function_response_parts[0].function_response.id + ) + or input.run_id + ) + await self._session_manager._session_service.append_event( + session, + Event( + timestamp=time.time(), + author="user", + content=types.Content(parts=function_response_parts, role="user"), + invocation_id=invocation_id, + ), + ) + # Mirror the resume path (see the append_event calls in _run_async_impl): + # drop the cached session snapshot so a later read in the same execution + # observes this just-appended FunctionResponse rather than a stale + # pre-append copy. + self._session_manager.invalidate_session( + backend_session_id, app_name, user_id + ) + logger.debug( + "Buffered %d FunctionResponse(s) for thread %s (invocation_id=%s) " + "without resuming the model.", + len(function_response_parts), + input.thread_id, + invocation_id, + ) + async def _extract_tool_results( self, input: RunAgentInput, @@ -1818,6 +2155,7 @@ async def _start_new_execution( user_id = self._get_user_id(input) exec_key = (input.thread_id, user_id) + session_cache_token = self._session_manager.start_session_read_cache() try: # Emit RUN_STARTED @@ -1866,6 +2204,14 @@ async def _start_new_execution( app_name = self._get_app_name(input) logger.debug(f"About to iterate over _stream_events for execution {execution.thread_id}") + # Track whether a terminal event already flowed through the queue. + # The background producer surfaces failures as a RUN_ERROR data + # event (see _run_adk_in_background) rather than by raising, so the + # loop below completes normally and would otherwise fall through to + # the unconditional RUN_FINISHED. The AG-UI spec allows at most one + # terminal event per run, and @ag-ui/client's state machine rejects + # a RUN_FINISHED that follows a RUN_ERROR. See issue #1892. + run_errored = False async for event in self._stream_events(execution): # HITL pending_tool_calls persistence happens on the producer # side via _HitlDeferringQueue: HITL TOOL_CALL_END events are @@ -1886,19 +2232,29 @@ async def _start_new_execution( app_name, execution.thread_id, [event.tool_call_id] ) + if isinstance(event, RunErrorEvent): + run_errored = True + logger.debug(f"Yielding event: {type(event).__name__}") yield event logger.debug(f"Finished iterating over _stream_events for execution {execution.thread_id}") logger.debug(f"Finished streaming events for execution {execution.thread_id}") - # Emit RUN_FINISHED - logger.debug(f"Emitting RUN_FINISHED for thread {input.thread_id}, run {input.run_id}") - yield RunFinishedEvent( - type=EventType.RUN_FINISHED, - thread_id=input.thread_id, - run_id=input.run_id - ) + # Emit RUN_FINISHED only if the run did not already terminate with a + # RUN_ERROR from the queue path (issue #1892). + if run_errored: + logger.debug( + f"Skipping RUN_FINISHED for thread {input.thread_id}, run {input.run_id}: " + "run already terminated with RUN_ERROR" + ) + else: + logger.debug(f"Emitting RUN_FINISHED for thread {input.thread_id}, run {input.run_id}") + yield RunFinishedEvent( + type=EventType.RUN_FINISHED, + thread_id=input.thread_id, + run_id=input.run_id + ) except Exception as e: logger.error(f"Error in new execution: {e}", exc_info=True) @@ -1908,16 +2264,23 @@ async def _start_new_execution( code="EXECUTION_ERROR" ) finally: - # Clean up execution if complete and no pending tool calls (HITL scenarios) - async with self._execution_lock: - if exec_key in self._active_executions: - execution = self._active_executions[exec_key] - execution.is_complete = True - - # Check if session has pending tool calls before cleanup - has_pending = await self._has_pending_tool_calls(input.thread_id, user_id) - if not has_pending: - del self._active_executions[exec_key] + try: + # The ADK runner can mutate session state without going + # through SessionManager, so the parent context's pre-run read + # cache is stale by the time this cleanup guard runs. + self._session_manager.disable_session_read_cache() + # Clean up execution if complete and no pending tool calls (HITL scenarios) + async with self._execution_lock: + if exec_key in self._active_executions: + execution = self._active_executions[exec_key] + execution.is_complete = True + + # Check if session has pending tool calls before cleanup + has_pending = await self._has_pending_tool_calls(input.thread_id, user_id) + if not has_pending: + del self._active_executions[exec_key] + finally: + self._session_manager.stop_session_read_cache(session_cache_token) @staticmethod def _collect_output_schema_agent_names(agent: Any, result: Optional[set] = None) -> set: @@ -1936,6 +2299,11 @@ def _collect_output_schema_agent_names(agent: Any, result: Optional[set] = None) if isinstance(sub_agents, (list, tuple)): for sub in sub_agents: ADKAgent._collect_output_schema_agent_names(sub, result) + graph = getattr(agent, 'graph', None) + graph_nodes = getattr(graph, 'nodes', None) + if isinstance(graph_nodes, (list, tuple)): + for node in graph_nodes: + ADKAgent._collect_output_schema_agent_names(node, result) return result @staticmethod @@ -2055,27 +2423,96 @@ def instruction_provider_wrapper_sync(*args, **kwargs): adk_agent.instruction = new_instruction + # A2UI auto-injection (mirrors the Strands adapter). When the runtime + # forwards ``injectA2UITool`` (or the host opts in via the ``a2ui`` + # config), inject a ``generate_a2ui`` recovery tool onto the root + # ``LlmAgent``, infer the sub-agent model from its ``canonical_model``, + # and drop the injected ``render_a2ui`` frontend proxy so the model calls + # generate_a2ui directly. Best-effort: a failure here logs and the run + # proceeds without A2UI rather than crashing the turn. + a2ui_plan: Optional[dict] = None + frontend_tools = input.tools + try: + forwarded = ( + input.forwarded_props + if isinstance(input.forwarded_props, dict) + else {} + ) + flag = forwarded.get("injectA2UITool") + if flag is None and self._a2ui_config: + flag = self._a2ui_config.get("inject_a2ui_tool") + if flag: + # Resolve the model + existing tool names from the per-run root + # only when injection is actually requested — avoids touching the + # LLM registry on every unrelated run. A non-LlmAgent root has no + # inferable model; pass None so the planner warns and skips. + root_model = None + existing_tool_names: list[str] = [] + if isinstance(adk_agent, LlmAgent): + try: + root_model = adk_agent.canonical_model + except Exception as e: # noqa: BLE001 — degrade, don't crash + logger.warning( + "A2UI auto-inject: could not resolve the agent's " + "model; skipping injection: %s", + e, + ) + existing_tool_names = [ + name + for tool in (adk_agent.tools or []) + if (name := getattr(tool, "name", None)) + ] + a2ui_plan = plan_a2ui_injection( + model=root_model, + input=input, + existing_tool_names=existing_tool_names, + config=self._a2ui_config, + log=logger, + ) + if a2ui_plan: + drop = set(a2ui_plan["drop_tool_names"]) + frontend_tools = [ + t + for t in (input.tools or []) + if ( + t.get("name") + if isinstance(t, dict) + else getattr(t, "name", None) + ) + not in drop + ] + except Exception as e: # noqa: BLE001 — never crash the turn here + logger.error( + "A2UI auto-injection planning failed; running without A2UI for " + "this turn: %s", + e, + exc_info=True, + ) + a2ui_plan = None + frontend_tools = input.tools + # Log tools available from frontend - tool_names = [t.name for t in input.tools] if input.tools else [] + tool_names = [t.name for t in frontend_tools] if frontend_tools else [] logger.info(f"Tools from frontend: {tool_names}") # Track all ClientProxyToolset instances for collecting accumulated predictive state client_proxy_toolsets: list[ClientProxyToolset] = [] def _update_agent_tools_recursive(agent: Any) -> None: - """Bind a ``ClientProxyToolset`` to every ``AGUIToolset`` placeholder - in the agent tree. - - Pre-2026-05 (ADK 1.x): we replaced the placeholder wholesale - (``agent.tools = [..., ClientProxyToolset(...)]``). ADK 1.x resolved - ``get_tools()`` lazily on each run so the replacement was visible. - - Post-2026-05 (ADK 2.0, ag-ui#1389): ``Runner.__init__`` eagerly - caches ``get_tools()`` results, so replacing the toolset object - leaves the Runner pointing at the stale placeholder. We now keep - the placeholder instance and bind a fresh delegate to it via - ``AGUIToolset.bind(...)`` — same object identity, dynamic tool - list, compatible with both ADK majors. + """Replace every ``AGUIToolset`` placeholder with a per-run + ``ClientProxyToolset`` in the agent tree. + + The placeholder carries no client info; this builds a concrete + ``ClientProxyToolset`` from ``input.tools`` (with this run's + ``event_queue``) and swaps it into the per-run agent's ``tools`` + list. Because ``_shallow_copy_agent_tree`` gave this agent its own + ``tools`` list and the construction-time placeholder is never + mutated, concurrent runs are fully isolated. (An earlier + ``AGUIToolset.bind()`` delegation stored the per-run toolset on the + shared placeholder and was not concurrency-safe; replacement restores + per-run isolation. ADK 2.0 GA reads ``agent.tools`` fresh per + invocation, so the swap is picked up — see + ``tests/test_agui_toolset_concurrency.py``.) Args: agent: Agent instance to process recursively. @@ -2085,36 +2522,55 @@ def _update_agent_tools_recursive(agent: Any) -> None: if isinstance(agent, LlmAgent) and hasattr(agent, "tools"): tool_count = len(agent.tools) if agent.tools else 0 - logger.info(f"[TOOL_SETUP] Agent {agent.name} has {tool_count} tools before binding") + logger.info(f"[TOOL_SETUP] Agent {agent.name} has {tool_count} tools before replacement") + new_tools: list[ToolUnion] = [] for tool in agent.tools: if isinstance(tool, AGUIToolset): logger.info( f"[TOOL_SETUP] Agent {agent.name}: Found AGUIToolset with " - f"filter={tool.tool_filter}; binding ClientProxyToolset delegate" + f"filter={tool.tool_filter}; replacing with per-run ClientProxyToolset" ) proxy_toolset = ClientProxyToolset( - ag_ui_tools=input.tools, + ag_ui_tools=frontend_tools, event_queue=event_queue, tool_filter=tool.tool_filter, tool_name_prefix=tool.tool_name_prefix, predict_state=self._predict_state, ) client_proxy_toolsets.append(proxy_toolset) - # Bind delegate to the placeholder. Object identity - # of `tool` in agent.tools is preserved — critical - # for ADK 2.0's eager Runner.__init__ tool cache - # (ag-ui#1389). For ADK 1.x this is functionally - # equivalent to the previous replace-the-object - # approach: get_tools() forwards to the delegate - # either way. - tool.bind(proxy_toolset) - logger.info( - f"[TOOL_SETUP] Bound ClientProxyToolset delegate to AGUIToolset " - f"for agent {agent.name}" - ) + # Swap the placeholder for a fresh per-run + # ClientProxyToolset in THIS run's tools list. + # _shallow_copy_agent_tree gave this agent its own list, + # so concurrent runs never share a proxy (each carries + # its own input.tools + event_queue) and the + # construction-time AGUIToolset is never mutated. + tool = proxy_toolset + elif isinstance(tool, A2UISubAgentTool): + # Per-run swap: give this run's A2UI subagent tool its own + # event_queue so it can emit the nested render_a2ui + # tool-call stream onto THIS run's stream — without mutating + # the shared construction-time instance (concurrency-safe, + # mirrors the ClientProxyToolset replacement above). + tool = tool.for_run(event_queue) + new_tools.append(tool) + + # Auto-inject the A2UI ``generate_a2ui`` tool onto the ROOT + # LlmAgent only (the planning agent — mirrors the Strands + # adapter's single-agent injection). ``plan_a2ui_injection`` + # already honored USER-PREVAILS (a dev-wired generate_a2ui makes + # the plan None), so this never double-adds. Bind this run's + # event_queue via ``for_run`` exactly like the dev-wired branch. + if a2ui_plan is not None and agent is adk_agent: + new_tools.append(a2ui_plan["tool"].for_run(event_queue)) + logger.info( + f"[TOOL_SETUP] Agent {agent.name}: auto-injected " + f"'{a2ui_plan['tool_name']}' (dropped frontend " + f"{a2ui_plan['drop_tool_names']})" + ) - logger.info(f"[TOOL_SETUP] Agent {agent.name} tool binding complete") + agent.tools = new_tools + logger.info(f"[TOOL_SETUP] Agent {agent.name} now has {len(new_tools)} tools after replacement") # Recursively process sub-agents if they exist # This handles SequentialAgent, LoopAgent, and other composite agents @@ -2326,43 +2782,9 @@ async def _run_adk_in_background( if active_tool_results and user_message: # We have BOTH tool results AND a user message # Add FunctionResponse as a separate event to the session, then send user message - function_response_parts = [] - for tool_msg in active_tool_results: - tool_call_id = tool_msg['message'].tool_call_id - # Apply LRO ID remap: convert client-facing ID to ADK-persisted ID - tool_call_id = lro_id_remap.get(tool_call_id, tool_call_id) - content = tool_msg['message'].content - - # Debug: Log the actual tool message content we received - logger.debug(f"Received tool result for call {tool_call_id}: content='{content}', type={type(content)}") - - # Parse content - try JSON first, fall back to plain string - try: - if content and content.strip(): - # Try to parse as JSON first - try: - result = json.loads(content) - except json.JSONDecodeError: - # Not valid JSON - treat as plain string result - result = {"success": True, "result": content, "status": "completed"} - logger.debug(f"Tool result for {tool_call_id} is plain string, wrapped in result object") - else: - # Handle empty content as a success with empty result - result = {"success": True, "result": None, "status": "completed"} - logger.warning(f"Empty tool result content for tool call {tool_call_id}, using empty success result") - except Exception as e: - # Handle any other error - result = {"success": True, "result": str(content) if content else None, "status": "completed"} - logger.warning(f"Error processing tool result for {tool_call_id}: {e}, using string fallback") - - updated_function_response_part = types.Part( - function_response=types.FunctionResponse( - id=tool_call_id, - name=tool_msg["tool_name"], - response=result, - ) - ) - function_response_parts.append(updated_function_response_part) + function_response_parts = self._build_function_response_parts( + active_tool_results, lro_id_remap + ) # Add FunctionResponse as separate event to session # (session was already obtained from _ensure_session_exists above) @@ -2379,6 +2801,9 @@ async def _run_adk_in_background( logger.debug(f"Creating FunctionResponse event with invocation_id={resume_invocation_id}") await self._session_manager._session_service.append_event(session, function_response_event) + self._session_manager.invalidate_session( + backend_session_id, app_name, user_id + ) # Mark user messages from message_batch as processed if message_batch: @@ -2391,44 +2816,32 @@ async def _run_adk_in_background( elif active_tool_results: # Tool results WITHOUT user message - send FunctionResponse alone - function_response_parts = [] - for tool_msg in active_tool_results: - tool_call_id = tool_msg['message'].tool_call_id - # Apply LRO ID remap: convert client-facing ID to ADK-persisted ID - tool_call_id = lro_id_remap.get(tool_call_id, tool_call_id) - content = tool_msg['message'].content - - logger.debug(f"Received tool result for call {tool_call_id}: content='{content}', type={type(content)}") - - # Parse content - try JSON first, fall back to plain string - try: - if content and content.strip(): - # Try to parse as JSON first - try: - result = json.loads(content) - except json.JSONDecodeError: - # Not valid JSON - treat as plain string result - result = {"success": True, "result": content, "status": "completed"} - logger.debug(f"Tool result for {tool_call_id} is plain string, wrapped in result object") - else: - result = {"success": True, "result": None, "status": "completed"} - logger.warning(f"Empty tool result content for tool call {tool_call_id}, using empty success result") - except Exception as e: - # Handle any other error - result = {"success": True, "result": str(content) if content else None, "status": "completed"} - logger.warning(f"Error processing tool result for {tool_call_id}: {e}, using string fallback") - - updated_function_response_part = types.Part( - function_response=types.FunctionResponse( - id=tool_call_id, - name=tool_msg["tool_name"], - response=result, - ) - ) - function_response_parts.append(updated_function_response_part) + function_response_parts = self._build_function_response_parts( + active_tool_results, lro_id_remap + ) function_response_content = types.Content(parts=function_response_parts, role='user') + # ag-ui#1839: HITL confirmation responses must be the LAST + # user event in the session so ADK's + # _RequestConfirmationLlmRequestProcessor — which reverse-scans + # for the last user event and returns on the first one lacking + # function_responses — can re-execute the original tool. The + # pre-append + empty-text-placeholder workaround below makes the + # placeholder the trailing user event, which blinds that + # processor (the FunctionResponse it needs sits one event + # earlier). ``adk_request_confirmation`` is a long-running tool + # that PAUSES (not ends) the invocation, so routing it through + # the direct ``new_message`` path does NOT hit the + # ``end_of_agent`` early-return in _resolve_invocation_id's + # resume path that motivated the #1534 workaround for + # turn-ending client/frontend tools. + is_confirmation_resume = any( + part.function_response is not None + and part.function_response.name == 'adk_request_confirmation' + for part in function_response_parts + ) + # ag-ui#1669: the #1534 pre-append workaround is correct for # LlmAgent roots (and composite orchestrators built from # LlmAgent), but breaks ADK 2.0 ``Workflow`` roots. Workflows @@ -2443,6 +2856,7 @@ async def _run_adk_in_background( _ADK_OVERRIDES_INVOCATION_ID and self._is_adk_resumable() and not self._root_agent_is_workflow() + and not is_confirmation_resume ): # ADK with _resolve_invocation_id (~1.28+) routing, non-Workflow root: # @@ -2491,6 +2905,9 @@ async def _run_adk_in_background( await self._session_manager._session_service.append_event( session, function_response_event ) + self._session_manager.invalidate_session( + backend_session_id, app_name, user_id + ) # Placeholder trigger: a single empty text part. _append_new_message_to_session # requires at least one part, and _get_function_responses_from_content returns @@ -2564,8 +2981,12 @@ async def _run_adk_in_background( # Share the translator's emitted IDs set with proxy toolsets so # ClientProxyTool can skip emission when the translator already handled it. + # Also share the translator's name→[partial IDs] ledger so the proxy can + # suppress the cross-path twin when SSE streaming gives the partial event + # and the proxy invocation different IDs (#1168) — matched by tool name. for toolset in client_proxy_toolsets: toolset._translator_emitted_tool_call_ids = event_translator.emitted_tool_call_ids + toolset._translator_lro_emitted_ids_by_name = event_translator.lro_emitted_ids_by_name try: # Session was already obtained from _ensure_session_exists above @@ -2575,8 +2996,21 @@ async def _run_adk_in_background( # If sending FunctionResponse, look for the original FunctionCall in session if active_tool_results: - tool_call_id = active_tool_results[0]['message'].tool_call_id - logger.info(f"[SESSION_DEBUG] Looking for FunctionCall with id={tool_call_id}") + # Session FunctionCall events store the ADK-persisted id, so + # apply the same client->ADK remap the resume path uses below + # before searching. Without it this check reports "NOT FOUND" + # (and the misleading "ADK will fail") on every SSE-remapped + # resume — including ones that actually succeed. + client_tool_call_id = active_tool_results[0]['message'].tool_call_id + tool_call_id = lro_id_remap.get(client_tool_call_id, client_tool_call_id) + logger.info( + f"[SESSION_DEBUG] Looking for FunctionCall with id={tool_call_id}" + + ( + f" (remapped from client id {client_tool_call_id})" + if tool_call_id != client_tool_call_id + else "" + ) + ) # Log all function calls in session for debugging all_function_call_ids = [] @@ -2637,6 +3071,7 @@ async def _run_adk_in_background( logger.debug(f"Calling runner.run_async with session_id={backend_session_id}, has_message={new_message is not None}") + self._session_manager.disable_session_read_cache() async for adk_event in runner.run_async(**run_kwargs): event_invocation_id = getattr(adk_event, 'invocation_id', None) event_author = getattr(adk_event, 'author', 'unknown') @@ -2997,21 +3432,6 @@ async def _run_adk_in_background( close_error, ) - # Unbind every AGUIToolset in the agent tree so the next run on - # the same agent starts from a clean slate. Without this, a stale - # ClientProxyToolset (whose event_queue belongs to the previous - # run) would still satisfy AGUIToolset.get_tools() — usually - # harmless because the next run rebinds before tool invocation, - # but worth defensive cleanup to avoid surprising debug output - # if get_tools() is queried mid-cleanup. - try: - _unbind_agui_toolsets_recursive(adk_agent) - except Exception as unbind_error: - logger.debug( - "Error while unbinding AGUIToolset for thread %s: %s", - input.thread_id, - unbind_error, - ) # Flush any LRO ID remap captured during the runner loop. This # runs after the runner has been closed, so the # ``update_session_state`` write can't trip OCC against ADK's diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/agui_toolset.py b/integrations/adk-middleware/python/src/ag_ui_adk/agui_toolset.py index 89860b2689..916a12528b 100644 --- a/integrations/adk-middleware/python/src/ag_ui_adk/agui_toolset.py +++ b/integrations/adk-middleware/python/src/ag_ui_adk/agui_toolset.py @@ -1,62 +1,43 @@ -"""AGUIToolset — a frontend-tool placeholder that delegates to ClientProxyToolset. +"""AGUIToolset — a placeholder that ``ADKAgent`` replaces per run. -Why a placeholder + delegation? -------------------------------- AG-UI integrations declare an ``AGUIToolset`` on their ADK agents at agent- construction time, before any AG-UI run is in flight. At run-time :class:`~ag_ui_adk.adk_agent.ADKAgent` knows the actual frontend tools the -client supplied (``input.tools``) and wires up a concrete -:class:`~ag_ui_adk.client_proxy_toolset.ClientProxyToolset` that proxies tool -calls back to the client over the AG-UI stream. - -In ADK 1.x the middleware simply replaced the placeholder in -``agent.tools = [..., ClientProxyToolset(...)]`` once it knew the client tools. -That worked because ADK 1.x resolved toolsets lazily — every call to -``runner.run_async`` re-walked ``agent.tools`` and called ``get_tools()`` fresh. - -ADK 2.0 (GA 2026-05-19) changed this: ``Runner.__init__`` eagerly walks -``agent.tools`` and caches whatever each toolset returns from ``get_tools()``. -The original "swap the toolset" approach now races the cache — the Runner may -already hold a reference to the placeholder when ``_update_agent_tools_recursive`` -replaces it, leaving the LLM with an empty tool list (ag-ui#1389). - -Fix: keep the placeholder instance, give it a ``bind()`` method that attaches -a concrete delegate, and have ``get_tools()`` forward to the delegate. Object -identity is preserved end-to-end so ADK 2.0's cache stays valid; the delegate -can be replaced or unbound between runs without invalidating any ADK state. - -Compat: this preserves the 1.x behavior 1:1. If ``bind()`` is never called -(misconfiguration), ``get_tools()`` returns ``[]`` instead of raising, so the -LLM sees zero frontend tools rather than blowing up at agent construction -time inside ADK's own toolset-discovery flow. The original -``NotImplementedError`` path is retained for an explicit ``unbind() + -get_tools()`` sequence so misuse is still detectable in tests. +client supplied (``input.tools``) and substitutes a concrete +:class:`~ag_ui_adk.client_proxy_toolset.ClientProxyToolset` for this placeholder +in the *per-run copy* of the agent tree +(``ADKAgent._start_background_execution._update_agent_tools_recursive``). + +The substitution happens on a per-run shallow copy whose ``tools`` list is its +own, so every concurrent run gets its own ``ClientProxyToolset`` (its own +``input.tools`` and ``event_queue``); the construction-time placeholder is never +mutated and never shared across runs. (An earlier ``bind()``-delegation design +stored the per-run toolset on this shared instance and was not concurrency-safe; +per-run replacement restores isolation. ADK 2.0 GA reads ``agent.tools`` fresh +per invocation, so the replacement is picked up.) + +If ``get_tools()`` is called on the placeholder itself, the substitution did not +happen (a misconfiguration — e.g. the agent was run without being wrapped by +``ADKAgent``), so it raises rather than silently exposing zero tools. """ from __future__ import annotations -from typing import List, Optional, TYPE_CHECKING, Union +from typing import List, Optional, Union from google.adk.tools.base_tool import BaseTool from google.adk.tools.base_toolset import BaseToolset, ToolPredicate from google.adk.agents.readonly_context import ReadonlyContext -if TYPE_CHECKING: - from .client_proxy_toolset import ClientProxyToolset - class AGUIToolset(BaseToolset): - """Frontend-tool placeholder that delegates to a bound ``ClientProxyToolset``. + """Frontend-tool placeholder, replaced per-run by a ``ClientProxyToolset``. Construction-time: declared on the ADK agent with ``tool_filter`` and - ``tool_name_prefix`` (no client info yet). - - Run-time: :class:`~ag_ui_adk.adk_agent.ADKAgent._start_background_execution` - builds a :class:`~ag_ui_adk.client_proxy_toolset.ClientProxyToolset` using - ``input.tools`` and calls :meth:`bind` on this instance. - - The Runner can be created either before or after ``bind()`` — both orders - work because ``get_tools()`` is delegated rather than memoized. + ``tool_name_prefix`` (no client info yet). Run-time: + :meth:`~ag_ui_adk.adk_agent.ADKAgent._start_background_execution` swaps it for + a :class:`~ag_ui_adk.client_proxy_toolset.ClientProxyToolset` built from + ``input.tools`` in the per-run agent copy. """ def __init__( @@ -68,89 +49,32 @@ def __init__( """Initialize the toolset. Args: - tool_filter: Filter to apply to tools — forwarded to the bound - ``ClientProxyToolset`` at delegation time. + tool_filter: Filter to apply to tools — forwarded to the per-run + ``ClientProxyToolset`` at substitution time. tool_name_prefix: Prefix to prepend to tool names — also forwarded - to the bound delegate. + to the per-run ``ClientProxyToolset``. """ - # BaseToolset.__init__ initializes the cache attributes - # (``_use_invocation_cache``, ``_cached_invocation_id``, - # ``_cached_prefixed_tools``) on both ADK 1.x and 2.0. ADK 2.0's - # ``llm_agent.py:185`` eagerly reads ``_use_invocation_cache`` and - # silently drops the toolset when missing — required now that bind() - # delegation preserves the instance across the run (#1389). + # BaseToolset.__init__ initializes ADK 2.0's toolset cache attributes + # (``_use_invocation_cache`` et al.). A no-op on ADK 1.x. Kept so the + # placeholder is a well-formed BaseToolset even though it is normally + # replaced before ADK ever resolves it. super().__init__(tool_filter=tool_filter, tool_name_prefix=tool_name_prefix) self.tool_filter = tool_filter self.tool_name_prefix = tool_name_prefix - # The bound delegate. Replaced by `bind()` once the run-time - # ClientProxyToolset is constructed. `None` means no client tools - # have been wired up yet (legitimate at agent-construction time - # but a misconfiguration if get_tools() is called and `_unbound_raises` - # is True). - self._delegate: Optional["ClientProxyToolset"] = None - # When True, `get_tools()` without a bound delegate raises (legacy - # 1.x behavior preserved for explicit-misuse detection). When False - # (the default), unbound get_tools() returns []. Toggled by tests - # that want to verify the placeholder is never reached in production. - self._unbound_raises: bool = False - - def bind(self, delegate: "ClientProxyToolset") -> None: - """Bind a concrete delegate that ``get_tools()`` will forward to. - - Called by :func:`~ag_ui_adk.adk_agent._update_agent_tools_recursive` - once the run-time :class:`~ag_ui_adk.client_proxy_toolset.ClientProxyToolset` - has been constructed from ``input.tools``. - - Subsequent calls overwrite the binding — this is intentional so that - a single ``AGUIToolset`` instance can be reused across runs with - different client tool sets (e.g. multi-turn conversations). - - Args: - delegate: The ``ClientProxyToolset`` that should serve ``get_tools()`` - calls for the lifetime of the current run. - """ - self._delegate = delegate - - def unbind(self) -> None: - """Detach the currently-bound delegate. - - Called by ``_run_adk_in_background`` cleanup paths so a stale - ``ClientProxyToolset`` reference doesn't linger on the placeholder - between runs. After ``unbind()`` the placeholder reverts to its - construction-time state — safe to ``bind()`` again next run. - """ - self._delegate = None async def get_tools( self, readonly_context: Optional[ReadonlyContext] = None, ) -> list[BaseTool]: - """Return tools from the bound delegate, or ``[]`` if unbound. - - This is called by ADK's tool-discovery flow — in 1.x lazily on each - ``run_async``, in 2.0 eagerly during ``Runner.__init__``. Either way - the bound delegate forwards the actual tool list. - - Args: - readonly_context: Context used to filter tools available to the - agent. Forwarded verbatim to the delegate. + """Placeholders are replaced before use; reaching this is a misconfiguration. - Returns: - list[BaseTool]: The delegate's tool list, or ``[]`` if no - delegate is bound. Raises ``NotImplementedError`` when unbound - only if ``_unbound_raises`` is set (legacy 1.x parity for tests). + Raises: + NotImplementedError: always — the run-time ``ClientProxyToolset`` + substitution in ``ADKAgent`` did not happen (e.g. the agent was run + without being wrapped by ``ADKAgent``). """ - if self._delegate is None: - if self._unbound_raises: - raise NotImplementedError( - "AGUIToolset is a placeholder and must be bound to a " - "ClientProxyToolset before use (call AGUIToolset.bind(...) " - "or wrap the agent with ADKAgent which does it for you)." - ) - # Construction-time / between-runs: no delegate. Return empty - # rather than raising — ADK 2.0's eager Runner cache otherwise - # crashes agent registration. The actual binding happens before - # the LLM is invoked, so this empty list is never observed by - # the LLM in production paths. - return [] - return await self._delegate.get_tools(readonly_context) + raise NotImplementedError( + "AGUIToolset is a placeholder and must be replaced with a " + "ClientProxyToolset before use (wrap the agent with ADKAgent, which " + "does this per run)." + ) diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_tool.py b/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_tool.py index b7ca5b58c7..ec23a760d9 100644 --- a/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_tool.py +++ b/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_tool.py @@ -41,11 +41,23 @@ "type", "format", "description", "nullable", "enum", "example", "items", "properties", "required", "default", "title", "pattern", "minimum", "maximum", "minItems", "maxItems", "minLength", "maxLength", - "minProperties", "maxProperties", "additionalProperties", "anyOf", + "minProperties", "maxProperties", "anyOf", "ref", "defs", "propertyOrdering", }) +# Keys that ``google.genai.types.Schema`` accepts as model fields (so the +# allowlist above keeps them) but the Gemini ``generateContent`` function-calling +# API rejects with a 400 ("Unknown name ... Cannot find field"). They must be +# stripped explicitly. ``zod-to-json-schema`` (used by CopilotKit / AG-UI +# frontend tools) emits ``additionalProperties: false`` on every object, so any +# client-supplied tool trips this — manifesting as a RUN_ERROR and no tool call +# reaching the UI. See ag-ui-protocol/ag-ui HITL dojo "nothing renders" report. +_GENAI_REJECTED_SCHEMA_KEYS = frozenset({ + "additionalProperties", "additional_properties", +}) + + def _clean_schema_for_genai(schema: Any) -> Any: """Recursively clean a JSON Schema dict for google.genai.types.Schema. @@ -63,6 +75,10 @@ def _clean_schema_for_genai(schema: Any) -> Any: # Always strip $-prefixed keys if k.startswith("$"): continue + # Strip keys the Gemini API rejects even though genai.Schema accepts + # them as model fields (e.g. additionalProperties from zod schemas). + if k in _GENAI_REJECTED_SCHEMA_KEYS: + continue # Map examples -> example (preserve first element as opaque data) if k == "examples" and isinstance(v, list) and v: result["example"] = v[0] @@ -112,6 +128,8 @@ def __init__( emitted_tool_call_ids: Optional[Set[str]] = None, translator_emitted_tool_call_ids: Optional[Set[str]] = None, long_running_tool_ids: Optional[Set[str]] = None, + translator_lro_emitted_ids_by_name: Optional[Dict[str, List[str]]] = None, + lro_finalized_by_name: Optional[Dict[str, int]] = None, ): """Initialize the client proxy tool. @@ -151,6 +169,8 @@ def __init__( self._emitted_tool_call_ids = emitted_tool_call_ids if emitted_tool_call_ids is not None else set() self._translator_emitted_tool_call_ids = translator_emitted_tool_call_ids if translator_emitted_tool_call_ids is not None else set() self._long_running_tool_ids = long_running_tool_ids if long_running_tool_ids is not None else set() + self._translator_lro_emitted_ids_by_name = translator_lro_emitted_ids_by_name if translator_lro_emitted_ids_by_name is not None else {} + self._lro_finalized_by_name = lro_finalized_by_name if lro_finalized_by_name is not None else {} # Create dynamic function with proper parameter signatures for ADK inspection # This allows ADK to extract parameters from user requests correctly @@ -276,6 +296,23 @@ async def _execute_proxy_tool(self, args: Dict[str, Any], tool_context: Any) -> logger.debug(f"Skipping TOOL_CALL emission for {tool_call_id} — already emitted by EventTranslator") return None + # Cross-path twin suppression: under SSE streaming the translator + # emits this long-running call from the *partial* event with one ID + # and ADK then invokes this proxy with a *different* ID (#1168), so + # the ID guard above can't recognize them as the same logical call — + # the dojo then renders the HITL card twice. Match this invocation to + # an already-emitted partial by tool name, positionally (FIFO), so + # genuinely parallel same-name calls each still emit once. + emitted_partials = self._translator_lro_emitted_ids_by_name.get(self.name, []) + finalized = self._lro_finalized_by_name.get(self.name, 0) + if finalized < len(emitted_partials): + self._lro_finalized_by_name[self.name] = finalized + 1 + logger.debug( + f"Skipping proxy TOOL_CALL emission for '{self.name}' — twin of " + f"streamed partial {emitted_partials[finalized]} (proxy id {tool_call_id})" + ) + return None + # Check if this tool has predictive state configuration # Emit PredictState CustomEvent BEFORE TOOL_CALL_START (once per tool name) mappings_for_tool = [m for m in self.predict_state_mappings if m.tool == self.name] diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_toolset.py b/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_toolset.py index 60265cfc28..9d9ae8cbd4 100644 --- a/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_toolset.py +++ b/integrations/adk-middleware/python/src/ag_ui_adk/client_proxy_toolset.py @@ -66,6 +66,17 @@ def __init__( # DatabaseSessionService stale-marker race) for in-stream backend # tool calls. Assigned externally; see issue #1652. self._long_running_tool_ids: set[str] = set() + # The EventTranslator's name→[partial-emitted IDs] ledger. Assigned + # externally after the translator is created. Under SSE streaming the + # translator emits a long-running call from the partial event with one + # ID and ADK then invokes this proxy with a *different* ID (#1168), so + # the ID-based guard above can't tell they're the same logical call. + # The proxy matches its invocation to an already-emitted partial by name + # (positionally) to avoid emitting a duplicate TOOL_CALL trio. + self._translator_lro_emitted_ids_by_name: dict = {} + # Per-name count of proxy invocations already matched to a translator + # partial (shared across this toolset's proxies for the run). + self._lro_finalized_by_name: dict = {} logger.info(f"Initialized ClientProxyToolset with {len(ag_ui_tools)} tools (all long-running)") @@ -101,6 +112,8 @@ async def get_tools( emitted_tool_call_ids=self._emitted_tool_call_ids, translator_emitted_tool_call_ids=self._translator_emitted_tool_call_ids, long_running_tool_ids=self._long_running_tool_ids, + translator_lro_emitted_ids_by_name=self._translator_lro_emitted_ids_by_name, + lro_finalized_by_name=self._lro_finalized_by_name, ) proxy_tools.append(proxy_tool) logger.info(f"[GET_TOOLS] Created proxy tool for '{ag_ui_tool.name}' (long-running)") diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/endpoint.py b/integrations/adk-middleware/python/src/ag_ui_adk/endpoint.py index 226e6f729f..d182da896e 100644 --- a/integrations/adk-middleware/python/src/ag_ui_adk/endpoint.py +++ b/integrations/adk-middleware/python/src/ag_ui_adk/endpoint.py @@ -5,9 +5,17 @@ import logging import uuid import warnings -from typing import Any, Callable, Coroutine, List, Optional - -from ag_ui.core import EventType, RunAgentInput, RunErrorEvent +from collections.abc import Sequence +from typing import Any, Awaitable, Callable, Coroutine, List, Mapping, Optional + +from ag_ui.core import ( + AssistantMessage, + EventType, + Message, + RunAgentInput, + RunErrorEvent, + ToolMessage, +) from ag_ui.encoder import EventEncoder from fastapi import APIRouter, FastAPI, Request from fastapi.responses import JSONResponse, StreamingResponse @@ -30,6 +38,45 @@ logger = logging.getLogger(__name__) +AgentResolver = Callable[[Request, RunAgentInput], Awaitable[ADKAgent | None]] + + +def resolve_agent_from_message_history( + messages: Sequence[Message], + agent_registry: Mapping[str, ADKAgent], +) -> ADKAgent | None: + """Resolve a tool-result resumption to its originating agent. + + This helper treats ``AssistantMessage.name`` as an explicit agent registry + key. It scopes routing to the latest ``ToolMessage``, matches its + ``ToolMessage.tool_call_id`` value to prior + ``AssistantMessage.tool_calls[].id`` values in the same message history, + and returns the corresponding registry agent. + + ``None`` is returned when the latest message is not a tool result, the + matching assistant message is absent, the assistant message has no + registry key in ``name``, or the key is unknown. This keeps the helper + conservative so the caller can safely fall back to its normal routing + policy. + """ + if not messages or not isinstance(messages[-1], ToolMessage): + return None + + tool_message = messages[-1] + for message in reversed(messages[:-1]): + if not isinstance(message, AssistantMessage): + continue + + tool_call_ids = {tool_call.id for tool_call in message.tool_calls or []} + if tool_message.tool_call_id not in tool_call_ids: + continue + + if not message.name: + return None + return agent_registry.get(message.name) + + return None + def _build_run_error(message: str, code: str) -> RunErrorEvent: """Construct a ``RunErrorEvent`` with the given message and code. @@ -41,6 +88,44 @@ def _build_run_error(message: str, code: str) -> RunErrorEvent: return RunErrorEvent(type=EventType.RUN_ERROR, message=message, code=code) +async def _merge_extractor_state( + input_data: RunAgentInput, + request: Request, + extract_state_fn: Optional[ + Callable[[Request, RunAgentInput], Coroutine[dict[str, Any], Any, Any]] + ], +) -> RunAgentInput: + """Run the request extractor and merge returned state over input state.""" + if not extract_state_fn: + return input_data + + extracted_state_dict = await extract_state_fn(request, input_data) + if not extracted_state_dict: + return input_data + + existing_state = input_data.state if isinstance(input_data.state, dict) else {} + merged_state = {**existing_state, **extracted_state_dict} + return input_data.model_copy(update={"state": merged_state}) + + +async def _resolve_agent( + default_agent: ADKAgent, + request: Request, + input_data: RunAgentInput, + agent_resolver: Optional[AgentResolver], +) -> ADKAgent: + """Resolve the request-scoped agent, falling back to the default agent.""" + if agent_resolver is None: + return default_agent + + resolved_agent = await agent_resolver(request, input_data) + if resolved_agent is None: + return default_agent + if not isinstance(resolved_agent, ADKAgent): + raise TypeError("agent_resolver must return an ADKAgent instance or None") + return resolved_agent + + def _sse_event(raw_data: str, *, event: Optional[str] = None) -> ServerSentEvent: """Build a ``ServerSentEvent`` carrying ``raw_data`` byte-for-byte. @@ -230,6 +315,7 @@ def add_adk_fastapi_endpoint( path: str = "/", extract_headers: Optional[List[str]] = None, extract_state_from_request: Optional[Callable[[Request, RunAgentInput], Coroutine[dict[str,Any], Any, Any]]] = None, + agent_resolver: Optional[AgentResolver] = None, ): """Add ADK middleware endpoint to FastAPI app. @@ -242,11 +328,47 @@ def add_adk_fastapi_endpoint( State values returned from this function will override any existing state values. The RunAgentInput is provided so conflicts can be identified and resolved appropriately. Cannot be used with extract_headers. + agent_resolver: Optional async function that can select an ``ADKAgent`` + for the request after state extraction. Returning ``None`` uses + the default agent. Note: This function also adds an experimental POST /agents/state endpoint for consumption by front-end frameworks that need to retrieve thread state and message history. This endpoint is subject to change in future versions. + When ``agent_resolver`` is configured, routing is applied to the run + endpoint, ``/capabilities``, and ``/agents/state`` after request state + extraction. Routed agents should share a session backend when continuity + across route switches is expected. During HITL or long-running tool + resumption, the resolver is responsible for returning the same agent + that originated the open tool call. + + A resolver can pin tool-result resumptions to the agent that emitted + the matching tool call by treating ``AssistantMessage.name`` as the + agent registry key. For this convention to work, the inbound message + history must preserve the assistant message that created the tool call, + with ``name`` set to that registry key. Histories built only from live + reducer events may not include that key unless the client preserves it. + + .. code-block:: python + + from ag_ui_adk import resolve_agent_from_message_history + + AGENT_REGISTRY = { + "supervisor": supervisor_agent, + "subagent1": subagent1_agent, + } + + async def agent_resolver(request, input_data): + history_agent = resolve_agent_from_message_history( + input_data.messages, + AGENT_REGISTRY, + ) + if history_agent is not None: + return history_agent + + state = input_data.state if isinstance(input_data.state, dict) else {} + return AGENT_REGISTRY.get(state.get("to_agent")) """ extract_state_fn = extract_state_from_request if extract_headers is not None: @@ -261,6 +383,8 @@ def add_adk_fastapi_endpoint( else: raise ValueError("Cannot use both 'extract_headers' and 'extract_state_from_request' parameters together.") + default_agent = agent + @app.post(path) async def adk_endpoint(input_data: RunAgentInput, request: Request): """ADK middleware endpoint. @@ -280,14 +404,10 @@ async def adk_endpoint(input_data: RunAgentInput, request: Request): continue to work without keep-alive pings (which are SSE-specific). """ - # Extract headers into state.headers if list provided - if extract_state_fn: - extracted_state_dict = await extract_state_fn(request, input_data) - - if extracted_state_dict: - existing_state = input_data.state if isinstance(input_data.state, dict) else {} - merged_state = {**existing_state, **extracted_state_dict} - input_data = input_data.model_copy(update={"state": merged_state}) + input_data = await _merge_extractor_state(input_data, request, extract_state_fn) + agent = await _resolve_agent( + default_agent, request, input_data, agent_resolver + ) # ``EventEncoder`` types ``accept`` as ``str`` (not ``Optional[str]``); # pass an empty string when the client didn't send an ``Accept`` header @@ -306,14 +426,31 @@ async def adk_endpoint(input_data: RunAgentInput, request: Request): capabilities_path = f"{path.rstrip('/')}/capabilities" if path != "/" else "/capabilities" @app.get(capabilities_path) - async def capabilities_endpoint(): + async def capabilities_endpoint(request: Request): """Return the agent's declared capabilities. Allows frontend clients to discover what features the agent supports before initiating a run (e.g., predictive chips, suggested questions). - Returns an empty object when no capabilities are configured. + The request extractor and resolver are applied with a fixed synthetic + input so selection only depends on request/extractor context. Returns + an empty object when no capabilities are configured. """ try: + synthetic_input = RunAgentInput( + thread_id="capabilities", + run_id="capabilities", + state={}, + messages=[], + tools=[], + context=[], + forwarded_props=None, + ) + synthetic_input = await _merge_extractor_state( + synthetic_input, request, extract_state_fn + ) + agent = await _resolve_agent( + default_agent, request, synthetic_input, agent_resolver + ) caps = agent.get_capabilities() if caps is None: logger.debug("Capabilities endpoint called but no capabilities configured on agent") @@ -367,11 +504,13 @@ async def agents_state_endpoint(request_data: AgentStateRequest, request: Reques ) if extract_state_fn: - extracted_state_dict = await extract_state_fn(request, synthetic_input) - if extracted_state_dict: - synthetic_input = synthetic_input.model_copy( - update={"state": extracted_state_dict} - ) + synthetic_input = await _merge_extractor_state( + synthetic_input, request, extract_state_fn + ) + + agent = await _resolve_agent( + default_agent, request, synthetic_input, agent_resolver + ) extractor_state = ( synthetic_input.state if isinstance(synthetic_input.state, dict) else {} @@ -513,6 +652,7 @@ def create_adk_app( path: str = "/", extract_headers: Optional[List[str]] = None, extract_state_from_request: Optional[Callable[[Request, RunAgentInput], Coroutine[dict[str,Any], Any, Any]]] = None, + agent_resolver: Optional[AgentResolver] = None, ) -> FastAPI: """Create a FastAPI app with ADK middleware endpoint. @@ -524,10 +664,20 @@ def create_adk_app( State values returned from this function will override any existing state values. The RunAgentInput is provided so conflicts can be identified and resolved appropriately. Cannot be used with extract_headers. + agent_resolver: Optional async function that can select an ``ADKAgent`` + for the request after state extraction. Returning ``None`` uses + the default agent. Returns: FastAPI application instance """ app = FastAPI(title="ADK Middleware for AG-UI Protocol") - add_adk_fastapi_endpoint(app, agent, path, extract_headers=extract_headers, extract_state_from_request=extract_state_from_request) - return app \ No newline at end of file + add_adk_fastapi_endpoint( + app, + agent, + path, + extract_headers=extract_headers, + extract_state_from_request=extract_state_from_request, + agent_resolver=agent_resolver, + ) + return app diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/event_translator.py b/integrations/adk-middleware/python/src/ag_ui_adk/event_translator.py index 6c643b417d..b40064cf9d 100644 --- a/integrations/adk-middleware/python/src/ag_ui_adk/event_translator.py +++ b/integrations/adk-middleware/python/src/ag_ui_adk/event_translator.py @@ -16,6 +16,8 @@ ToolCallResultEvent, StateSnapshotEvent, StateDeltaEvent, CustomEvent, Message, UserMessage, AssistantMessage, ToolMessage, ReasoningMessage, ToolCall, FunctionCall, + ImageInputContent, AudioInputContent, VideoInputContent, + DocumentInputContent, InputContentUrlSource, TextInputContent, ReasoningStartEvent, ReasoningEndEvent, ReasoningMessageStartEvent, ReasoningMessageContentEvent, ReasoningMessageEndEvent, ReasoningEncryptedValueEvent, @@ -61,6 +63,29 @@ def _check_thought_support() -> bool: _THOUGHT_SUPPORT_CHECKED = True return _HAS_THOUGHT_SUPPORT + +def _file_data_to_media_part(file_data): + """Convert an ADK file_data part to the right AG-UI media content type. + + Dispatches on MIME type prefix: image/* → ImageInputContent, + audio/* → AudioInputContent, video/* → VideoInputContent, + everything else (documents, text, etc.) → DocumentInputContent. + Returns None when file_uri is missing. + """ + uri = getattr(file_data, "file_uri", None) + if not uri: + return None + mime = getattr(file_data, "mime_type", None) or "" + source = InputContentUrlSource(value=uri, mimeType=mime or None) + if mime.startswith("image/"): + return ImageInputContent(source=source) + if mime.startswith("audio/"): + return AudioInputContent(source=source) + if mime.startswith("video/"): + return VideoInputContent(source=source) + return DocumentInputContent(source=source) + + def _coerce_tool_response(value: Any, _visited: Optional[set[int]] = None) -> Any: """Recursively convert arbitrary tool responses into JSON-serializable structures.""" @@ -225,6 +250,13 @@ def __init__( # A list is used because the same tool can be called multiple times # in parallel (e.g. 5 concurrent create_item calls). self.lro_emitted_ids_by_name: Dict[str, List[str]] = {} + # This ledger doubles as the high-water mark for replay suppression in + # translate_lro_function_calls: ADK can replay the same logical LRO call + # across several events (streaming chunk, aggregated partial, persisted + # final) with a different ID each time (#1168); any same-name call whose + # position within its event does not exceed len(ledger[name]) is a + # replay and is suppressed. ClientProxyTool consults the same ledger to + # suppress its own cross-path twin (see client_proxy_tool.py). # Track reasoning message streaming state (for thought parts) self._is_reasoning: bool = False # Whether we're currently in a reasoning block @@ -807,9 +839,34 @@ async def translate_lro_function_calls(self,adk_event: ADKEvent)-> AsyncGenerato if adk_event.content and adk_event.content.parts: lro_ids = set(adk_event.long_running_tool_ids or []) + # High-water-mark dedupe across REPLAYED events. Under SSE streaming + # ADK can deliver the same logical LRO call several times — a + # streaming chunk (partial=True), an aggregated partial, and the + # persisted final (partial=False) — and assigns a *different* ID to + # each replay (#1168), so the ID-based guard below cannot recognize + # them as the same call and a duplicate TOOL_CALL trio renders the + # HITL card twice in the dojo. Instead, count same-name LRO calls + # positionally WITHIN this event: the Nth same-name call in an event + # is a replay if we already emitted >= N calls for that name in this + # run (the FIFO pairing _extract_lro_id_remap also uses). Genuinely + # parallel same-name calls arrive as multiple parts of ONE event, so + # they exceed the high-water mark and still emit individually. A + # second model turn calling the same tool again cannot occur within + # this runner stream — LRO pauses the invocation — so a same-name + # reappearance in a LATER event is always a replay. + seen_in_event: Dict[str, int] = {} for i, part in enumerate(adk_event.content.parts): if part.function_call: fc = part.function_call + if getattr(fc, 'id', None) in lro_ids \ + and fc.id not in self.emitted_tool_call_ids: + position = seen_in_event.get(fc.name, 0) + 1 + seen_in_event[fc.name] = position + already_emitted = len(self.lro_emitted_ids_by_name.get(fc.name, [])) + if position <= already_emitted: + # Replay of the position-th call — already emitted + # (under a different ID); suppress the duplicate. + continue # Emit whenever the FC is LRO and hasn't already been emitted # — by ClientProxyTool (1.18+ when ADK invokes the proxy) or # by a previous call to this method (SSE streams an LRO event @@ -1359,10 +1416,21 @@ def adk_events_to_messages(events: List[ADKEvent]) -> List[Message]: if author == "user": if not text_content: continue + media_parts = [ + part_obj + for p in content.parts + if getattr(p, "file_data", None) + for part_obj in [_file_data_to_media_part(p.file_data)] + if part_obj is not None + ] + user_content: object = ( + [TextInputContent(text=text_content)] + media_parts + if media_parts else text_content + ) user_message = UserMessage( id=event_id, role="user", - content=text_content + content=user_content, ) messages.append(user_message) @@ -1384,13 +1452,18 @@ def adk_events_to_messages(events: List[ADKEvent]) -> List[Message]: # Only emit assistant message if there is visible content or tool calls if text_content or tool_calls: + assistant_name = ( + author + if isinstance(author, str) and author != "model" + else None + ) assistant_message = AssistantMessage( id=event_id, role="assistant", + name=assistant_name, content=text_content if text_content else None, tool_calls=tool_calls ) messages.append(assistant_message) return messages - \ No newline at end of file diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/session_manager.py b/integrations/adk-middleware/python/src/ag_ui_adk/session_manager.py index a7e48861fb..cee7fd7158 100644 --- a/integrations/adk-middleware/python/src/ag_ui_adk/session_manager.py +++ b/integrations/adk-middleware/python/src/ag_ui_adk/session_manager.py @@ -2,6 +2,7 @@ """Session manager that adds production features to ADK's native session service.""" +from contextvars import ContextVar from typing import Dict, Optional, Set, Any, Union, Iterable, Tuple import asyncio import logging @@ -16,6 +17,10 @@ CONTEXT_STATE_KEY = "_ag_ui_context" INVOCATION_ID_STATE_KEY = "_ag_ui_invocation_id" +_SESSION_READ_CACHE: ContextVar[Optional[Dict[Tuple[str, str, str], Any]]] = ( + ContextVar("ag_ui_adk_session_read_cache", default=None) +) + class SessionManager: """Session manager that wraps ADK's session service. @@ -103,6 +108,46 @@ def __init__( f"hitl_max_wait: {hitl_max_wait_seconds or 'unlimited'}s" ) + def start_session_read_cache(self): + """Start a short-lived cache for repeated session reads in one execution.""" + return _SESSION_READ_CACHE.set({}) + + def stop_session_read_cache(self, token) -> None: + _SESSION_READ_CACHE.reset(token) + + def disable_session_read_cache(self) -> None: + """Disable session caching for the remainder of the current context.""" + _SESSION_READ_CACHE.set(None) + + def _cache_key( + self, + session_id: str, + app_name: str, + user_id: str, + ) -> Tuple[str, str, str]: + return (session_id, app_name, user_id) + + def _cache_session( + self, + session_id: str, + app_name: str, + user_id: str, + session: Any, + ) -> None: + cache = _SESSION_READ_CACHE.get() + if cache is not None and session is not None: + cache[self._cache_key(session_id, app_name, user_id)] = session + + def invalidate_session( + self, + session_id: str, + app_name: str, + user_id: str, + ) -> None: + cache = _SESSION_READ_CACHE.get() + if cache is not None: + cache.pop(self._cache_key(session_id, app_name, user_id), None) + @classmethod def get_default(cls, **kwargs) -> "SessionManager": """Return the process-wide default SessionManager. @@ -222,6 +267,7 @@ async def _get_or_create_by_thread_id( state=state, session_id=thread_id, ) + self._cache_session(thread_id, app_name, user_id, session) logger.info(f"Created session with thread_id as session_id: {thread_id}") return session, thread_id except Exception as e: @@ -261,6 +307,7 @@ async def _get_or_create_by_scan( app_name=app_name, state=state, ) + self._cache_session(session.id, app_name, user_id, session) logger.info(f"Created new session for thread {thread_id}: {session.id}") return session, session.id @@ -293,6 +340,7 @@ async def _find_session_by_thread_id( # list_sessions returns ListSessionsResponse with .sessions attribute for session in response.sessions: if session.state and session.state.get(THREAD_ID_STATE_KEY) == thread_id: + self._cache_session(session.id, app_name, user_id, session) return session except Exception as e: logger.error(f"Error listing sessions for thread_id lookup: {e}") @@ -316,11 +364,18 @@ async def get_session( Session object if found, None otherwise """ try: - return await self._session_service.get_session( + cache = _SESSION_READ_CACHE.get() + cache_key = self._cache_key(session_id, app_name, user_id) + if cache is not None and cache_key in cache: + return cache[cache_key] + + session = await self._session_service.get_session( session_id=session_id, app_name=app_name, user_id=user_id ) + self._cache_session(session_id, app_name, user_id, session) + return session except Exception as e: logger.error(f"Error getting session {session_id}: {e}") return None @@ -348,7 +403,7 @@ async def update_session_state( True if successful, False otherwise """ try: - session = await self._session_service.get_session( + session = await self.get_session( session_id=session_id, app_name=app_name, user_id=user_id @@ -388,6 +443,7 @@ async def update_session_state( # Apply changes through ADK's event system await self._session_service.append_event(session, event) + self.invalidate_session(session_id, app_name, user_id) logger.info(f"Updated state for session {app_name}:{session_id}") logger.debug(f"State updates: {state_updates}") @@ -415,7 +471,7 @@ async def get_session_state( Session state dictionary or None if session not found """ try: - session = await self._session_service.get_session( + session = await self.get_session( session_id=session_id, app_name=app_name, user_id=user_id @@ -457,7 +513,7 @@ async def get_state_value( Value for the key or default """ try: - session = await self._session_service.get_session( + session = await self.get_session( session_id=session_id, app_name=app_name, user_id=user_id @@ -785,6 +841,7 @@ async def _delete_session(self, session): except Exception as e: logger.error(f"Failed to delete session {session_key}: {e}") + self.invalidate_session(session.id, session.app_name, session.user_id) self._untrack_session(session_key, session.user_id) def _start_cleanup_task(self): @@ -887,4 +944,4 @@ async def stop_cleanup_task(self): await self._cleanup_task except asyncio.CancelledError: pass - self._cleanup_task = None \ No newline at end of file + self._cleanup_task = None diff --git a/integrations/adk-middleware/python/src/ag_ui_adk/utils/converters.py b/integrations/adk-middleware/python/src/ag_ui_adk/utils/converters.py index f92ec0d57e..d7aad48aec 100644 --- a/integrations/adk-middleware/python/src/ag_ui_adk/utils/converters.py +++ b/integrations/adk-middleware/python/src/ag_ui_adk/utils/converters.py @@ -246,6 +246,7 @@ def convert_ag_ui_messages_to_adk(messages: List[Message]) -> List[ADKEvent]: ) elif isinstance(message, AssistantMessage): + event.author = message.name or "model" parts = [] # Add text content if present @@ -344,9 +345,15 @@ def convert_adk_event_to_ag_ui_message(event: ADKEvent) -> Optional[Message]: ) )) + assistant_name = ( + event.author + if isinstance(event.author, str) and event.author != "model" + else None + ) return AssistantMessage( id=event.id, role="assistant", + name=assistant_name, content="\n".join(text_parts) if text_parts else None, tool_calls=tool_calls if tool_calls else None ) diff --git a/integrations/adk-middleware/python/tests/constants.py b/integrations/adk-middleware/python/tests/constants.py new file mode 100644 index 0000000000..70f0dbdbdc --- /dev/null +++ b/integrations/adk-middleware/python/tests/constants.py @@ -0,0 +1,17 @@ +"""Shared model identifiers for live/integration tests. + +Centralizing these here means a forced model cutover (Gemini deprecations come +on a schedule) is a one-line change instead of a sweep across every test file. +Both values are env-overridable so CI can pin a model without code edits. +""" + +import os + +# Default "flash" model used by the bulk of live integration tests. +# gemini-2.0-flash reached its shutdown date (2026-06-01); we leapfrog 2.5-flash +# (shuts down 2026-10-16) straight to the current stable flash GA. +LIVE_TEST_MODEL = os.getenv("ADK_TEST_MODEL", "gemini-3.5-flash") + +# High-reasoning / "pro" model for tests that need it. Held at 2.5-pro for now. +# Note: gemini-2.5-pro also shuts down 2026-10-16 — revisit before then. +LIVE_TEST_PRO_MODEL = os.getenv("ADK_TEST_PRO_MODEL", "gemini-2.5-pro") diff --git a/integrations/adk-middleware/python/tests/test_a2ui_google_sdk.py b/integrations/adk-middleware/python/tests/test_a2ui_google_sdk.py new file mode 100644 index 0000000000..130aa3245b --- /dev/null +++ b/integrations/adk-middleware/python/tests/test_a2ui_google_sdk.py @@ -0,0 +1,285 @@ +"""Tests for the Google A2UI Agent SDK reuse (OSS-158). + +Covers the slimmed glue module (``a2ui_google_sdk``): catalog normalization, +``render_catalog_instructions`` (the prompt-rendering reuse — including that it +survives the client's non-conformant catalog, unlike strict validation), and +``parse_and_fix``-based healing; plus the adapter behaviors that engage when a +catalog is present (Google-rendered prompt + healed args). Validation itself is +the toolkit's job and is exercised in ``test_a2ui_tool.py``. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import AsyncGenerator + +import pytest +from google.adk.models.base_llm import BaseLlm +from google.adk.models.llm_response import LlmResponse +from google.genai import types + +from ag_ui_adk import get_a2ui_tool, CONTEXT_STATE_KEY +from ag_ui_adk.a2ui_tool import A2UI_SCHEMA_CONTEXT_DESCRIPTION +from ag_ui_adk.a2ui_google_sdk import ( + heal_json_arg, + normalize_catalog_dict, + render_catalog_instructions, +) + + +def _envelope_text(result) -> str: + """``run_async`` returns the envelope as a dict (ADK serializes it as the + bare envelope JSON); re-serialize for tests that assert on that text.""" + return result if isinstance(result, str) else json.dumps(result) + + +CID = "https://a2ui.org/demos/dojo/dynamic_catalog.json" + +# A clean inline catalog (loose types, no internal $refs). +CLEAN_CATALOG = { + "catalogId": CID, + "components": { + "Row": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "component": {"const": "Row"}, + "children": {}, + }, + "required": ["id", "component", "children"], + }, + "HotelCard": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "component": {"const": "HotelCard"}, + "name": {}, + }, + "required": ["id", "component", "name"], + }, + }, +} + +# A NON-conformant catalog: component-rooted #/properties ref that dangles under the +# catalog root (mirrors the zod-extracted client catalog that breaks strict validation). +NONCONFORMANT_CATALOG = { + "catalogId": CID, + "components": { + "HotelCard": { + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + { + "properties": { + "component": {"const": "HotelCard"}, + "name": {"$ref": "#/properties/accessibility/properties/label"}, + }, + "required": ["component", "name"], + }, + ] + } + }, +} + + +# --------------------------------------------------------------------------- # +# normalize_catalog_dict +# --------------------------------------------------------------------------- # + + +def test_normalize_inline_dict_injects_default_id(): + out = normalize_catalog_dict( + {"components": CLEAN_CATALOG["components"]}, default_catalog_id="cat://x" + ) + assert out["catalogId"] == "cat://x" and "Row" in out["components"] + + +def test_normalize_existing_id_wins(): + assert ( + normalize_catalog_dict(CLEAN_CATALOG, default_catalog_id="cat://other")[ + "catalogId" + ] + == CID + ) + + +def test_normalize_json_string(): + assert ( + normalize_catalog_dict(json.dumps(CLEAN_CATALOG), default_catalog_id=None)[ + "catalogId" + ] + == CID + ) + + +def test_normalize_non_json_string_returns_none(): + assert ( + normalize_catalog_dict("Card, Text, Row", default_catalog_id="cat://x") is None + ) + + +def test_normalize_legacy_list_form(): + out = normalize_catalog_dict( + [{"name": "HotelCard", "props": {"name": {"type": "string"}}}], + default_catalog_id="cat://x", + ) + assert out["catalogId"] == "cat://x" and "HotelCard" in out["components"] + + +def test_normalize_empty_returns_none(): + assert normalize_catalog_dict({}, default_catalog_id="cat://x") is None + assert normalize_catalog_dict([], default_catalog_id="cat://x") is None + + +# --------------------------------------------------------------------------- # +# render_catalog_instructions +# --------------------------------------------------------------------------- # + + +def test_render_emits_schema_block_and_components_no_tag(): + instr = render_catalog_instructions(CLEAN_CATALOG, default_catalog_id=CID) + assert instr is not None + # Rendered as Google's schema block (markers), carrying the components — and + # never the tag-delivery instruction (we don't use generate_system_prompt). + assert "---BEGIN A2UI JSON SCHEMA---" in instr + assert "HotelCard" in instr and "Row" in instr + assert "" not in instr + + +def test_render_includes_common_types_definitions_when_referenced(): + # A catalog that references common types (like the real zod-extracted client + # catalog) gets the canonical common-types DEFINITIONS bundled into the prompt — + # the definitions the injected catalog only references. That's the reuse value. + instr = render_catalog_instructions(NONCONFORMANT_CATALOG, default_catalog_id=CID) + assert instr is not None + assert "Common Types Schema" in instr + + +def test_render_survives_nonconformant_catalog(): + # Strict validation chokes on this; rendering just serializes, so it must NOT. + instr = render_catalog_instructions(NONCONFORMANT_CATALOG, default_catalog_id=CID) + assert instr is not None and "HotelCard" in instr + + +def test_render_unusable_source_returns_none(): + assert ( + render_catalog_instructions("Card, Text, Row", default_catalog_id=CID) is None + ) + assert render_catalog_instructions({}, default_catalog_id=CID) is None + + +def test_render_is_cached(): + a = render_catalog_instructions(CLEAN_CATALOG, default_catalog_id=CID) + b = render_catalog_instructions(CLEAN_CATALOG, default_catalog_id=CID) + assert a is b + + +# --------------------------------------------------------------------------- # +# heal_json_arg +# --------------------------------------------------------------------------- # + + +def test_heal_smart_quotes_and_trailing_comma(): + assert heal_json_arg( + "[{“id”:“root”,“component”:“Text”,“text”:“Hi”,}]", expect="list" + ) == [{"id": "root", "component": "Text", "text": "Hi"}] + + +def test_heal_dict_unwraps_single_object(): + assert heal_json_arg("{}", expect="dict") == {} + assert heal_json_arg('{"items":[1,2]}', expect="dict") == {"items": [1, 2]} + + +def test_heal_hard_failure_raises(): + with pytest.raises(ValueError): + heal_json_arg("[{not valid", expect="list") + + +# --------------------------------------------------------------------------- # +# Adapter end-to-end: render into prompt + healing +# --------------------------------------------------------------------------- # + + +class _RenderLlm(BaseLlm): + """Yields one ``render_a2ui`` call with ``args``; records the prompt it saw.""" + + args: dict = {} + prompts: list = [] + + async def generate_content_async( + self, llm_request, stream: bool = False + ) -> AsyncGenerator[LlmResponse, None]: + try: + self.prompts.append(llm_request.contents[-1].parts[0].text) + except (AttributeError, IndexError, TypeError): + self.prompts.append(None) + yield LlmResponse( + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + name="render_a2ui", args=self.args + ) + ) + ], + ), + partial=False, + turn_complete=True, + ) + + +class _Ctx: + def __init__(self, state=None): + self.state = state if state is not None else {} + + +@pytest.mark.asyncio +async def test_client_catalog_is_google_rendered_into_prompt(): + model = _RenderLlm( + model="m", + args={ + "surfaceId": "s", + "components": [{"id": "root", "component": "HotelCard", "name": "Ritz"}], + }, + ) + tool = get_a2ui_tool({"model": model, "default_catalog_id": CID}) + tool.event_queue = asyncio.Queue() + state = { + CONTEXT_STATE_KEY: [ + { + "description": A2UI_SCHEMA_CONTEXT_DESCRIPTION, + "value": json.dumps(CLEAN_CATALOG), + } + ] + } + await tool.run_async(args={"intent": "create"}, tool_context=_Ctx(state=state)) + prompt = model.prompts[0] + # The client catalog was rendered via Google's schema block (markers prove it + # wasn't dumped raw), carrying the components — and without the tag instruction. + assert "---BEGIN A2UI JSON SCHEMA---" in prompt + assert "HotelCard" in prompt + assert "" not in prompt + + +@pytest.mark.asyncio +async def test_freeform_string_args_are_healed_and_committed(): + # Gemini returns components as a JSON STRING with smart quotes + trailing comma. + model = _RenderLlm( + model="m", + args={ + "surfaceId": "s", + "components": "[{“id”:“root”,“component”:“Text”,“text”:“Hi”,}]", + }, + ) + tool = get_a2ui_tool({"model": model}) + tool.event_queue = asyncio.Queue() + result = await tool.run_async(args={"intent": "create"}, tool_context=_Ctx()) + assert "a2ui_operations" in _envelope_text(result) + env = json.loads(_envelope_text(result)) + comps = next( + op["updateComponents"]["components"] + for op in env["a2ui_operations"] + if "updateComponents" in op + ) + assert comps[0]["component"] == "Text" and comps[0]["id"] == "root" diff --git a/integrations/adk-middleware/python/tests/test_a2ui_import_hygiene.py b/integrations/adk-middleware/python/tests/test_a2ui_import_hygiene.py new file mode 100644 index 0000000000..9d32189d7b --- /dev/null +++ b/integrations/adk-middleware/python/tests/test_a2ui_import_hygiene.py @@ -0,0 +1,32 @@ +"""Import-hygiene guard for the A2UI hybrid (OSS-158). + +The adapter reuses Google's ``a2ui-agent-sdk`` but ONLY its A2A-free subset +(``a2ui.schema`` / ``a2ui.parser`` / ``a2ui.basic_catalog``). Importing +``ag_ui_adk`` must never pull in ``a2a`` or the A2A/ADK-coupled ``a2ui`` modules +(``a2ui.a2a`` / ``a2ui.adk``) — those would (a) reintroduce the ``a2a-sdk`` import +coupling the proof-point had to pin around, and (b) make the runtime drag A2A +machinery it never uses. This runs in a subprocess so it observes a clean import +graph, not whatever the test session already loaded. +""" + +import subprocess +import sys + + +def test_importing_ag_ui_adk_never_imports_a2a(): + code = ( + "import sys, ag_ui_adk, ag_ui_adk.a2ui_tool, ag_ui_adk.a2ui_google_sdk\n" + "bad = sorted(m for m in sys.modules\n" + " if m == 'a2a' or m.startswith('a2a.')\n" + " or m == 'a2ui.a2a' or m.startswith('a2ui.a2a.')\n" + " or m == 'a2ui.adk' or m.startswith('a2ui.adk.'))\n" + "assert not bad, f'ag_ui_adk pulled A2A/ADK-coupled modules: {bad}'\n" + "print('clean')\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True + ) + assert result.returncode == 0, ( + f"import-hygiene check failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + assert "clean" in result.stdout diff --git a/integrations/adk-middleware/python/tests/test_a2ui_tool.py b/integrations/adk-middleware/python/tests/test_a2ui_tool.py new file mode 100644 index 0000000000..f2a91b3bab --- /dev/null +++ b/integrations/adk-middleware/python/tests/test_a2ui_tool.py @@ -0,0 +1,804 @@ +"""Tests for the ADK A2UI subagent tool (OSS-158). + +The adapter is a thin glue layer over ``ag-ui-a2ui-toolkit``: it owns the ADK +``BaseTool`` decorator, model bind + invoke (with explicit streaming), and the +per-run event-queue emission. The validate→retry recovery loop itself lives in +the toolkit and is exercised here through the adapter seam, mirroring the +LangGraph adapter's contract. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import AsyncGenerator +from unittest.mock import patch + +import pytest +from ag_ui.core import RunAgentInput, UserMessage +from google.adk.agents import LlmAgent +from google.adk.models.base_llm import BaseLlm +from google.adk.models.llm_response import LlmResponse +from google.genai import types + +from ag_ui_adk import get_a2ui_tool, CONTEXT_STATE_KEY, ADKAgent, A2UISubAgentTool +from ag_ui_adk.a2ui_tool import A2UI_SCHEMA_CONTEXT_DESCRIPTION + + +def _envelope_text(result) -> str: + """``run_async`` returns the envelope as a dict so ADK serializes it as the + bare envelope JSON the A2UI middleware inspects (rather than wrapping a string + return as ``{"result": ...}``). Tests assert on that serialized text, so + re-serialize the dict the same way ADK does.""" + return result if isinstance(result, str) else json.dumps(result) + + +# A structurally-valid single-root surface (no catalog, no children, no bindings). +VALID_ARGS = { + "surfaceId": "s1", + "components": [{"id": "root", "component": "Text", "text": "Hi"}], +} +# Structurally invalid: root's child "card" has no matching component (unresolved_child). +INVALID_ARGS = { + "surfaceId": "s1", + "components": [{"id": "root", "component": "Row", "children": ["card"]}], +} + + +class _FakeToolContext: + """Minimal stand-in for ADK's ToolContext (only ``state`` is read here).""" + + def __init__(self, state=None): + self.state = state if state is not None else {} + + +class _FakeEvent: + """Stand-in for an ADK session Event carrying a genai Content turn.""" + + def __init__(self, content, author): + self.content = content + self.author = author + self.partial = False + self.id = None + + def get_function_calls(self): + return [] + + def get_function_responses(self): + return [] + + +class _FakeSession: + def __init__(self, events): + self.events = events + + +class _FakeToolContextWithSession: + """ToolContext stand-in exposing both ``state`` and ``session.events``.""" + + def __init__(self, state=None, events=None): + self.state = state if state is not None else {} + self.session = _FakeSession(events or []) + + +def _user_event(text): + return _FakeEvent( + types.Content(role="user", parts=[types.Part(text=text)]), author="user" + ) + + +class _ToolResultEvent: + """ADK session event carrying a generate_a2ui function RESPONSE, wrapped the + way ADK wraps a string tool return: response = {"result": ""}.""" + + def __init__(self, envelope_str, call_id): + from types import SimpleNamespace + + self.content = types.Content( + role="user", parts=[types.Part(text="(tool result)")] + ) + self.author = "user" + self.partial = False + self.id = call_id + self._fr = SimpleNamespace(response={"result": envelope_str}, id=call_id) + + def get_function_calls(self): + return [] + + def get_function_responses(self): + return [self._fr] + + +class _RecordingRenderLlm(BaseLlm): + """Records the LlmRequest it receives, then yields a valid render_a2ui call.""" + + last_request: object = None + + async def generate_content_async( + self, llm_request, stream: bool = False + ) -> AsyncGenerator[LlmResponse, None]: + type(self).last_request = llm_request + yield LlmResponse( + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + name="render_a2ui", args=VALID_ARGS + ) + ) + ], + ), + partial=False, + turn_complete=True, + ) + + +class _FreeformRenderLlm(BaseLlm): + """Mimics Gemini under the free-form schema: returns components/data as JSON + *strings* (not structured arrays/objects).""" + + async def generate_content_async( + self, llm_request, stream: bool = False + ) -> AsyncGenerator[LlmResponse, None]: + yield LlmResponse( + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + name="render_a2ui", + args={ + "surfaceId": "s1", + "components": json.dumps( + [{"id": "root", "component": "Text", "text": "Hi"}] + ), + "data": "{}", + }, + ) + ) + ], + ), + partial=False, + turn_complete=True, + ) + + +def _drain(queue: asyncio.Queue) -> list: + """Pop every event currently queued (non-blocking).""" + out = [] + while not queue.empty(): + out.append(queue.get_nowait()) + return out + + +class _ScriptedRenderLlm(BaseLlm): + """Test double: yields a ``render_a2ui`` function call per turn. + + ``scripts`` is a list of ``args`` dicts (one per attempt). Each + ``generate_content_async`` call pops the next script and yields a single + final ``LlmResponse`` carrying a ``render_a2ui`` FunctionCall with those + args. A ``None`` entry yields a no-tool-call text response instead. + """ + + scripts: list = [] + calls: int = 0 + prompts: list = [] + + async def generate_content_async( + self, llm_request, stream: bool = False + ) -> AsyncGenerator[LlmResponse, None]: + idx = self.calls + self.calls += 1 + # Record the user prompt this attempt received (to assert re-augmentation). + try: + self.prompts.append(llm_request.contents[-1].parts[0].text) + except (AttributeError, IndexError, TypeError): + self.prompts.append(None) + args = self.scripts[idx] if idx < len(self.scripts) else None + if args is None: + yield LlmResponse( + content=types.Content( + role="model", parts=[types.Part(text="(no tool call)")] + ), + partial=False, + turn_complete=True, + ) + return + yield LlmResponse( + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall(name="render_a2ui", args=args) + ) + ], + ), + partial=False, + turn_complete=True, + ) + + +def test_factory_returns_tool_named_generate_a2ui(): + tool = get_a2ui_tool({"model": _ScriptedRenderLlm(model="scripted")}) + + assert tool.name == "generate_a2ui" + assert tool.description + + +@pytest.mark.asyncio +async def test_valid_first_attempt_emits_envelope_and_tool_call_events(): + model = _ScriptedRenderLlm(model="scripted", scripts=[VALID_ARGS]) + tool = get_a2ui_tool({"model": model}) + queue: asyncio.Queue = asyncio.Queue() + tool.event_queue = queue + + result = await tool.run_async( + args={"intent": "create"}, tool_context=_FakeToolContext() + ) + + # A validated surface was committed as an operations envelope. + assert "a2ui_operations" in _envelope_text(result) + envelope = json.loads(_envelope_text(result)) + assert "a2ui_operations" in envelope + + # Exactly one model attempt (valid on first try — no retry). + assert model.calls == 1 + + # The nested render_a2ui tool call streamed onto the run queue, framed by a + # single stable id: START ... ARGS ... END. + events = _drain(queue) + type_names = [type(e).__name__ for e in events] + assert type_names[0] == "ToolCallStartEvent" + assert type_names[-1] == "ToolCallEndEvent" + assert "ToolCallArgsEvent" in type_names + assert events[0].tool_call_name == "render_a2ui" + ids = {e.tool_call_id for e in events} + assert len(ids) == 1 + + +@pytest.mark.asyncio +async def test_invalid_first_attempt_recovers_and_reuses_stable_id(): + # Attempt 1: unresolved-child (invalid). Attempt 2: valid. + model = _ScriptedRenderLlm(model="scripted", scripts=[INVALID_ARGS, VALID_ARGS]) + attempts: list = [] + tool = get_a2ui_tool({"model": model, "on_a2ui_attempt": attempts.append}) + queue: asyncio.Queue = asyncio.Queue() + tool.event_queue = queue + + result = await tool.run_async( + args={"intent": "create"}, tool_context=_FakeToolContext() + ) + + # Two attempts; only the valid surface (Text root) is committed — the faulty + # Row-with-unresolved-child never reaches the envelope. + assert model.calls == 2 + assert "Text" in _envelope_text(result) and "Row" not in _envelope_text(result) + assert [a["ok"] for a in attempts] == [False, True] + + # The retry prompt was re-augmented with the prior attempt's structured error. + assert "Previous attempt was invalid" in model.prompts[1] + + # Both attempts streamed under the SAME stable nested id (swap-in-place). + events = _drain(queue) + starts = [e for e in events if type(e).__name__ == "ToolCallStartEvent"] + assert len(starts) == 2 + assert len({e.tool_call_id for e in events}) == 1 + + +@pytest.mark.asyncio +async def test_exhaustion_returns_recovery_exhausted_envelope(): + # Every attempt invalid → recovery cap (3) hit → structured hard-failure. + model = _ScriptedRenderLlm( + model="scripted", scripts=[INVALID_ARGS, INVALID_ARGS, INVALID_ARGS] + ) + tool = get_a2ui_tool({"model": model}) + queue: asyncio.Queue = asyncio.Queue() + tool.event_queue = queue + + result = await tool.run_async( + args={"intent": "create"}, tool_context=_FakeToolContext() + ) + + assert model.calls == 3 + envelope = json.loads(_envelope_text(result)) + assert envelope["code"] == "a2ui_recovery_exhausted" + # No faulty surface committed. + assert "a2ui_operations" not in _envelope_text(result) + + +@pytest.mark.asyncio +async def test_context_and_schema_routed_into_subagent_prompt(): + # The ADK middleware stores AG-UI context (flat {description, value} list) + # under CONTEXT_STATE_KEY. The adapter must remap it into the toolkit's + # state["ag-ui"] view, splitting the A2UI schema entry out of regular context. + model = _ScriptedRenderLlm(model="scripted", scripts=[VALID_ARGS]) + tool = get_a2ui_tool({"model": model}) + tool.event_queue = asyncio.Queue() + state = { + CONTEXT_STATE_KEY: [ + {"description": "User preferences", "value": "dark mode please"}, + { + "description": A2UI_SCHEMA_CONTEXT_DESCRIPTION, + "value": "Card, Text, Row", + }, + ] + } + + await tool.run_async( + args={"intent": "create"}, tool_context=_FakeToolContext(state=state) + ) + + prompt = model.prompts[0] + assert "User preferences" in prompt + assert "dark mode please" in prompt + # The schema rides the "Available Components" section, not generic context. + assert "Card, Text, Row" in prompt + + +def test_for_run_returns_isolated_clone_with_event_queue(): + # The construction-time tool is shared across concurrent runs; each run must + # get its OWN clone carrying that run's queue, leaving the original untouched. + tool = get_a2ui_tool({"model": _ScriptedRenderLlm(model="scripted")}) + queue: asyncio.Queue = asyncio.Queue() + + clone = tool.for_run(queue) + + assert clone is not tool + assert clone.event_queue is queue + assert tool.event_queue is None # original never mutated + assert clone.name == tool.name + + +@pytest.mark.asyncio +async def test_subagent_call_mirrors_langgraph_system_instruction_and_conversation(): + # Apples-to-apples with LangGraph's `[SystemMessage(prompt), *messages]`: + # the assembled subagent prompt must ride as system_instruction, and the real + # conversation messages must be forwarded as contents (not the prompt as a + # lone user turn, and not the user request smuggled in as a context entry). + model = _RecordingRenderLlm(model="rec") + tool = get_a2ui_tool( + {"model": model, "guidelines": {"composition_guide": "USE Row + HotelCard."}} + ) + tool.event_queue = asyncio.Queue() + ctx = _FakeToolContextWithSession( + state={}, + events=[_user_event("Compare 3 luxury hotels with ratings and prices.")], + ) + + await tool.run_async(args={"intent": "create"}, tool_context=ctx) + + req = _RecordingRenderLlm.last_request + # Assembled prompt (guidelines etc.) rides as system_instruction. + sysi = req.config.system_instruction + sysi_text = sysi if isinstance(sysi, str) else str(sysi) + assert "HotelCard" in sysi_text # composition guide reached system_instruction + + # The real conversation is forwarded as contents (a user turn with the request). + user_texts = [ + p.text + for c in req.contents + for p in (c.parts or []) + if getattr(p, "text", None) + ] + assert any("luxury hotels" in t for t in user_texts) + # The prompt is NOT duplicated into a user content turn. + assert not any("HotelCard" in t for t in user_texts) + + +@pytest.mark.asyncio +async def test_update_intent_finds_prior_surface_and_skips_create(): + # intent="update" must locate the PRIOR render in ADK session history and + # produce an UPDATE (no createSurface). The prior generate_a2ui result is + # stored by ADK as a wrapped/serialized function response, which the adapter + # must unwrap so the toolkit's find_prior_surface can read a2ui_operations. + prior_env = json.dumps( + { + "a2ui_operations": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "hotel-comparison", + "catalogId": "cat://dynamic", + }, + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "hotel-comparison", + "components": [{"id": "root", "component": "Row"}], + }, + }, + ] + } + ) + tool = get_a2ui_tool({"model": _FreeformRenderLlm(model="ff")}) + tool.event_queue = asyncio.Queue() + ctx = _FakeToolContextWithSession( + state={}, + events=[ + _ToolResultEvent(prior_env, "call_prev"), + _user_event("Make the layout a single column instead of a row."), + ], + ) + + result = await tool.run_async( + args={ + "intent": "update", + "target_surface_id": "hotel-comparison", + "changes": "use a column layout", + }, + tool_context=ctx, + ) + + # Prior was found (not an error envelope) and committed as an UPDATE. + assert "a2ui_operations" in _envelope_text(result), result + assert "createSurface" not in _envelope_text( + result + ) # update reuses the surface, never re-creates + env = json.loads(_envelope_text(result)) + assert any("updateComponents" in op for op in env["a2ui_operations"]) + + +@pytest.mark.asyncio +async def test_render_tool_declares_components_and_data_as_freeform_strings(): + # Gemini fills typed `array`/`object` args strictly -> empty {}. + # The adapter declares components/data as STRING so Gemini writes free-form + # JSON it can actually populate. + model = _RecordingRenderLlm(model="rec") + tool = get_a2ui_tool({"model": model}) + tool.event_queue = asyncio.Queue() + + await tool.run_async(args={"intent": "create"}, tool_context=_FakeToolContext()) + + req = _RecordingRenderLlm.last_request + props = req.config.tools[0].function_declarations[0].parameters.properties + assert props["components"].type == types.Type.STRING + assert props["data"].type == types.Type.STRING + + +@pytest.mark.asyncio +async def test_freeform_string_args_are_parsed_into_a_structured_surface(): + # When Gemini returns components/data as JSON strings, the adapter parses them + # back into the structured shape the toolkit validates and commits. + tool = get_a2ui_tool({"model": _FreeformRenderLlm(model="ff")}) + tool.event_queue = asyncio.Queue() + + result = await tool.run_async( + args={"intent": "create"}, tool_context=_FakeToolContext() + ) + + assert "a2ui_operations" in _envelope_text(result) + env = json.loads(_envelope_text(result)) + comps = next( + op["updateComponents"]["components"] + for op in env["a2ui_operations"] + if "updateComponents" in op + ) + # Parsed into a real component object, not left as a JSON string. + assert comps[0]["component"] == "Text" + assert comps[0]["id"] == "root" + + +@pytest.mark.asyncio +async def test_adk_agent_injects_per_run_event_queue_into_a2ui_tool(): + # ADKAgent must swap the shared A2UISubAgentTool for a per-run clone carrying + # this run's event_queue (so the tool can emit nested tool-call events), + # leaving the construction-time tool untouched for concurrent runs. + a2ui = get_a2ui_tool({"model": _ScriptedRenderLlm(model="scripted")}) + root = LlmAgent(name="root", instruction="be helpful", tools=[a2ui]) + agent = ADKAgent( + adk_agent=root, + app_name="a2ui_app", + user_id="u", + use_in_memory_services=True, + ) + + captured: list = [] + + async def _noop(self, **kwargs): + captured.append(kwargs) + return None + + with patch.object(ADKAgent, "_run_adk_in_background", _noop): + execution = await agent._start_background_execution( + RunAgentInput( + thread_id="thread-A", + run_id="run_A", + messages=[UserMessage(id="m1", role="user", content="hi")], + context=[], + state={}, + tools=[], + forwarded_props={}, + ) + ) + await asyncio.gather(execution.task, return_exceptions=True) + + run_tree = captured[0]["adk_agent"] + run_queue = captured[0]["event_queue"] + run_tool = run_tree.tools[0] + + assert isinstance(run_tool, A2UISubAgentTool) + assert run_tool.event_queue is run_queue # per-run queue injected + assert run_tool is not a2ui # replaced, not the shared original + assert a2ui.event_queue is None # construction-time tool untouched + + +# --------------------------------------------------------------------------- +# Auto-inject decision — plan_a2ui_injection +# +# Mirrors the Strands suite (integrations/aws-strands/python/tests/ +# test_a2ui_tool.py). String literals mirror the shared wire contracts +# (GENERATE_A2UI_TOOL_NAME from the toolkit, render_a2ui + +# A2UI_SCHEMA_CONTEXT_DESCRIPTION from the middleware), hardcoded ON PURPOSE so +# the suite fails if an upstream constant drifts. +# --------------------------------------------------------------------------- + +from unittest.mock import MagicMock + +from ag_ui.core import Context +from ag_ui_adk import is_auto_injected_a2ui_tool, plan_a2ui_injection + +_GENERATE_A2UI_TOOL_NAME = "generate_a2ui" +_RENDER_A2UI_TOOL_NAME = "render_a2ui" +_STUB_MODEL = MagicMock(name="stub-model") +_CATALOG = { + "components": { + "Row": {"required": ["children"]}, + "HotelCard": {"required": ["name", "rating"]}, + } +} + + +def _plan_input(forwarded_props=None, context=None, tools=None) -> RunAgentInput: + return RunAgentInput( + thread_id="thread-1", + run_id="run-1", + state={}, + messages=[], + tools=tools or [], + context=context or [], + forwarded_props=forwarded_props or {}, + ) + + +def test_plan_injects_when_flag_true_and_model_present(): + plan = plan_a2ui_injection( + model=_STUB_MODEL, + input=_plan_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + ) + assert plan is not None + assert plan["tool_name"] == _GENERATE_A2UI_TOOL_NAME + assert _RENDER_A2UI_TOOL_NAME in plan["drop_tool_names"] + assert isinstance(plan["tool"], A2UISubAgentTool) + + +def test_plan_drops_custom_named_render_tool_when_flag_is_string(): + plan = plan_a2ui_injection( + model=_STUB_MODEL, + input=_plan_input(forwarded_props={"injectA2UITool": "render_ui_custom"}), + existing_tool_names=[], + ) + assert plan is not None + assert "render_ui_custom" in plan["drop_tool_names"] + + +def test_plan_skips_and_warns_when_no_model_inferable(): + log = MagicMock() + plan = plan_a2ui_injection( + model=None, + input=_plan_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + log=log, + ) + assert plan is None + log.warning.assert_called_once() + + +def test_plan_no_inject_without_flag_or_override(): + plan = plan_a2ui_injection( + model=_STUB_MODEL, + input=_plan_input(), + existing_tool_names=[], + ) + assert plan is None + + +def test_plan_backend_override_injects_without_runtime_flag(): + plan = plan_a2ui_injection( + model=_STUB_MODEL, + input=_plan_input(), + existing_tool_names=[], + config={"inject_a2ui_tool": True}, + ) + assert plan is not None + assert plan["tool_name"] == _GENERATE_A2UI_TOOL_NAME + + +def test_plan_runtime_false_disables_backend_override(): + # Explicit runtime injectA2UITool=False wins over a backend opt-in. + plan = plan_a2ui_injection( + model=_STUB_MODEL, + input=_plan_input(forwarded_props={"injectA2UITool": False}), + existing_tool_names=[], + config={"inject_a2ui_tool": True}, + ) + assert plan is None + + +def test_plan_user_prevails_no_double_inject(): + # THE "USER PREVAILS" REQUIREMENT: explicit dev wiring wins. + plan = plan_a2ui_injection( + model=_STUB_MODEL, + input=_plan_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[_GENERATE_A2UI_TOOL_NAME], + ) + assert plan is None + + +def test_plan_resolves_catalog_from_schema_context_entry(): + plan = plan_a2ui_injection( + model=_STUB_MODEL, + input=_plan_input( + forwarded_props={"injectA2UITool": True}, + context=[ + Context( + description=A2UI_SCHEMA_CONTEXT_DESCRIPTION, + value=json.dumps(_CATALOG), + ) + ], + ), + existing_tool_names=[], + ) + assert plan is not None + assert plan["catalog"] == _CATALOG + + +def test_plan_marker_distinguishes_auto_injected_from_dev_wired(): + plan = plan_a2ui_injection( + model=_STUB_MODEL, + input=_plan_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + ) + assert plan is not None + assert is_auto_injected_a2ui_tool(plan["tool"]) is True + # A dev-wired tool carries no marker. + assert is_auto_injected_a2ui_tool(get_a2ui_tool({"model": _STUB_MODEL})) is False + + +@pytest.mark.asyncio +async def test_adk_agent_auto_injects_generate_a2ui_when_flag_forwarded(): + # No A2UI tool wired on the agent; the runtime flag triggers injection of a + # per-run generate_a2ui bound to this run's event_queue. + root = LlmAgent( + name="root", + model=_ScriptedRenderLlm(model="scripted"), + instruction="be helpful", + ) + agent = ADKAgent( + adk_agent=root, + app_name="a2ui_app", + user_id="u", + use_in_memory_services=True, + a2ui={"default_catalog_id": "cat-1"}, + ) + + captured: list = [] + + async def _noop(self, **kwargs): + captured.append(kwargs) + return None + + with patch.object(ADKAgent, "_run_adk_in_background", _noop): + execution = await agent._start_background_execution( + RunAgentInput( + thread_id="thread-A", + run_id="run_A", + messages=[UserMessage(id="m1", role="user", content="hi")], + context=[], + state={}, + tools=[], + forwarded_props={"injectA2UITool": True}, + ) + ) + await asyncio.gather(execution.task, return_exceptions=True) + + run_tree = captured[0]["adk_agent"] + run_queue = captured[0]["event_queue"] + injected = [t for t in run_tree.tools if isinstance(t, A2UISubAgentTool)] + + assert len(injected) == 1 + assert injected[0].name == _GENERATE_A2UI_TOOL_NAME + assert is_auto_injected_a2ui_tool(injected[0]) is True + assert injected[0].event_queue is run_queue # per-run queue bound + # The construction-time agent stays clean (no A2UI tool leaks onto it). + assert not any(isinstance(t, A2UISubAgentTool) for t in (root.tools or [])) + + +@pytest.mark.asyncio +async def test_adk_agent_no_auto_inject_without_flag(): + root = LlmAgent( + name="root", + model=_ScriptedRenderLlm(model="scripted"), + instruction="be helpful", + ) + agent = ADKAgent( + adk_agent=root, + app_name="a2ui_app", + user_id="u", + use_in_memory_services=True, + a2ui={"default_catalog_id": "cat-1"}, + ) + + captured: list = [] + + async def _noop(self, **kwargs): + captured.append(kwargs) + return None + + with patch.object(ADKAgent, "_run_adk_in_background", _noop): + execution = await agent._start_background_execution( + RunAgentInput( + thread_id="thread-A", + run_id="run_A", + messages=[UserMessage(id="m1", role="user", content="hi")], + context=[], + state={}, + tools=[], + forwarded_props={}, # no injectA2UITool + ) + ) + await asyncio.gather(execution.task, return_exceptions=True) + + run_tree = captured[0]["adk_agent"] + assert not any(isinstance(t, A2UISubAgentTool) for t in (run_tree.tools or [])) + + +@pytest.mark.asyncio +async def test_adk_agent_user_prevails_over_auto_inject(): + # USER PREVAILS: a dev-wired generate_a2ui beats auto-injection even when + # the runtime forwards injectA2UITool. Exactly one tool survives — the + # dev's (no marker) — and it still gets this run's event_queue bound. + dev_tool = get_a2ui_tool({"model": _ScriptedRenderLlm(model="scripted")}) + root = LlmAgent( + name="root", + model=_ScriptedRenderLlm(model="scripted"), + instruction="be helpful", + tools=[dev_tool], + ) + agent = ADKAgent( + adk_agent=root, + app_name="a2ui_app", + user_id="u", + use_in_memory_services=True, + a2ui={"default_catalog_id": "cat-1"}, + ) + + captured: list = [] + + async def _noop(self, **kwargs): + captured.append(kwargs) + return None + + with patch.object(ADKAgent, "_run_adk_in_background", _noop): + execution = await agent._start_background_execution( + RunAgentInput( + thread_id="thread-A", + run_id="run_A", + messages=[UserMessage(id="m1", role="user", content="hi")], + context=[], + state={}, + tools=[], + forwarded_props={"injectA2UITool": True}, + ) + ) + await asyncio.gather(execution.task, return_exceptions=True) + + run_tree = captured[0]["adk_agent"] + run_queue = captured[0]["event_queue"] + a2ui_tools = [t for t in run_tree.tools if isinstance(t, A2UISubAgentTool)] + + assert len(a2ui_tools) == 1 # no double-inject + assert is_auto_injected_a2ui_tool(a2ui_tools[0]) is False # the dev's tool + assert a2ui_tools[0].event_queue is run_queue # still per-run bound diff --git a/integrations/adk-middleware/python/tests/test_adk_130_invocation_id_override.py b/integrations/adk-middleware/python/tests/test_adk_130_invocation_id_override.py index 5f80367601..8e71e4969d 100644 --- a/integrations/adk-middleware/python/tests/test_adk_130_invocation_id_override.py +++ b/integrations/adk-middleware/python/tests/test_adk_130_invocation_id_override.py @@ -49,9 +49,10 @@ from google.adk import Runner from google.adk.agents import Agent from google.adk.apps import App, ResumabilityConfig +from tests.constants import LIVE_TEST_MODEL -DEFAULT_MODEL = "gemini-2.0-flash" +DEFAULT_MODEL = LIVE_TEST_MODEL def _collect_text(events: List[BaseEvent]) -> str: diff --git a/integrations/adk-middleware/python/tests/test_adk_2_0_compat.py b/integrations/adk-middleware/python/tests/test_adk_2_0_compat.py index 2e5521452d..4692c91020 100644 --- a/integrations/adk-middleware/python/tests/test_adk_2_0_compat.py +++ b/integrations/adk-middleware/python/tests/test_adk_2_0_compat.py @@ -25,6 +25,7 @@ BaseEvent, FunctionCall, RunStartedEvent, + Tool, ToolCall, ToolMessage, UserMessage, @@ -45,123 +46,109 @@ # --------------------------------------------------------------------------- -class TestAGUIToolsetDelegation: - """Verify the bind/unbind pattern that fixes ag-ui#1389 in ADK 2.0.""" +class TestAGUIToolsetReplacement: + """Verify the per-run replacement pattern (ag-ui#1746 follow-up: replaces the + bind/unbind delegation, which stored per-run state on a shared instance and + was not concurrency-safe).""" def test_construction_initializes_baseToolset_state(self) -> None: - """ag-ui#1389 sub-fix: AGUIToolset.__init__ MUST call - ``super().__init__()`` so ADK 2.0's ``_use_invocation_cache`` - attribute is set. Without this, ADK 2.0's ``llm_agent.py:185`` - ``getattr(toolset, '_use_invocation_cache')`` raises - AttributeError and the toolset is silently dropped from the LLM - tool list.""" + """AGUIToolset.__init__ calls ``super().__init__()`` so ADK 2.0's + ``BaseToolset`` cache attributes (``_use_invocation_cache`` et al.) are + initialized and the placeholder is a well-formed toolset.""" toolset = AGUIToolset(tool_filter=['x'], tool_name_prefix='pfx_') - # On ADK 2.0 these attrs must exist; on ADK 1.x calling - # super().__init__ is a no-op so the absence is also OK there. - # We assert the 2.0 invariant — the test will be a no-op on 1.x. + # On ADK 2.0 these attrs must exist; on ADK 1.x super().__init__ is a + # no-op so the absence is also OK there. if hasattr(ADKBaseToolset, '_use_invocation_cache') or any( 'invocation_cache' in name for name in dir(toolset) ): assert hasattr(toolset, '_use_invocation_cache') - def test_unbound_get_tools_returns_empty_list(self) -> None: - """Before bind() is called, ``get_tools()`` returns ``[]`` rather - than raising. This lets ADK 2.0's eager ``Runner.__init__`` walk - the toolset without crashing — actual tool list is supplied by - the run-time ``bind()`` call in ``_update_agent_tools_recursive``. - """ - toolset = AGUIToolset() - result = asyncio.run(toolset.get_tools()) - assert result == [] - - def test_unbound_get_tools_raises_when_explicit(self) -> None: - """Legacy 1.x ``NotImplementedError`` behavior is preserved when - a test explicitly opts in via ``_unbound_raises = True``.""" + def test_placeholder_get_tools_raises(self) -> None: + """The placeholder is replaced per-run before use; calling + ``get_tools()`` on it directly means the substitution didn't happen + (misconfiguration), so it raises.""" toolset = AGUIToolset() - toolset._unbound_raises = True with pytest.raises(NotImplementedError, match="placeholder"): asyncio.run(toolset.get_tools()) - def test_bind_then_get_tools_forwards_to_delegate(self) -> None: - """Once a delegate is bound, ``get_tools()`` forwards to it.""" - toolset = AGUIToolset(tool_filter=['x']) - delegate = MagicMock(spec=ClientProxyToolset) - - async def mock_get_tools(readonly_context=None): - return ['mock_tool_1', 'mock_tool_2'] - - delegate.get_tools = mock_get_tools - toolset.bind(delegate) - result = asyncio.run(toolset.get_tools()) - assert result == ['mock_tool_1', 'mock_tool_2'] - - def test_unbind_resets_to_empty(self) -> None: - """``unbind()`` detaches the delegate so a subsequent ``get_tools()`` - falls back to the unbound branch.""" - toolset = AGUIToolset() - delegate = MagicMock(spec=ClientProxyToolset) - - async def mock_get_tools(readonly_context=None): - return ['delegate_tool'] - - delegate.get_tools = mock_get_tools - toolset.bind(delegate) - toolset.unbind() - result = asyncio.run(toolset.get_tools()) - assert result == [] - assert toolset._delegate is None - - def test_rebind_overwrites_previous_delegate(self) -> None: - """Successive ``bind()`` calls replace the binding — supports - multi-turn runs where each turn supplies a different - ``input.tools`` and therefore a different ``ClientProxyToolset``. - """ - toolset = AGUIToolset() - - delegate_a = MagicMock(spec=ClientProxyToolset) - delegate_b = MagicMock(spec=ClientProxyToolset) - - async def get_a(readonly_context=None): - return ['a'] - - async def get_b(readonly_context=None): - return ['b'] + @pytest.mark.asyncio + async def test_placeholder_replaced_per_run(self) -> None: + """``ADKAgent`` replaces the ``AGUIToolset`` placeholder with a per-run + ``ClientProxyToolset`` in the per-run agent copy, leaving the + construction-time placeholder untouched — so concurrent runs stay + isolated (no shared mutable delegate).""" + agui = AGUIToolset(tool_filter=['probe_tool']) + root_agent = Agent(name="probe_agent", instruction="probe", tools=[agui]) - delegate_a.get_tools = get_a - delegate_b.get_tools = get_b + captured: dict = {} - toolset.bind(delegate_a) - assert asyncio.run(toolset.get_tools()) == ['a'] + async def _noop(self, **kwargs): + captured.update(kwargs) + return None - toolset.bind(delegate_b) - assert asyncio.run(toolset.get_tools()) == ['b'] + with patch.object(ADKAgent, "_run_adk_in_background", _noop): + adk_agent = ADKAgent( + adk_agent=root_agent, + app_name="probe_app", + user_id="probe_user", + use_in_memory_services=True, + ) + run_input = RunAgentInput( + thread_id="probe_thread", + run_id="probe_run", + messages=[UserMessage(id="m1", role="user", content="hi")], + context=[], + state={}, + tools=[Tool( + name="probe_tool", + description="probe tool", + parameters={"type": "object", "properties": {}}, + )], + forwarded_props={}, + ) + exec_state = await adk_agent._start_background_execution(run_input) + await asyncio.gather(exec_state.task, return_exceptions=True) + + per_run_agent = captured["adk_agent"] + replaced = per_run_agent.tools[0] + # Placeholder was replaced with a per-run ClientProxyToolset carrying + # this run's filter. + assert isinstance(replaced, ClientProxyToolset) + assert replaced.tool_filter == ['probe_tool'] + # Construction-time placeholder untouched (not mutated, not shared in). + assert root_agent.tools[0] is agui + assert isinstance(root_agent.tools[0], AGUIToolset) @pytest.mark.asyncio - async def test_object_identity_preserved_across_run(self) -> None: - """The original ``AGUIToolset`` instance is reused across the - run — critical for ADK 2.0 because ``Runner.__init__`` caches a - reference to it during eager ``get_tools()`` resolution. - - Test: declare an ``AGUIToolset`` on an agent, capture its id, - run the agent, and verify the same id is in ``agent.tools`` after - ``_update_agent_tools_recursive`` has bound a delegate. + async def test_swapped_in_toolset_resolves_nonempty_via_get_tools_with_prefix(self) -> None: + """#1389 regression guard (replaces the removed object-identity test). + + The actual #1389 failure was an *empty* tool list: in ADK 2.x a toolset + that is not a well-formed ``BaseToolset`` (no ``super().__init__()`` -> + missing ``_use_invocation_cache``) is silently dropped to ``[]`` by + ``llm_agent._convert_tool_union_to_tools``'s ``try/except``. Assert the + per-run ``ClientProxyToolset`` the middleware swaps in resolves + *non-empty* tools through ``get_tools_with_prefix`` (the ADK path that + reads ``_use_invocation_cache``), and that the agent's + ``canonical_tools`` still exposes the frontend tool -- so we cannot + silently regress to the empty-tool-list symptom. """ - agui = AGUIToolset(tool_filter=['probe_tool']) - original_id = id(agui) + agui = AGUIToolset() # no filter -> every frontend tool passes through root_agent = Agent( name="probe_agent", + model="gemini-2.5-flash", instruction="probe", tools=[agui], ) - with patch.object(ADKAgent, "_run_adk_in_background") as bg_mock: + captured: dict = {} - async def empty_gen() -> AsyncGenerator[BaseEvent, None]: - if False: - yield - return + async def _noop(self, **kwargs): + captured.update(kwargs) + return None + with patch.object(ADKAgent, "_run_adk_in_background", _noop): adk_agent = ADKAgent( adk_agent=root_agent, app_name="probe_app", @@ -171,26 +158,33 @@ async def empty_gen() -> AsyncGenerator[BaseEvent, None]: run_input = RunAgentInput( thread_id="probe_thread", run_id="probe_run", - messages=[ - UserMessage(id="m1", role="user", content="hi") - ], + messages=[UserMessage(id="m1", role="user", content="hi")], context=[], state={}, - tools=[], + tools=[Tool( + name="frontend_tool", + description="a frontend tool", + parameters={"type": "object", "properties": {}}, + )], forwarded_props={}, ) - async for ev in adk_agent.run(run_input): - if not isinstance(ev, RunStartedEvent): - break - - captured_agent = bg_mock.call_args.kwargs['adk_agent'] - captured_toolset = captured_agent.tools[0] - # Object identity preserved → ADK 2.0 Runner cache stays valid - assert id(captured_toolset) == original_id - assert isinstance(captured_toolset, AGUIToolset) - # And a delegate is bound - assert captured_toolset._delegate is not None - assert isinstance(captured_toolset._delegate, ClientProxyToolset) + exec_state = await adk_agent._start_background_execution(run_input) + await asyncio.gather(exec_state.task, return_exceptions=True) + + swapped_in = captured["adk_agent"].tools[0] + assert isinstance(swapped_in, ClientProxyToolset) + + # The #1389 failure mode was *empty* tools through this exact path + # (get_tools_with_prefix reads _use_invocation_cache on ADK 2.x). A + # well-formed toolset resolves the frontend tool rather than []. + resolved = await swapped_in.get_tools_with_prefix() + assert resolved, "swapped-in ClientProxyToolset resolved no tools (#1389 regression)" + assert [t.name for t in resolved] == ["frontend_tool"] + + # End-to-end: the agent's real resolution entrypoint (which would drop a + # malformed toolset to [] via try/except) still exposes the tool. + canonical = await captured["adk_agent"].canonical_tools() + assert "frontend_tool" in [t.name for t in canonical] # --------------------------------------------------------------------------- diff --git a/integrations/adk-middleware/python/tests/test_adk_agent.py b/integrations/adk-middleware/python/tests/test_adk_agent.py index eeb37782f8..a096ab2164 100644 --- a/integrations/adk-middleware/python/tests/test_adk_agent.py +++ b/integrations/adk-middleware/python/tests/test_adk_agent.py @@ -415,15 +415,38 @@ async def test_error_handling(self, adk_agent, sample_input): async for event in adk_agent.run(sample_input): events.append(event) - # Should get RUN_STARTED, RUN_ERROR, and RUN_FINISHED - assert len(events) == 3 + # Should get RUN_STARTED then RUN_ERROR, and NO trailing RUN_FINISHED. + # The AG-UI spec allows at most one terminal event per run; emitting + # RUN_FINISHED after RUN_ERROR makes @ag-ui/client's state machine throw + # ("The run has already errored"). See issue #1892. + assert len(events) == 2 assert events[0].type == EventType.RUN_STARTED assert events[1].type == EventType.RUN_ERROR - assert events[2].type == EventType.RUN_FINISHED # Check that it's an error with meaningful content assert len(events[1].message) > 0 assert events[1].code == 'BACKGROUND_EXECUTION_ERROR' + @pytest.mark.asyncio + async def test_errored_run_emits_single_terminal_event(self, adk_agent, sample_input): + """A run that errors mid-stream must emit exactly one terminal event. + + Regression test for issue #1892: the background queue path emits + RUN_ERROR, after which the consumer loop must NOT fall through to its + unconditional RUN_FINISHED. Two terminal events violate the AG-UI spec + and are rejected by @ag-ui/client. + """ + adk_agent._adk_agent.side_effect = Exception('boom mid-stream') + + events = [event async for event in adk_agent.run(sample_input)] + + terminal_types = [ + e.type for e in events + if e.type in (EventType.RUN_FINISHED, EventType.RUN_ERROR) + ] + assert terminal_types == [EventType.RUN_ERROR], ( + f"expected a single RUN_ERROR terminal event, got {terminal_types}" + ) + @pytest.mark.asyncio async def test_cleanup(self, adk_agent): """Test cleanup method.""" @@ -949,42 +972,30 @@ async def empty_async_generator() -> AsyncGenerator[BaseEvent, None]: assert agent_under_test.tools == [] assert len(agent_under_test.sub_agents) == 2 - # ag-ui#1389: AGUIToolset placeholders are NOT replaced wholesale — - # they get a ClientProxyToolset delegate bound to them, preserving - # object identity so ADK 2.0's eager Runner cache stays valid. - # Test the delegated behavior: each AGUIToolset.tool_filter and - # the underlying delegate's tool_filter must match the declared - # tool_filter from agent construction. + # AGUIToolset placeholders are replaced per-run by a + # ClientProxyToolset carrying the declared tool_filter, on the + # per-run agent copy (the originals are left untouched). - # hello_agent: AGUIToolset with hello_tool filter, delegate also has it + # hello_agent: AGUIToolset(hello_tool) -> ClientProxyToolset(hello_tool) assert agent_under_test.sub_agents[0].name == "hello_agent" assert len(agent_under_test.sub_agents[0].tools) == 1 hello_toolset = agent_under_test.sub_agents[0].tools[0] - assert isinstance(hello_toolset, AGUIToolset) + assert isinstance(hello_toolset, ClientProxyToolset) assert hello_toolset.tool_filter == ['hello_tool'] - assert hello_toolset._delegate is not None - assert isinstance(hello_toolset._delegate, ClientProxyToolset) - assert hello_toolset._delegate.tool_filter == ['hello_tool'] - # deep_agent: AGUIToolset with deep_tool filter, delegate also has it + # deep_agent: AGUIToolset(deep_tool) -> ClientProxyToolset(deep_tool) assert agent_under_test.sub_agents[0].sub_agents[0].name == "deep_agent" assert len(agent_under_test.sub_agents[0].sub_agents[0].tools) == 1 deep_toolset = agent_under_test.sub_agents[0].sub_agents[0].tools[0] - assert isinstance(deep_toolset, AGUIToolset) + assert isinstance(deep_toolset, ClientProxyToolset) assert deep_toolset.tool_filter == ['deep_tool'] - assert deep_toolset._delegate is not None - assert isinstance(deep_toolset._delegate, ClientProxyToolset) - assert deep_toolset._delegate.tool_filter == ['deep_tool'] - # goodbye_agent: AGUIToolset with goodbye_tool filter, delegate also has it + # goodbye_agent: AGUIToolset(goodbye_tool) -> ClientProxyToolset(goodbye_tool) assert agent_under_test.sub_agents[1].name == "goodbye_agent" assert len(agent_under_test.sub_agents[1].tools) == 1 goodbye_toolset = agent_under_test.sub_agents[1].tools[0] - assert isinstance(goodbye_toolset, AGUIToolset) + assert isinstance(goodbye_toolset, ClientProxyToolset) assert goodbye_toolset.tool_filter == ['goodbye_tool'] - assert goodbye_toolset._delegate is not None - assert isinstance(goodbye_toolset._delegate, ClientProxyToolset) - assert goodbye_toolset._delegate.tool_filter == ['goodbye_tool'] @pytest.mark.asyncio async def test_non_deepcopyable_tool_does_not_crash(self): @@ -1038,27 +1049,24 @@ async def get_tools(self, readonly_context=None): submethod_mocked.assert_called_once() agent_under_test = submethod_mocked.call_args.kwargs['adk_agent'] - # The unpicklable toolset should be preserved (shared by reference). - # ag-ui#1389: AGUIToolsets now also stay by reference (bind-delegation - # pattern) instead of being replaced, so both tools should be - # present in agent.tools — just the AGUIToolset now has a bound - # ClientProxyToolset delegate. + # The AGUIToolset is replaced per-run by a ClientProxyToolset; the + # unpicklable toolset is preserved by reference (shared, not copied), + # so both tools are present and no pickling occurred. assert len(agent_under_test.tools) == 2 + assert not any(isinstance(t, AGUIToolset) for t in agent_under_test.tools) - agui_toolsets = [ - t for t in agent_under_test.tools if isinstance(t, AGUIToolset) + proxies = [ + t for t in agent_under_test.tools if isinstance(t, ClientProxyToolset) ] - assert len(agui_toolsets) == 1 - assert agui_toolsets[0]._delegate is not None - assert isinstance(agui_toolsets[0]._delegate, ClientProxyToolset) + assert len(proxies) == 1 - non_proxy_non_agui_tools = [ + others = [ t for t in agent_under_test.tools - if not isinstance(t, (ClientProxyToolset, AGUIToolset)) + if not isinstance(t, ClientProxyToolset) ] - assert len(non_proxy_non_agui_tools) == 1 - assert non_proxy_non_agui_tools[0] is unpicklable - assert non_proxy_non_agui_tools[0].errlog is sys.stderr + assert len(others) == 1 + assert others[0] is unpicklable + assert others[0].errlog is sys.stderr @pytest.mark.asyncio async def test_original_agent_not_mutated_after_run(self): diff --git a/integrations/adk-middleware/python/tests/test_agui_toolset_concurrency.py b/integrations/adk-middleware/python/tests/test_agui_toolset_concurrency.py new file mode 100644 index 0000000000..0ff2a675ab --- /dev/null +++ b/integrations/adk-middleware/python/tests/test_agui_toolset_concurrency.py @@ -0,0 +1,175 @@ +"""Concurrency-safety regression guard for AGUIToolset (ag-ui#1746 follow-up). + +History: PR #1746 made ``ADKAgent`` attach the per-run ``ClientProxyToolset`` to +a single shared ``AGUIToolset`` instance via ``bind()`` (one mutable ``_delegate`` +slot). Because ``_shallow_copy_agent_tree`` shares tool objects by reference and +``max_concurrent_executions`` defaults to 10, concurrent runs clobbered each +other's delegate — Run A's frontend tool calls could be routed to Run B's event +stream, and the first run's cleanup could strand an in-flight peer. + +Fix: ``_update_agent_tools_recursive`` now *replaces* the placeholder with a +fresh per-run ``ClientProxyToolset`` in the per-run copy's ``tools`` list. Each +run gets its own toolset (its own ``input.tools`` + ``event_queue``); the +construction-time placeholder is never mutated. These tests assert that +isolation so the shared-state design can't return. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Callable, Dict, List +from unittest.mock import patch + +from ag_ui.core import Tool, UserMessage +from ag_ui.core.types import RunAgentInput +from google.adk.agents import LlmAgent + +from ag_ui_adk import ADKAgent +from ag_ui_adk.agui_toolset import AGUIToolset +from ag_ui_adk.client_proxy_toolset import ClientProxyToolset + + +def _make_input(thread_id: str, tool_name: str) -> RunAgentInput: + """A minimal new-run input for ``thread_id`` exposing exactly one frontend tool.""" + return RunAgentInput( + thread_id=thread_id, + run_id=f"run_{thread_id}", + messages=[UserMessage(id=f"m_{thread_id}", role="user", content="hi")], + context=[], + state={}, + tools=[ + Tool( + name=tool_name, + description=f"the {tool_name} tool", + parameters={"type": "object", "properties": {}}, + ) + ], + forwarded_props={}, + ) + + +def _build_agent() -> tuple[ADKAgent, AGUIToolset]: + """An ADKAgent whose root LlmAgent declares a single (unfiltered) AGUIToolset.""" + placeholder = AGUIToolset() # no tool_filter -> every client tool passes through + root = LlmAgent(name="root", instruction="be helpful", tools=[placeholder]) + agent = ADKAgent( + adk_agent=root, + app_name="concurrency_app", + user_id="shared_user", + use_in_memory_services=True, + ) + return agent, placeholder + + +def _patch_background_noop() -> tuple[Any, List[Dict[str, Any]]]: + """Patch ``_run_adk_in_background`` with an async no-op that records its kwargs.""" + captured: List[Dict[str, Any]] = [] + + async def _noop(self, **kwargs): # bound as a method -> receives self + captured.append(kwargs) + return None + + return patch.object(ADKAgent, "_run_adk_in_background", _noop), captured + + +async def _await_tasks(*execs) -> None: + await asyncio.gather(*(e.task for e in execs), return_exceptions=True) + + +class TestAGUIToolsetConcurrencySafety: + """Two concurrent runs must each get their own isolated frontend toolset.""" + + async def test_concurrent_runs_get_isolated_proxy_toolsets(self) -> None: + """Each run replaces the placeholder with its OWN ClientProxyToolset + (its own tools + event_queue); the construction-time placeholder is + never mutated and is not shared into either run's tools list.""" + agent, placeholder = _build_agent() + bg_patch, captured = _patch_background_noop() + + with bg_patch: + exec_a = await agent._start_background_execution(_make_input("thread-A", "toolA")) + exec_b = await agent._start_background_execution(_make_input("thread-B", "toolB")) + await _await_tasks(exec_a, exec_b) + + tree_a, tree_b = captured[0]["adk_agent"], captured[1]["adk_agent"] + ts_a, ts_b = tree_a.tools[0], tree_b.tools[0] + queue_a, queue_b = captured[0]["event_queue"], captured[1]["event_queue"] + + # Placeholder was REPLACED in each per-run copy, with distinct proxies. + assert isinstance(ts_a, ClientProxyToolset) and isinstance(ts_b, ClientProxyToolset) + assert ts_a is not ts_b, "concurrent runs must not share a ClientProxyToolset" + + # Construction-time placeholder untouched (not mutated, not in either run). + assert agent._adk_agent.tools[0] is placeholder + assert isinstance(agent._adk_agent.tools[0], AGUIToolset) + + # Each run resolves ITS OWN tools, on ITS OWN event stream. + resolved_a = await ts_a.get_tools() + resolved_b = await ts_b.get_tools() + assert [t.name for t in resolved_a] == ["toolA"] + assert [t.name for t in resolved_b] == ["toolB"] + assert resolved_a[0].event_queue is queue_a + assert resolved_b[0].event_queue is queue_b + assert resolved_a[0].event_queue is not queue_b + + async def test_inflight_run_unaffected_by_other_runs_completion(self) -> None: + """A run completing must not disturb a concurrent in-flight run's tools + (the old ``finally`` unbind of the shared placeholder is gone).""" + agent, _placeholder = _build_agent() + bg_patch, captured = _patch_background_noop() + + with bg_patch: + exec_a = await agent._start_background_execution(_make_input("thread-A", "toolA")) + exec_b = await agent._start_background_execution(_make_input("thread-B", "toolB")) + # Run A finishes; its (no-op) background task completes. + await _await_tasks(exec_a) + + # Run B is still in flight and keeps its full tool list. + ts_b = captured[1]["adk_agent"].tools[0] + resolved_b = [t.name for t in await ts_b.get_tools()] + assert resolved_b == ["toolB"], ( + f"in-flight Run B lost tools (got {resolved_b}) after Run A completed" + ) + await _await_tasks(exec_b) + + async def test_real_concurrent_runs_each_resolve_their_own_tools(self) -> None: + """Under genuine concurrent asyncio scheduling, each run's toolset (as a + Runner would resolve it mid-flight) yields that run's own tools/stream.""" + agent, _placeholder = _build_agent() + + release = asyncio.Event() + started: Dict[str, asyncio.Event] = {"thread-A": asyncio.Event(), "thread-B": asyncio.Event()} + resolved: Dict[str, Dict[str, Any]] = {} + + async def runner_fake(self, *, input, adk_agent, event_queue, client_proxy_toolsets, **kwargs): + label = input.thread_id + started[label].set() + await release.wait() # park until both runs have set up + tools = await adk_agent.tools[0].get_tools() # what this run's Runner resolves + resolved[label] = { + "names": [t.name for t in tools], + "own_queue": event_queue, + "resolved_queue": tools[0].event_queue if tools else None, + } + + async def _wait_until(pred: Callable[[], bool], timeout: float = 5.0) -> None: + deadline = asyncio.get_event_loop().time() + timeout + while not pred(): + if asyncio.get_event_loop().time() > deadline: + raise AssertionError("condition not met within timeout") + await asyncio.sleep(0.005) + + with patch.object(ADKAgent, "_run_adk_in_background", runner_fake): + exec_a = await agent._start_background_execution(_make_input("thread-A", "toolA")) + await asyncio.wait_for(started["thread-A"].wait(), 5) + exec_b = await agent._start_background_execution(_make_input("thread-B", "toolB")) + await asyncio.wait_for(started["thread-B"].wait(), 5) + release.set() + await _wait_until(lambda: {"thread-A", "thread-B"} <= resolved.keys()) + await _await_tasks(exec_a, exec_b) + + assert resolved["thread-A"]["names"] == ["toolA"] + assert resolved["thread-B"]["names"] == ["toolB"] + assert resolved["thread-A"]["resolved_queue"] is resolved["thread-A"]["own_queue"] + assert resolved["thread-B"]["resolved_queue"] is resolved["thread-B"]["own_queue"] + assert resolved["thread-A"]["resolved_queue"] is not resolved["thread-B"]["own_queue"] diff --git a/integrations/adk-middleware/python/tests/test_client_proxy_tool.py b/integrations/adk-middleware/python/tests/test_client_proxy_tool.py index 9a2ee8006b..227027b419 100644 --- a/integrations/adk-middleware/python/tests/test_client_proxy_tool.py +++ b/integrations/adk-middleware/python/tests/test_client_proxy_tool.py @@ -454,7 +454,9 @@ def test_preserves_valid_genai_fields(self): result = _clean_schema_for_genai(schema) assert result["title"] == "MyTool" assert result["default"] == {"key": "value"} - assert result["additionalProperties"] is False + # additionalProperties is stripped: the Gemini Developer API rejects it + # in function declarations with a 400 even though genai.Schema accepts it. + assert "additionalProperties" not in result assert result["minProperties"] == 1 assert result["maxProperties"] == 10 assert result["properties"]["amount"]["minimum"] == 0 @@ -733,8 +735,16 @@ def test_e2e_schema_with_title_default_examples(self): assert query_prop.example == "machine learning" assert query_prop.default == "hello world" - def test_e2e_schema_with_additional_properties(self): - """Schema with additionalProperties passes model_validate (Google docs example).""" + def test_e2e_schema_with_additional_properties_stripped(self): + """additionalProperties is stripped from the function declaration. + + The Gemini Developer API rejects ``additionalProperties`` in function + declarations with a 400 ("Unknown name additional_properties ... Cannot + find field"), even though ``genai.types.Schema`` accepts it as a model + field. zod-to-json-schema (CopilotKit / AG-UI frontend tools) emits it on + every object, so leaving it in breaks every client-supplied tool on the + Developer API. It must be stripped. + """ tool = AGUITool( name="recipe_tool", description="Recipe schema per Google docs", @@ -757,7 +767,66 @@ def test_e2e_schema_with_additional_properties(self): assert declaration is not None assert declaration.parameters is not None - assert declaration.parameters.additional_properties is False + # Stripped, not preserved — otherwise the Developer API 400s on this tool. + assert declaration.parameters.additional_properties is None + + def test_e2e_dojo_hitl_tool_has_no_additional_properties_at_any_depth(self): + """Regression for the AG-UI HITL dojo "nothing renders" report. + + CopilotKit's ``useHumanInTheLoop`` registers a frontend tool whose zod + schema is serialized via ``zodToJsonSchema(..., {$refStrategy: "none"})``, + which stamps ``additionalProperties: false`` on every object (root *and* + array items) plus a ``$schema`` key. Forwarded verbatim, the Gemini + Developer API returns 400 ("Unknown name additional_properties ... Cannot + find field"), the run emits RUN_ERROR, and no tool call reaches the UI. + + The cleaned declaration must therefore contain ``additional_properties`` + nowhere — at any nesting depth — while keeping the real schema intact. + """ + tool = AGUITool( + name="generate_task_steps", + description="Generates a list of steps for the user to perform", + parameters={ + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": {"type": "string"}, + "status": { + "type": "string", + "enum": ["enabled", "disabled", "executing"], + }, + }, + "required": ["description", "status"], + "additionalProperties": False, # nested — must also be stripped + }, + } + }, + "required": ["steps"], + "additionalProperties": False, # root + "$schema": "http://json-schema.org/draft-07/schema#", + }, + ) + proxy = ClientProxyTool(ag_ui_tool=tool, event_queue=AsyncMock()) + declaration = proxy._get_declaration() + + assert declaration is not None + assert declaration.parameters is not None + + # Serialize exactly as it goes on the wire to Gemini; the rejected key + # must appear at no depth (and neither must the $schema meta key). + dumped = declaration.parameters.model_dump_json(by_alias=True, exclude_none=True) + assert "additionalProperties" not in dumped + assert "additional_properties" not in dumped + assert "$schema" not in dumped + + # The real schema survived: steps -> array of objects with the enum intact. + steps = declaration.parameters.properties["steps"] + assert steps.items is not None + assert steps.items.properties["status"].enum == ["enabled", "disabled", "executing"] def test_e2e_schema_with_const_mapped_to_enum(self): """Schema with const is mapped to enum and passes model_validate.""" @@ -949,7 +1018,9 @@ def test_e2e_kitchen_sink_issue_1003(self): params = declaration.parameters # Valid fields preserved assert params.title == "DatabaseQuery" - assert params.additional_properties is False + # additionalProperties stripped (Developer API rejects it; see + # test_e2e_schema_with_additional_properties_stripped). + assert params.additional_properties is None assert params.min_properties == 1 assert params.max_properties == 10 # sql: examples[0] mapped to example, minLength/maxLength preserved @@ -969,8 +1040,9 @@ def test_e2e_kitchen_sink_issue_1003(self): assert format_prop.title == "Output Format" assert format_prop.enum == ["json", "csv", "table"] assert format_prop.default == "json" - # options: nested additionalProperties preserved + # options: nested additionalProperties also stripped (Developer API + # rejects it at any depth, not just the root). options_prop = params.properties["options"] - assert options_prop.additional_properties is True + assert options_prop.additional_properties is None # Invalid fields stripped at root level # (readOnly, writeOnly, deprecated, contentMediaType, dependentRequired) diff --git a/integrations/adk-middleware/python/tests/test_concurrent_limits.py b/integrations/adk-middleware/python/tests/test_concurrent_limits.py index 9decaeddeb..9d1eadfc84 100644 --- a/integrations/adk-middleware/python/tests/test_concurrent_limits.py +++ b/integrations/adk-middleware/python/tests/test_concurrent_limits.py @@ -11,6 +11,7 @@ ) from ag_ui_adk import ADKAgent +from tests.constants import LIVE_TEST_MODEL class TestConcurrentLimits: @@ -23,7 +24,7 @@ def mock_adk_agent(self): from google.adk.agents import LlmAgent return LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Test agent for concurrent testing" ) @@ -204,7 +205,7 @@ async def test_zero_concurrent_limit(self): """Test behavior with zero concurrent execution limit.""" # Create ADK middleware with zero limit from google.adk.agents import LlmAgent - mock_agent = LlmAgent(name="test", model="gemini-2.0-flash", instruction="test") + mock_agent = LlmAgent(name="test", model=LIVE_TEST_MODEL, instruction="test") zero_limit_middleware = ADKAgent( adk_agent=mock_agent, @@ -293,7 +294,7 @@ async def test_execution_with_pending_tools_not_cleaned(self, adk_middleware): async def test_high_concurrent_limit(self): """Test behavior with very high concurrent limit.""" from google.adk.agents import LlmAgent - mock_agent = LlmAgent(name="test", model="gemini-2.0-flash", instruction="test") + mock_agent = LlmAgent(name="test", model=LIVE_TEST_MODEL, instruction="test") high_limit_middleware = ADKAgent( adk_agent=mock_agent, diff --git a/integrations/adk-middleware/python/tests/test_context_integration.py b/integrations/adk-middleware/python/tests/test_context_integration.py index 670aad4bc0..7996803e5f 100644 --- a/integrations/adk-middleware/python/tests/test_context_integration.py +++ b/integrations/adk-middleware/python/tests/test_context_integration.py @@ -23,10 +23,11 @@ from google.adk.agents import LlmAgent from google.adk.agents.readonly_context import ReadonlyContext from google.adk.tools import ToolContext +from tests.constants import LIVE_TEST_MODEL # Default model for live tests -DEFAULT_MODEL = "gemini-2.0-flash" +DEFAULT_MODEL = LIVE_TEST_MODEL async def collect_events(agent: ADKAgent, run_input: RunAgentInput) -> List[BaseEvent]: diff --git a/integrations/adk-middleware/python/tests/test_duplicate_function_response.py b/integrations/adk-middleware/python/tests/test_duplicate_function_response.py index 6f855883d5..8e7d5fbd89 100644 --- a/integrations/adk-middleware/python/tests/test_duplicate_function_response.py +++ b/integrations/adk-middleware/python/tests/test_duplicate_function_response.py @@ -31,6 +31,7 @@ from ag_ui_adk import ADKAgent from ag_ui_adk.session_manager import SessionManager +from tests.constants import LIVE_TEST_MODEL class TestDuplicateFunctionResponseFix: @@ -42,7 +43,7 @@ def mock_adk_agent(self): from google.adk.agents import LlmAgent return LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Test agent for duplicate function_response fix" ) diff --git a/integrations/adk-middleware/python/tests/test_endpoint.py b/integrations/adk-middleware/python/tests/test_endpoint.py index 5dff6e98b9..d823a3f0e7 100644 --- a/integrations/adk-middleware/python/tests/test_endpoint.py +++ b/integrations/adk-middleware/python/tests/test_endpoint.py @@ -430,7 +430,7 @@ def test_create_app_calls_add_endpoint(self, mock_add_endpoint, mock_agent): # Should call add_adk_fastapi_endpoint with correct parameters mock_add_endpoint.assert_called_once_with( - app, mock_agent, "/test", extract_headers = None, extract_state_from_request=None + app, mock_agent, "/test", extract_headers = None, extract_state_from_request=None, agent_resolver=None ) @patch('ag_ui_adk.endpoint.add_adk_fastapi_endpoint') @@ -442,7 +442,7 @@ async def extract_headers(request, input_data): # Should call add_adk_fastapi_endpoint with extract_headers mock_add_endpoint.assert_called_once_with( - app, mock_agent, "/test", extract_headers = ['Authorization'], extract_state_from_request=extract_headers + app, mock_agent, "/test", extract_headers = ['Authorization'], extract_state_from_request=extract_headers, agent_resolver=None ) def test_create_app_default_path(self, mock_agent): @@ -1056,4 +1056,4 @@ def test_legacy_extract_headers_parameter(self, sample_input): input= mock_inner_extract_headers_fn.call_args.args[1] assert isinstance(input, RunAgentInput) - assert input == sample_input \ No newline at end of file + assert input == sample_input diff --git a/integrations/adk-middleware/python/tests/test_endpoint_agent_resolver.py b/integrations/adk-middleware/python/tests/test_endpoint_agent_resolver.py new file mode 100644 index 0000000000..a48a9baaff --- /dev/null +++ b/integrations/adk-middleware/python/tests/test_endpoint_agent_resolver.py @@ -0,0 +1,586 @@ +#!/usr/bin/env python +"""Black-box endpoint tests for minimal async agent resolution.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from ag_ui.core import ( + AssistantMessage, + EventType, + FunctionCall, + RunAgentInput, + RunStartedEvent, + ToolCall, + ToolMessage, + UserMessage, +) +from ag_ui_adk.adk_agent import ADKAgent +from ag_ui_adk.endpoint import ( + add_adk_fastapi_endpoint, + create_adk_app, + resolve_agent_from_message_history, +) +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def _run_input( + *, + thread_id: str = "thread-1", + run_id: str = "run-1", + messages=None, + state=None, +) -> RunAgentInput: + return RunAgentInput( + thread_id=thread_id, + run_id=run_id, + messages=messages + if messages is not None + else [UserMessage(id="user-1", role="user", content="hello")], + tools=[], + context=[], + state={} if state is None else state, + forwarded_props={}, + ) + + +def _agent(name: str, *, capabilities=None): + agent = MagicMock(spec=ADKAgent) + agent.name = name + agent.get_capabilities.return_value = capabilities + + async def run(input_data): + yield RunStartedEvent( + type=EventType.RUN_STARTED, + thread_id=input_data.thread_id, + run_id=input_data.run_id, + ) + + agent.run = MagicMock(side_effect=run) + return agent + + +def _state_agent(name: str, state: dict): + agent = _agent(name) + adk_agent = MagicMock() + adk_agent.name = name + agent._adk_agent = adk_agent + agent._static_app_name = f"{name}_app" + agent._static_user_id = f"{name}_user" + agent._session_lookup_cache = {} + agent._get_session_metadata = MagicMock( + return_value=(f"{name}_session", f"{name}_app", f"{name}_user") + ) + agent._session_manager = MagicMock() + agent._session_manager.get_session_state = AsyncMock(return_value=state) + agent._session_manager._session_service = MagicMock() + session = MagicMock() + session.events = [] + agent._session_manager._session_service.get_session = AsyncMock( + return_value=session + ) + return agent + + +def _assistant_tool_message( + *, + message_id: str, + name: str | None, + tool_call_id: str, +) -> AssistantMessage: + return AssistantMessage( + id=message_id, + role="assistant", + name=name, + content=None, + tool_calls=[ + ToolCall( + id=tool_call_id, + function=FunctionCall(name="client_tool", arguments="{}"), + ) + ], + ) + + +def _tool_result_message( + *, + message_id: str, + tool_call_id: str, +) -> ToolMessage: + return ToolMessage( + id=message_id, + role="tool", + tool_call_id=tool_call_id, + content='{"ok": true}', + ) + + +def _history_resolver_client(default_agent, agent_registry): + async def resolver(request, input_data): + history_agent = resolve_agent_from_message_history( + input_data.messages, agent_registry + ) + if history_agent is not None: + return history_agent + return agent_registry.get(input_data.state.get("agent")) + + app = FastAPI() + add_adk_fastapi_endpoint(app, default_agent, path="/agent", agent_resolver=resolver) + return TestClient(app) + + +def test_resolver_runs_after_extractor_and_can_fallback_to_default_agent(): + default_agent = _agent("default") + selected_agent = _agent("selected") + resolver_inputs = [] + + async def extractor(request, input_data): + return {"tenant": request.headers["x-tenant"], "from_extractor": True} + + async def resolver(request, input_data): + resolver_inputs.append(input_data) + if input_data.state["tenant"] == "selected": + return selected_agent + return None + + app = FastAPI() + add_adk_fastapi_endpoint( + app, + default_agent, + path="/agent", + extract_state_from_request=extractor, + agent_resolver=resolver, + ) + client = TestClient(app) + + selected_response = client.post( + "/agent", + json=_run_input(state={"client_state": "preserved"}).model_dump(), + headers={"x-tenant": "selected"}, + ) + fallback_response = client.post( + "/agent", + json=_run_input(run_id="run-2").model_dump(), + headers={"x-tenant": "unknown"}, + ) + + assert selected_response.status_code == 200 + assert fallback_response.status_code == 200 + assert selected_agent.run.call_count == 1 + assert default_agent.run.call_count == 1 + assert resolver_inputs[0].state == { + "client_state": "preserved", + "tenant": "selected", + "from_extractor": True, + } + + +def test_resolver_can_route_by_request_headers_and_query_params(): + default_agent = _agent("default") + selected_agent = _agent("selected") + + async def resolver(request, input_data): + if ( + request.headers.get("x-route-agent") == "selected" + and request.query_params.get("region") == "west" + ): + return selected_agent + return None + + app = FastAPI() + add_adk_fastapi_endpoint( + app, default_agent, path="/agent", agent_resolver=resolver + ) + client = TestClient(app) + + response = client.post( + "/agent?region=west", + json=_run_input().model_dump(), + headers={"x-route-agent": "selected"}, + ) + + assert response.status_code == 200 + selected_agent.run.assert_called_once() + default_agent.run.assert_not_called() + + +def test_create_adk_app_forwards_agent_resolver_functionally(): + default_agent = _agent("default") + selected_agent = _agent("selected") + + async def resolver(request, input_data): + return selected_agent if input_data.state.get("agent") == "selected" else None + + app = create_adk_app(default_agent, path="/agent", agent_resolver=resolver) + client = TestClient(app) + + response = client.post( + "/agent", json=_run_input(state={"agent": "selected"}).model_dump() + ) + + assert response.status_code == 200 + selected_agent.run.assert_called_once() + default_agent.run.assert_not_called() + + +def test_capabilities_uses_resolver_after_extractor_and_defaults_on_none(): + default_agent = _agent("default", capabilities={"identity": {"name": "default"}}) + selected_agent = _agent( + "selected", capabilities={"identity": {"name": "selected"}} + ) + resolver_inputs = [] + + async def extractor(request, input_data): + if "x-capability-agent" in request.headers: + return {"capability_agent": request.headers["x-capability-agent"]} + return {} + + async def resolver(request, input_data): + resolver_inputs.append(input_data) + if input_data.state.get("capability_agent") == "selected": + return selected_agent + return None + + app = FastAPI() + add_adk_fastapi_endpoint( + app, + default_agent, + path="/agent", + extract_state_from_request=extractor, + agent_resolver=resolver, + ) + client = TestClient(app) + + selected_response = client.get( + "/agent/capabilities", headers={"x-capability-agent": "selected"} + ) + fallback_response = client.get("/agent/capabilities") + + assert selected_response.status_code == 200 + assert selected_response.json()["identity"]["name"] == "selected" + assert fallback_response.status_code == 200 + assert fallback_response.json()["identity"]["name"] == "default" + assert resolver_inputs[0].state == {"capability_agent": "selected"} + assert resolver_inputs[0].messages == [] + + +def test_agents_state_uses_resolved_agent_after_extractor_merge(): + default_agent = _state_agent("default", {"source": "default"}) + selected_agent = _state_agent("selected", {"source": "selected"}) + resolver_inputs = [] + + async def extractor(request, input_data): + return {"state_agent": request.headers["x-state-agent"]} + + async def resolver(request, input_data): + resolver_inputs.append(input_data) + if input_data.state["state_agent"] == "selected": + return selected_agent + return None + + app = FastAPI() + add_adk_fastapi_endpoint( + app, + default_agent, + path="/", + extract_state_from_request=extractor, + agent_resolver=resolver, + ) + client = TestClient(app) + + response = client.post( + "/agents/state", + json={"threadId": "thread-state"}, + headers={"x-state-agent": "selected"}, + ) + + assert response.status_code == 200 + assert response.json()["state"] == {"source": "selected"} + assert resolver_inputs[0].thread_id == "thread-state" + assert resolver_inputs[0].state == {"state_agent": "selected"} + selected_agent._session_manager.get_session_state.assert_awaited_once() + default_agent._session_manager.get_session_state.assert_not_awaited() + + +def test_message_history_resolver_routes_by_assistant_name_and_ignores_conflicting_state(): + default_agent = _agent("default") + originating_agent = _agent("originating") + state_routed_agent = _agent("state-routed") + agent_registry = { + "originating": originating_agent, + "state-routed": state_routed_agent, + } + client = _history_resolver_client(default_agent, agent_registry) + + response = client.post( + "/agent", + json=_run_input( + state={"agent": "state-routed"}, + messages=[ + _assistant_tool_message( + message_id="assistant-1", + name="originating", + tool_call_id="tool-call-1", + ), + _tool_result_message( + message_id="tool-message-1", + tool_call_id="tool-call-1", + ), + ], + ).model_dump(), + ) + + assert response.status_code == 200 + originating_agent.run.assert_called_once() + state_routed_agent.run.assert_not_called() + default_agent.run.assert_not_called() + + +def test_message_history_resolver_accepts_messages_directly(): + originating_agent = _agent("originating") + agent_registry = {"originating": originating_agent} + messages = [ + _assistant_tool_message( + message_id="assistant-1", + name="originating", + tool_call_id="tool-call-1", + ), + _tool_result_message( + message_id="tool-message-1", + tool_call_id="tool-call-1", + ), + ] + + assert ( + resolve_agent_from_message_history(messages, agent_registry) + is originating_agent + ) + + +def test_message_history_resolver_handles_latest_tool_result_from_same_agent_batch(): + originating_agent = _agent("originating") + agent_registry = {"originating": originating_agent} + input_data = _run_input( + messages=[ + _assistant_tool_message( + message_id="assistant-1", + name="originating", + tool_call_id="tool-call-1", + ), + _assistant_tool_message( + message_id="assistant-2", + name="originating", + tool_call_id="tool-call-2", + ), + _tool_result_message( + message_id="tool-message-1", + tool_call_id="tool-call-1", + ), + _tool_result_message( + message_id="tool-message-2", + tool_call_id="tool-call-2", + ), + ], + ) + + assert ( + resolve_agent_from_message_history(input_data.messages, agent_registry) + is originating_agent + ) + + +def test_message_history_resolver_ignores_prior_completed_tool_results(): + first_agent = _agent("first") + second_agent = _agent("second") + agent_registry = {"first": first_agent, "second": second_agent} + input_data = _run_input( + messages=[ + _assistant_tool_message( + message_id="assistant-first", + name="first", + tool_call_id="tool-call-first", + ), + _tool_result_message( + message_id="tool-message-first", + tool_call_id="tool-call-first", + ), + _assistant_tool_message( + message_id="assistant-second", + name="second", + tool_call_id="tool-call-second", + ), + _tool_result_message( + message_id="tool-message-second", + tool_call_id="tool-call-second", + ), + ], + ) + + assert ( + resolve_agent_from_message_history(input_data.messages, agent_registry) + is second_agent + ) + + +def test_message_history_resolver_requires_latest_message_to_be_tool_result(): + originating_agent = _agent("originating") + agent_registry = {"originating": originating_agent} + input_data = _run_input( + messages=[ + _assistant_tool_message( + message_id="assistant-1", + name="originating", + tool_call_id="tool-call-1", + ), + _tool_result_message( + message_id="tool-message-1", + tool_call_id="tool-call-1", + ), + UserMessage(id="user-2", role="user", content="next turn"), + ], + ) + + assert resolve_agent_from_message_history(input_data.messages, agent_registry) is None + + +def test_message_history_resolver_missing_history_falls_back_to_state_agent(): + default_agent = _agent("default") + state_routed_agent = _agent("state-routed") + agent_registry = {"state-routed": state_routed_agent} + client = _history_resolver_client(default_agent, agent_registry) + + response = client.post( + "/agent", + json=_run_input( + state={"agent": "state-routed"}, + messages=[ + _tool_result_message( + message_id="tool-message-1", + tool_call_id="tool-call-1", + ), + ], + ).model_dump(), + ) + + assert response.status_code == 200 + state_routed_agent.run.assert_called_once() + default_agent.run.assert_not_called() + + +def test_message_history_resolver_unknown_or_missing_name_falls_back_to_state_agent(): + default_agent = _agent("default") + state_routed_agent = _agent("state-routed") + agent_registry = {"state-routed": state_routed_agent} + client = _history_resolver_client(default_agent, agent_registry) + + unknown_name_response = client.post( + "/agent", + json=_run_input( + run_id="run-unknown-name", + state={"agent": "state-routed"}, + messages=[ + _assistant_tool_message( + message_id="assistant-unknown", + name="unknown", + tool_call_id="tool-call-unknown", + ), + _tool_result_message( + message_id="tool-message-unknown", + tool_call_id="tool-call-unknown", + ), + ], + ).model_dump(), + ) + missing_name_response = client.post( + "/agent", + json=_run_input( + run_id="run-missing-name", + state={"agent": "state-routed"}, + messages=[ + _assistant_tool_message( + message_id="assistant-missing", + name=None, + tool_call_id="tool-call-missing", + ), + _tool_result_message( + message_id="tool-message-missing", + tool_call_id="tool-call-missing", + ), + ], + ).model_dump(), + ) + + assert unknown_name_response.status_code == 200 + assert missing_name_response.status_code == 200 + assert state_routed_agent.run.call_count == 2 + default_agent.run.assert_not_called() + + +def test_message_history_resolver_uses_latest_tool_message_owner(): + default_agent = _agent("default") + first_agent = _agent("first") + second_agent = _agent("second") + state_routed_agent = _agent("state-routed") + agent_registry = { + "first": first_agent, + "second": second_agent, + "state-routed": state_routed_agent, + } + client = _history_resolver_client(default_agent, agent_registry) + + response = client.post( + "/agent", + json=_run_input( + state={"agent": "state-routed"}, + messages=[ + _assistant_tool_message( + message_id="assistant-first", + name="first", + tool_call_id="tool-call-first", + ), + _assistant_tool_message( + message_id="assistant-second", + name="second", + tool_call_id="tool-call-second", + ), + _tool_result_message( + message_id="tool-message-first", + tool_call_id="tool-call-first", + ), + _tool_result_message( + message_id="tool-message-second", + tool_call_id="tool-call-second", + ), + ], + ).model_dump(), + ) + + assert response.status_code == 200 + second_agent.run.assert_called_once() + first_agent.run.assert_not_called() + state_routed_agent.run.assert_not_called() + default_agent.run.assert_not_called() + + +def test_message_history_resolver_returns_none_without_inbound_tool_messages(): + originating_agent = _agent("originating") + agent_registry = {"originating": originating_agent} + input_data = _run_input( + messages=[ + _assistant_tool_message( + message_id="assistant-1", + name="originating", + tool_call_id="tool-call-1", + ) + ], + ) + + assert resolve_agent_from_message_history(input_data.messages, agent_registry) is None + + +def test_message_history_resolver_is_exported_from_package(): + from ag_ui_adk import resolve_agent_from_message_history as package_export + from ag_ui_adk.endpoint import resolve_agent_from_message_history as endpoint_export + + assert package_export is endpoint_export diff --git a/integrations/adk-middleware/python/tests/test_from_app_integration.py b/integrations/adk-middleware/python/tests/test_from_app_integration.py index 0c1603addd..f01b414d67 100644 --- a/integrations/adk-middleware/python/tests/test_from_app_integration.py +++ b/integrations/adk-middleware/python/tests/test_from_app_integration.py @@ -11,6 +11,7 @@ from ag_ui_adk.session_manager import SessionManager from google.adk.apps import App from google.adk.agents import LlmAgent +from tests.constants import LIVE_TEST_MODEL @pytest.fixture(autouse=True) def setup_llmock(llmock_server): @@ -22,7 +23,7 @@ def sample_app(): """Create a simple App for testing.""" agent = LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You are a helpful assistant. Keep responses brief.", ) return App(name="test_app", root_agent=agent) @@ -121,7 +122,7 @@ async def test_from_app_with_custom_timeout(): """Test that plugin_close_timeout is stored correctly.""" agent = LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You are helpful.", ) app = App(name="test_app", root_agent=agent) @@ -260,18 +261,25 @@ async def test_from_app_with_unsupported_mime_type(sample_app): event_types = [e.type for e in events] # With save_input_blobs_as_artifacts=False, the invalid MIME type blob - # reaches the Gemini API directly. The API may reject it with an error - # or gracefully ignore it — either outcome is acceptable as long as the - # run completes (RUN_FINISHED is emitted). + # reaches the Gemini API directly. The API may reject it (-> RUN_ERROR) or + # gracefully ignore it (-> RUN_FINISHED) — either outcome is acceptable as + # long as the run terminates cleanly with exactly one terminal event. The + # AG-UI spec forbids more than one terminal event per run; see issue #1892. assert EventType.RUN_STARTED in event_types - assert EventType.RUN_FINISHED in event_types + terminal_types = [ + t for t in event_types + if t in (EventType.RUN_FINISHED, EventType.RUN_ERROR) + ] + assert len(terminal_types) == 1, ( + f"expected exactly one terminal event, got {terminal_types}" + ) @pytest.mark.asyncio async def test_runner_supports_plugin_close_timeout(): """Test that runtime detection of plugin_close_timeout works.""" agent = LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You are helpful.", ) app = App(name="test_app", root_agent=agent) diff --git a/integrations/adk-middleware/python/tests/test_hitl_resumption_text_output.py b/integrations/adk-middleware/python/tests/test_hitl_resumption_text_output.py index 8035e02d7d..5322ea8dc3 100644 --- a/integrations/adk-middleware/python/tests/test_hitl_resumption_text_output.py +++ b/integrations/adk-middleware/python/tests/test_hitl_resumption_text_output.py @@ -20,7 +20,7 @@ import asyncio import os -import time +import uuid import pytest from typing import List, Optional @@ -40,10 +40,11 @@ from google.adk.agents import Agent from google.adk.apps import App, ResumabilityConfig from google.genai import types +from tests.constants import LIVE_TEST_MODEL # Use a fast model for tests -DEFAULT_MODEL = "gemini-2.0-flash" +DEFAULT_MODEL = LIVE_TEST_MODEL # Maximum retries when LLM doesn't call the tool (non-deterministic) MAX_TOOL_CALL_RETRIES = 3 @@ -121,13 +122,20 @@ def hitl_agent(self): model=DEFAULT_MODEL, name='hitl_text_output_agent', instruction="""You are a task planning agent. -When asked to plan something, call the plan_steps tool to generate steps. + +When the user asks you to plan ANY task, you MUST immediately call the +plan_steps tool to generate the steps. Call plan_steps before writing any +text: never ask clarifying questions, never reply with a plan as plain +text, and make exactly one plan_steps call per planning request. + When you receive the tool result back, acknowledge the approved steps by listing each one and confirming execution. Always produce a text response after receiving tool results.""", tools=[AGUIToolset()], generate_content_config=types.GenerateContentConfig( - temperature=0.1, # Low temperature for deterministic output + # temperature=0 makes the tool-call decision as deterministic as + # the API allows, so run 1 reliably emits a single plan_steps call. + temperature=0.0, ), ) @@ -189,7 +197,7 @@ async def test_hitl_resumption_produces_text_after_tool_result( # Retry loop since LLM may not always call the tool for attempt in range(1, MAX_TOOL_CALL_RETRIES + 1): - thread_id = f"test_hitl_text_{int(time.time())}_{attempt}" + thread_id = f"test_hitl_text_{uuid.uuid4().hex}_{attempt}" # Step 1: Send initial request to trigger tool call run_input_1 = RunAgentInput( @@ -338,13 +346,17 @@ async def test_hitl_resumption_no_duplicate_function_response( tool_call_id = None for attempt in range(1, MAX_TOOL_CALL_RETRIES + 1): - thread_id = f"test_hitl_both_{int(time.time())}_{attempt}" + thread_id = f"test_hitl_both_{uuid.uuid4().hex}_{attempt}" run_input_1 = RunAgentInput( thread_id=thread_id, run_id="run_1", messages=[ - UserMessage(id="msg_1", role="user", content="Plan a 2-step task") + UserMessage( + id="msg_1", + role="user", + content="Use the plan_steps tool to plan a 2-step task for tidying a desk." + ) ], tools=[plan_tool], context=[], @@ -369,7 +381,11 @@ async def test_hitl_resumption_no_duplicate_function_response( thread_id=thread_id, run_id="run_2", messages=[ - UserMessage(id="msg_1", role="user", content="Plan a 2-step task"), + UserMessage( + id="msg_1", + role="user", + content="Use the plan_steps tool to plan a 2-step task for tidying a desk." + ), AssistantMessage( id="msg_2", role="assistant", diff --git a/integrations/adk-middleware/python/tests/test_issue_437_skip_summarization_integration.py b/integrations/adk-middleware/python/tests/test_issue_437_skip_summarization_integration.py index 7dc55436d5..ae5cc8adfb 100644 --- a/integrations/adk-middleware/python/tests/test_issue_437_skip_summarization_integration.py +++ b/integrations/adk-middleware/python/tests/test_issue_437_skip_summarization_integration.py @@ -48,6 +48,7 @@ from ag_ui_adk.session_manager import SessionManager from google.adk.agents import LlmAgent from google.adk.tools import ToolContext +from tests.constants import LIVE_TEST_MODEL @pytest.fixture(autouse=True) @@ -100,7 +101,7 @@ def weather_agent(self): """Create an ADK agent with the skip_summarization tool.""" adk_agent = LlmAgent( name="weather_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="""You are a weather assistant. When asked about the weather, ALWAYS use the get_weather_with_skip_summarization tool. After the tool returns, do NOT repeat or summarize the result. @@ -121,7 +122,7 @@ def normal_tool_agent(self): """Create an ADK agent with a normal tool (no skip_summarization).""" adk_agent = LlmAgent( name="temp_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="""You are a temperature assistant. When asked about the temperature, use the get_temperature tool. """, @@ -406,7 +407,7 @@ def tool_without_skip(tool_context: ToolContext, query: str) -> str: adk_agent = LlmAgent( name="multi_tool_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="""You have two tools: - tool_with_skip: Use this when asked about "skip" queries - tool_without_skip: Use this when asked about "normal" queries @@ -434,7 +435,7 @@ def slow_skip_tool(tool_context: ToolContext, data: str) -> str: adk_agent = LlmAgent( name="timeout_test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Use the slow_skip_tool when asked to process anything.", tools=[slow_skip_tool], ) @@ -523,7 +524,7 @@ def weather_skip_sum(tool_context: ToolContext, city: str) -> str: adk_agent = LlmAgent( name="weather_skip_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="""You are a weather assistant. ALWAYS use the weather_skip_sum tool when asked about weather. After the tool returns, do NOT repeat or summarize the result. diff --git a/integrations/adk-middleware/python/tests/test_llmagent_hitl_confirmation.py b/integrations/adk-middleware/python/tests/test_llmagent_hitl_confirmation.py new file mode 100644 index 0000000000..5c15de659a --- /dev/null +++ b/integrations/adk-middleware/python/tests/test_llmagent_hitl_confirmation.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python +"""Regression test for ag-ui#1839: HITL confirmation on a standalone LlmAgent root. + +When a backend tool calls ``tool_context.request_confirmation()`` on a standalone +``LlmAgent`` root with ``ResumabilityConfig(is_resumable=True)``, submitting the +user's confirmation must RE-EXECUTE the original tool — not silently fall through +to the LLM, which then hallucinates an "I'm awaiting confirmation" reply. + +Root cause (fixed in adk_agent.py): the #1534 pre-append workaround substituted +``new_message`` with an empty-text placeholder. That placeholder became the last +user event in the session, so ADK's ``_RequestConfirmationLlmRequestProcessor`` +(which reverse-scans for the last user event and returns on the first one lacking +``function_responses``) bailed before reaching the pre-appended confirmation +``FunctionResponse``. ``adk_request_confirmation`` is a long-running tool that +PAUSES (not ends) the invocation, so routing it through the direct ``new_message`` +path (like Workflow roots) re-executes the tool without re-triggering the #1534 +``end_of_agent`` early-return. + +This is the LlmAgent cousin of #1669 (the Workflow-root variant). + +Requires GOOGLE_API_KEY environment variable (live integration test, like the +sibling HITL tests). Skips gracefully when the key is absent or when the LLM +declines to call the tool (non-determinism). +""" + +import asyncio +import os +import time +from typing import List, Optional + +import pytest + +from ag_ui.core import ( + RunAgentInput, + EventType, + UserMessage, + AssistantMessage, + ToolMessage, + ToolCall, + FunctionCall, + BaseEvent, +) +from ag_ui_adk import ADKAgent +from ag_ui_adk.session_manager import SessionManager +from google.adk.agents.llm_agent import LlmAgent +from google.adk.agents.sequential_agent import SequentialAgent +from google.adk.apps import App, ResumabilityConfig +from google.genai import types + +from tests.constants import LIVE_TEST_MODEL + + +# Shared, env-overridable live model id (see tests/constants.py) so model +# cutovers stay a one-line change across the whole suite. +DEFAULT_MODEL = LIVE_TEST_MODEL +MAX_TOOL_CALL_RETRIES = 3 +RC_TOOL_NAME = "adk_request_confirmation" + + +async def collect_events(agent: ADKAgent, run_input: RunAgentInput) -> List[BaseEvent]: + events = [] + async for event in agent.run(run_input): + events.append(event) + return events + + +def find_rc_tool_call(events: List[BaseEvent]) -> tuple[Optional[str], str]: + """Return (tool_call_id, args_json) for the adk_request_confirmation call.""" + rc_id, args, inside = None, "", False + for event in events: + if event.type == EventType.TOOL_CALL_START: + inside = getattr(event, "tool_call_name", None) == RC_TOOL_NAME + if inside: + rc_id = getattr(event, "tool_call_id", None) + elif event.type == EventType.TOOL_CALL_ARGS and inside: + args += getattr(event, "delta", "") + elif event.type == EventType.TOOL_CALL_END: + inside = False + return rc_id, args + + +def collect_text(events: List[BaseEvent]) -> str: + return "".join( + getattr(e, "delta", "") + for e in events + if e.type == EventType.TEXT_MESSAGE_CONTENT + ).strip() + + +class _ExecCounter: + """Mutable backend-tool execution counter shared with the tool closure.""" + + def __init__(self) -> None: + self.executed = 0 + + +def _build_agent(counter: _ExecCounter, *, composite_root: bool) -> ADKAgent: + def dangerous_action(target: str, tool_context) -> dict: + """A backend tool gated by HITL confirmation.""" + confirmation = tool_context.tool_confirmation + if confirmation is None: + tool_context.request_confirmation( + hint=f"Confirm dangerous_action on target='{target}'?" + ) + return {"status": "awaiting_confirmation", "target": target} + if not confirmation.confirmed: + return {"status": "rejected", "target": target} + counter.executed += 1 + return {"status": "executed", "target": target, "count": counter.executed} + + leaf = LlmAgent( + name="issue_1839_agent", + model=DEFAULT_MODEL, + instruction=( + "When the user asks you to run an action, immediately call " + "dangerous_action with the requested target. After the tool " + "returns, briefly tell the user what happened." + ), + tools=[dangerous_action], + generate_content_config=types.GenerateContentConfig(temperature=0.1), + ) + root = ( + SequentialAgent(name="issue_1839_composite", sub_agents=[leaf]) + if composite_root + else leaf + ) + adk_app = App( + name="issue_1839_app", + root_agent=root, + resumability_config=ResumabilityConfig(is_resumable=True), + ) + return ADKAgent.from_app( + adk_app, + user_id="test_user", + use_in_memory_services=True, + ) + + +class TestLlmAgentHITLConfirmation: + """HITL confirmation must re-execute the original backend tool on resume.""" + + @pytest.fixture(autouse=True) + def setup_llmock(self, llmock_server): + """Ensure LLMock is running when no real API key is set.""" + + @pytest.fixture(autouse=True) + def reset_session_manager(self): + SessionManager.reset_instance() + yield + SessionManager.reset_instance() + + @pytest.fixture + def check_api_key(self): + if not os.getenv("GOOGLE_API_KEY"): + pytest.skip("GOOGLE_API_KEY not set - skipping live integration test") + + @pytest.mark.parametrize( + "composite_root,case", + [ + # ag-ui#1839 — standalone LlmAgent root (the bug under test). + (False, "standalone_llm_root"), + # SequentialAgent composite of LlmAgents. NOT the ADK 2.0 Workflow + # path (#1669) — that requires google.adk.workflow.Workflow, absent + # on ADK 1.x, where _root_agent_is_workflow() is always False. On + # the buggy code this composite hard-crashes on confirmation with + # "No agent to transfer to"; the same fix covers it. + (True, "sequential_composite_root"), + ], + ) + @pytest.mark.asyncio + async def test_confirmation_reexecutes_tool( + self, check_api_key, composite_root, case + ): + counter = _ExecCounter() + agent = _build_agent(counter, composite_root=composite_root) + + rc_id, rc_args = None, "" + thread_id = None + for attempt in range(1, MAX_TOOL_CALL_RETRIES + 1): + counter.executed = 0 + thread_id = f"issue_1839_{case}_{int(time.time())}_{attempt}" + turn1 = await collect_events( + agent, + RunAgentInput( + thread_id=thread_id, + run_id="run_initial", + messages=[ + UserMessage( + id="u-1", + role="user", + content="Run the dangerous action with target='foo'", + ) + ], + tools=[], + context=[], + state={}, + forwarded_props={}, + ), + ) + rc_id, rc_args = find_rc_tool_call(turn1) + if rc_id: + break + SessionManager.reset_instance() + await asyncio.sleep(1) + + if not rc_id: + pytest.skip( + f"Agent did not request confirmation after " + f"{MAX_TOOL_CALL_RETRIES} attempts (LLM non-determinism)" + ) + + # Turn 1 requests confirmation; the tool must NOT have executed yet. + assert counter.executed == 0, ( + "dangerous_action executed before confirmation was granted" + ) + + # Turn 2: user confirms. The original tool must re-execute exactly once. + turn2 = await collect_events( + agent, + RunAgentInput( + thread_id=thread_id, + run_id="run_confirm", + messages=[ + UserMessage( + id="u-1", + role="user", + content="Run the dangerous action with target='foo'", + ), + AssistantMessage( + id="a-1", + role="assistant", + content=None, + tool_calls=[ + ToolCall( + id=rc_id, + function=FunctionCall( + name=RC_TOOL_NAME, + arguments=rc_args or "{}", + ), + ) + ], + ), + ToolMessage( + id="t-1", + role="tool", + content='{"confirmed": true}', + tool_call_id=rc_id, + ), + ], + tools=[], + context=[], + state={}, + forwarded_props={}, + ), + ) + + text = collect_text(turn2) + low = text.lower() + hallucinated = "awaiting confirmation" in low or ( + "await" in low and "confirm" in low + ) + + # Authoritative signal: the backend tool re-executed exactly once. + assert counter.executed == 1, ( + f"[{case}] expected dangerous_action to re-execute exactly once on " + f"confirmation, got {counter.executed}. Final text: {text!r}" + ) + # Second half of the issue comment's ask: no LLM fall-through claiming + # it is still awaiting confirmation. + assert not hallucinated, ( + f"[{case}] LLM fell through to an awaiting-confirmation reply " + f"instead of acting on the re-executed tool: {text!r}" + ) diff --git a/integrations/adk-middleware/python/tests/test_lro_sse_id_remap.py b/integrations/adk-middleware/python/tests/test_lro_sse_id_remap.py index 942b673b80..81dd03c570 100644 --- a/integrations/adk-middleware/python/tests/test_lro_sse_id_remap.py +++ b/integrations/adk-middleware/python/tests/test_lro_sse_id_remap.py @@ -47,6 +47,7 @@ from ag_ui_adk import ADKAgent from ag_ui_adk.event_translator import EventTranslator from ag_ui_adk.session_manager import SessionManager +from tests.constants import LIVE_TEST_MODEL # ============================================================================= @@ -280,6 +281,112 @@ async def test_lro_emitted_ids_cleared_on_reset(self, translator): translator.reset() assert translator.lro_emitted_ids_by_name == {} + +class TestLroDuplicateEmissionSuppression: + """Regression: a single logical LRO call streamed by ADK as a partial event + then a final event (with *different* IDs, per #1168) must emit exactly ONE + TOOL_CALL trio — not two. Otherwise the dojo renders the HITL card twice. + """ + + @pytest.fixture + def translator(self): + return EventTranslator() + + def _event(self, fcs, *, partial): + """Build an ADK-style event. ``fcs`` is a list of (name, id) tuples.""" + parts = [] + for name, fid in fcs: + fc = MagicMock() + fc.id = fid + fc.name = name + fc.args = {"steps": [{"description": "x", "status": "enabled"}]} + part = MagicMock() + part.function_call = fc + part.text = None + parts.append(part) + evt = MagicMock() + evt.content = MagicMock() + evt.content.parts = parts + evt.long_running_tool_ids = [fid for _, fid in fcs] + evt.partial = partial + return evt + + async def _starts(self, translator, evt): + ids = [] + async for e in translator.translate_lro_function_calls(evt): + if e.type == EventType.TOOL_CALL_START: + ids.append(e.tool_call_id) + return ids + + @pytest.mark.asyncio + async def test_partial_then_final_emits_once(self, translator): + """The non-partial twin (different id) is suppressed — one emission total.""" + partial_ids = await self._starts( + translator, self._event([("generate_task_steps", "adk-AAA")], partial=True) + ) + final_ids = await self._starts( + translator, self._event([("generate_task_steps", "adk-BBB")], partial=False) + ) + assert partial_ids == ["adk-AAA"] + assert final_ids == [], "final twin must be suppressed (no duplicate render)" + + @pytest.mark.asyncio + async def test_parallel_same_name_calls_not_oversuppressed(self, translator): + """Two genuinely parallel calls each emit once (partials), finals suppressed.""" + partial_ids = await self._starts( + translator, + self._event( + [("generate_task_steps", "adk-A1"), ("generate_task_steps", "adk-A2")], + partial=True, + ), + ) + final_ids = await self._starts( + translator, + self._event( + [("generate_task_steps", "adk-B1"), ("generate_task_steps", "adk-B2")], + partial=False, + ), + ) + assert partial_ids == ["adk-A1", "adk-A2"] + assert final_ids == [] # both finals are twins of the two partials + + @pytest.mark.asyncio + async def test_final_only_still_emits(self, translator): + """Non-streaming case (no partial twin): the final must still emit once.""" + final_ids = await self._starts( + translator, self._event([("generate_task_steps", "adk-ONLY")], partial=False) + ) + assert final_ids == ["adk-ONLY"] + + @pytest.mark.asyncio + async def test_second_partial_replay_suppressed(self, translator): + """ADK can replay the call in a SECOND partial chunk (e.g. streaming + chunk + aggregated partial) with yet another ID — also a twin.""" + first = await self._starts( + translator, self._event([("generate_task_steps", "adk-P1")], partial=True) + ) + second = await self._starts( + translator, self._event([("generate_task_steps", "adk-P2")], partial=True) + ) + final = await self._starts( + translator, self._event([("generate_task_steps", "adk-F1")], partial=False) + ) + assert first == ["adk-P1"] + assert second == [], "second partial replay must be suppressed" + assert final == [], "final replay must be suppressed" + + @pytest.mark.asyncio + async def test_reset_clears_replay_ledger(self, translator): + """After reset(), a same-name call in a new run emits again.""" + await self._starts( + translator, self._event([("generate_task_steps", "adk-RUN1")], partial=True) + ) + translator.reset() + ids = await self._starts( + translator, self._event([("generate_task_steps", "adk-RUN2")], partial=True) + ) + assert ids == ["adk-RUN2"] + @pytest.mark.asyncio async def test_lro_adk_request_credential_oauth2(self, translator): """Regression (#1331): adk_request_credential with OAuth2 AuthConfig must serialize. @@ -1029,7 +1136,7 @@ async def test_hitl_round_trip_with_sse_streaming(self, lro_tool): agent = LlmAgent( name="approval_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction=( "When asked to do anything, ALWAYS use the get_approval tool first. " "Pass the action description as the 'action' parameter." @@ -1149,7 +1256,7 @@ async def test_hitl_without_streaming_still_works(self, lro_tool): agent = LlmAgent( name="approval_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction=( "When asked to do anything, ALWAYS use the get_approval tool first. " "Pass the action description as the 'action' parameter." @@ -1550,6 +1657,119 @@ async def test_resumable_hitl_lro_remap_does_not_trip_stale_session( ) +class TestLroNoDuplicateToolCallEndToEnd: + """End-to-end regression: a long-running client tool streamed by ADK as a + partial then final event (with different IDs, #1168) must surface exactly + ONE TOOL_CALL_START to the client — not two. The duplicate is cross-path: + the EventTranslator emits from the partial event while ADK separately + invokes the ClientProxyTool for the final, each with a different ID, so the + ID-based dedupe on both sides misses. Drives the real ADK runner + real + ClientProxyTool + real EventTranslator (no real LLM, no DB). + """ + + @pytest.fixture(autouse=True) + def reset_session_manager(self): + SessionManager.reset_instance() + yield + SessionManager.reset_instance() + + def _scripted_lro_llm(self, tool_name: str, shape: str = "partial-final"): + from google.adk.models.base_llm import BaseLlm + from google.adk.models.llm_response import LlmResponse + from google.genai import types as gt + + class _ScriptedLro(BaseLlm): + name_: str = tool_name + shape_: str = shape + + async def generate_content_async( + self, llm_request, stream: bool = False + ) -> AsyncGenerator: + def mk(partial, turn_complete=None): + return LlmResponse( + content=gt.Content( + role="model", + parts=[gt.Part(function_call=gt.FunctionCall( + name=self.name_, args={"action": "archive"}))], + ), + partial=partial, + turn_complete=(not partial) if turn_complete is None else turn_complete, + ) + # Each yield gets a FRESH ID from ADK's + # populate_client_function_call_id — guaranteed divergence. + if self.shape_ == "two-partials": + # streaming chunk + aggregated partial + persisted final + yield mk(partial=True) + yield mk(partial=True) + yield mk(partial=False) + elif self.shape_ == "two-partials-no-final": + # the "last event is partial, which is not expected" shape + yield mk(partial=True) + yield mk(partial=True, turn_complete=True) + else: # partial-final + yield mk(partial=True) + yield mk(partial=False) + + return _ScriptedLro(model=f"scripted-lro-{shape}") + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "shape", ["partial-final", "two-partials", "two-partials-no-final"] + ) + async def test_partial_plus_proxy_emits_single_tool_call(self, shape): + from ag_ui_adk.agui_toolset import AGUIToolset + from google.adk.agents import LlmAgent + from google.adk.apps import App, ResumabilityConfig + + frontend_tool = AGUITool( + name="approve_action", + description="Ask the user to approve an action.", + parameters={ + "type": "object", + "properties": {"action": {"type": "string"}}, + "required": ["action"], + }, + ) + agent = LlmAgent( + name="hitl_dupe_agent", + model=self._scripted_lro_llm("approve_action", shape), + instruction="Call the tool when asked.", + tools=[AGUIToolset()], + ) + adk_app = App( + name=f"app_{uuid.uuid4().hex[:8]}", + root_agent=agent, + resumability_config=ResumabilityConfig(is_resumable=True), + ) + adk_agent = ADKAgent.from_app( + adk_app, user_id="u1", use_in_memory_services=True, + ) + + starts = [] + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + async for event in adk_agent.run( + RunAgentInput( + thread_id=f"t_{uuid.uuid4().hex[:8]}", + run_id=str(uuid.uuid4()), + state={}, + messages=[UserMessage(id=str(uuid.uuid4()), content="archive please")], + tools=[frontend_tool], + context=[], + forwarded_props={}, + ) + ): + if event.type == EventType.TOOL_CALL_START: + starts.append((event.tool_call_id, getattr(event, "tool_call_name", None))) + + approve_starts = [s for s in starts if s[1] == "approve_action"] + assert len(approve_starts) == 1, ( + f"Expected exactly one TOOL_CALL_START for approve_action, got " + f"{len(approve_starts)}: {approve_starts}. The partial→proxy " + f"cross-path duplicate (#1168) has regressed." + ) + + # ============================================================================= # Direct Execution # ============================================================================= diff --git a/integrations/adk-middleware/python/tests/test_lro_sse_persistence.py b/integrations/adk-middleware/python/tests/test_lro_sse_persistence.py index d674ea7455..ae36d91c10 100644 --- a/integrations/adk-middleware/python/tests/test_lro_sse_persistence.py +++ b/integrations/adk-middleware/python/tests/test_lro_sse_persistence.py @@ -35,6 +35,7 @@ ) from ag_ui_adk import ADKAgent from ag_ui_adk.session_manager import SessionManager +from tests.constants import LIVE_TEST_MODEL # ============================================================================= @@ -375,7 +376,7 @@ async def test_agent_events_persisted_with_sse_streaming(self, lro_tool): # Create agent that will use the LRO tool agent = LlmAgent( name="greeter", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="When asked to greet someone, use the get_greeting tool with their name.", tools=[AGUIToolset()], ) @@ -457,7 +458,7 @@ async def test_agent_events_persisted_without_streaming_baseline(self, lro_tool) agent = LlmAgent( name="greeter", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="When asked to greet someone, use the get_greeting tool with their name.", tools=[AGUIToolset()], ) diff --git a/integrations/adk-middleware/python/tests/test_lro_tool_response_persistence.py b/integrations/adk-middleware/python/tests/test_lro_tool_response_persistence.py index df72c7b8d0..9d67749a01 100644 --- a/integrations/adk-middleware/python/tests/test_lro_tool_response_persistence.py +++ b/integrations/adk-middleware/python/tests/test_lro_tool_response_persistence.py @@ -42,10 +42,11 @@ from google.adk.agents import Agent from google.adk.apps import App, ResumabilityConfig from google.genai import types +from tests.constants import LIVE_TEST_MODEL # Default model for live tests -DEFAULT_MODEL = "gemini-2.0-flash" +DEFAULT_MODEL = LIVE_TEST_MODEL async def collect_events(agent: ADKAgent, run_input: RunAgentInput) -> List[BaseEvent]: diff --git a/integrations/adk-middleware/python/tests/test_message_history.py b/integrations/adk-middleware/python/tests/test_message_history.py index 963536917f..d88f6b01d9 100644 --- a/integrations/adk-middleware/python/tests/test_message_history.py +++ b/integrations/adk-middleware/python/tests/test_message_history.py @@ -21,10 +21,17 @@ from ag_ui.core import ( RunAgentInput, UserMessage, AssistantMessage, ToolMessage, ReasoningMessage, - EventType, MessagesSnapshotEvent, ToolCall, FunctionCall + EventType, MessagesSnapshotEvent, ToolCall, FunctionCall, + ImageInputContent, AudioInputContent, VideoInputContent, + DocumentInputContent, InputContentUrlSource, TextInputContent, ) -from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint, adk_events_to_messages +from ag_ui_adk import ( + ADKAgent, + add_adk_fastapi_endpoint, + adk_events_to_messages, + resolve_agent_from_message_history, +) from ag_ui_adk.event_translator import _translate_function_calls_to_tool_calls @@ -51,11 +58,13 @@ def create_mock_adk_event( if text: part = MagicMock() part.text = text + part.file_data = None event.content.parts = [part] elif function_calls or function_responses: # For function calls/responses, create empty parts but content exists part = MagicMock() part.text = None + part.file_data = None event.content.parts = [part] else: event.content = None @@ -93,6 +102,7 @@ def create_mock_adk_event_with_parts( part = MagicMock() part.text = p.get("text") part.thought = p.get("thought", False) + part.file_data = None mock_parts.append(part) event.content.parts = mock_parts else: @@ -104,6 +114,40 @@ def create_mock_adk_event_with_parts( return event +def create_mock_adk_event_with_file( + event_id: str = None, + author: str = "user", + text: str = "check this file", + file_uri: str = "https://storage.googleapis.com/bucket/file.png", + mime_type: str = "image/png", +): + """Create a mock ADK user event with a text part and a file_data part.""" + event = MagicMock() + event.id = event_id or str(uuid.uuid4()) + event.author = author + event.partial = False + + text_part = MagicMock() + text_part.text = text + text_part.file_data = None + + file_part = MagicMock() + file_part.text = None + file_part.file_data = MagicMock() + file_part.file_data.file_uri = file_uri + file_part.file_data.mime_type = mime_type + + event.content = MagicMock() + event.content.parts = [text_part, file_part] + event.get_function_calls = MagicMock(return_value=[]) + event.get_function_responses = MagicMock(return_value=[]) + return event + + +# Keep old name as alias so any external callers still work +create_mock_adk_event_with_image = create_mock_adk_event_with_file + + def create_mock_function_call(name: str, args: dict = None, fc_id: str = None): """Create a mock function call object.""" fc = MagicMock() @@ -149,6 +193,128 @@ def test_user_message_conversion(self): assert messages[0].role == "user" assert messages[0].content == "Hello, how are you?" + def test_user_message_with_image_attachment(self): + """User event with text + image file_data → content list with text and image.""" + event = create_mock_adk_event_with_file( + event_id="user-img-1", + text="describe this image", + file_uri="https://storage.googleapis.com/bucket/photo.png", + mime_type="image/png", + ) + + messages = adk_events_to_messages([event]) + + assert len(messages) == 1 + msg = messages[0] + assert isinstance(msg, UserMessage) + assert isinstance(msg.content, list) + assert len(msg.content) == 2 + + text_part = msg.content[0] + assert isinstance(text_part, TextInputContent) + assert text_part.text == "describe this image" + + img_part = msg.content[1] + assert isinstance(img_part, ImageInputContent) + assert isinstance(img_part.source, InputContentUrlSource) + assert img_part.source.value == "https://storage.googleapis.com/bucket/photo.png" + assert img_part.source.mime_type == "image/png" + + def test_user_message_with_audio_attachment(self): + """User event with text + audio file_data → AudioInputContent.""" + event = create_mock_adk_event_with_file( + event_id="user-audio-1", + text="transcribe this", + file_uri="https://storage.googleapis.com/bucket/clip.mp3", + mime_type="audio/mpeg", + ) + + messages = adk_events_to_messages([event]) + + assert len(messages) == 1 + msg = messages[0] + assert isinstance(msg.content, list) + audio_part = msg.content[1] + assert isinstance(audio_part, AudioInputContent) + assert audio_part.source.value == "https://storage.googleapis.com/bucket/clip.mp3" + assert audio_part.source.mime_type == "audio/mpeg" + + def test_user_message_with_video_attachment(self): + """User event with text + video file_data → VideoInputContent.""" + event = create_mock_adk_event_with_file( + event_id="user-video-1", + text="summarize this video", + file_uri="https://storage.googleapis.com/bucket/recording.mp4", + mime_type="video/mp4", + ) + + messages = adk_events_to_messages([event]) + + assert len(messages) == 1 + msg = messages[0] + assert isinstance(msg.content, list) + video_part = msg.content[1] + assert isinstance(video_part, VideoInputContent) + assert video_part.source.value == ( + "https://storage.googleapis.com/bucket/recording.mp4" + ) + assert video_part.source.mime_type == "video/mp4" + + def test_user_message_with_document_attachment(self): + """User event with text + document file_data → DocumentInputContent.""" + event = create_mock_adk_event_with_file( + event_id="user-doc-1", + text="summarize this document", + file_uri="https://storage.googleapis.com/bucket/report.docx", + mime_type=( + "application/vnd.openxmlformats-officedocument" + ".wordprocessingml.document" + ), + ) + + messages = adk_events_to_messages([event]) + + assert len(messages) == 1 + msg = messages[0] + assert isinstance(msg.content, list) + doc_part = msg.content[1] + assert isinstance(doc_part, DocumentInputContent) + assert doc_part.source.value == ( + "https://storage.googleapis.com/bucket/report.docx" + ) + + def test_user_message_file_data_without_uri_is_skipped(self): + """file_data parts with no file_uri are filtered out; content stays a string.""" + event = create_mock_adk_event_with_file( + event_id="user-no-uri", + text="text only please", + file_uri=None, + mime_type="image/png", + ) + + messages = adk_events_to_messages([event]) + + assert len(messages) == 1 + msg = messages[0] + assert isinstance(msg, UserMessage) + # No valid media parts → content collapses back to a plain string + assert msg.content == "text only please" + + def test_user_message_without_image_stays_string(self): + """User event with text only → content remains a plain string (backward compat).""" + event = create_mock_adk_event( + event_id="user-text-1", + author="user", + text="just text, no image", + ) + + messages = adk_events_to_messages([event]) + + assert len(messages) == 1 + msg = messages[0] + assert isinstance(msg, UserMessage) + assert msg.content == "just text, no image" + def test_assistant_message_conversion(self): """Should convert model events to AssistantMessage.""" event = create_mock_adk_event( @@ -275,12 +441,14 @@ def test_none_author_treated_as_assistant(self): assert len(messages) == 1 assert isinstance(messages[0], AssistantMessage) assert messages[0].content == "Anonymous response" + assert messages[0].name is None def test_custom_agent_name_treated_as_assistant(self): """Events with custom agent names should be treated as assistant messages. This is critical: ADK agents set author to the agent's name (e.g., "my_agent"), - not "model". This test ensures we handle real ADK agent names correctly. + not "model". This test ensures we handle real ADK agent names correctly + and preserve them as AssistantMessage.name for agent resolver pinning. """ # Test various realistic agent names agent_names = ["my_assistant", "weather_agent", "code_helper", "assistant"] @@ -297,6 +465,7 @@ def test_custom_agent_name_treated_as_assistant(self): assert len(messages) == 1, f"Failed for agent_name={agent_name}" assert isinstance(messages[0], AssistantMessage), f"Failed for agent_name={agent_name}" assert messages[0].content == f"Response from {agent_name}" + assert messages[0].name == agent_name def test_model_author_treated_as_assistant(self): """Events with author='model' should still work as assistant messages.""" @@ -311,6 +480,45 @@ def test_model_author_treated_as_assistant(self): assert len(messages) == 1 assert isinstance(messages[0], AssistantMessage) assert messages[0].content == "Model response" + assert messages[0].name is None + + def test_agent_author_preserved_on_tool_call_message(self): + """Tool-call assistant messages should preserve the ADK agent author.""" + fc = create_mock_function_call( + name="do_something", + args={}, + fc_id="tool-call-1", + ) + event = create_mock_adk_event( + event_id="fc-agent", + author="subagent1", + text="", + function_calls=[fc], + ) + + messages = adk_events_to_messages([event]) + + assert len(messages) == 1 + assert isinstance(messages[0], AssistantMessage) + assert messages[0].name == "subagent1" + assert messages[0].tool_calls is not None + assert messages[0].tool_calls[0].id == "tool-call-1" + + agent = MagicMock(spec=ADKAgent) + resolved_agent = resolve_agent_from_message_history( + [ + *messages, + ToolMessage( + id="tool-result-1", + role="tool", + tool_call_id="tool-call-1", + content='{"ok": true}', + ), + ], + {"subagent1": agent}, + ) + + assert resolved_agent is agent def test_empty_text_with_function_calls(self): """Should create assistant message with just tool calls if no text.""" @@ -327,6 +535,7 @@ def test_empty_text_with_function_calls(self): assert len(messages) == 1 assert isinstance(messages[0], AssistantMessage) assert messages[0].content is None or messages[0].content == "" + assert messages[0].name is None assert len(messages[0].tool_calls) == 1 diff --git a/integrations/adk-middleware/python/tests/test_multi_instance_hitl.py b/integrations/adk-middleware/python/tests/test_multi_instance_hitl.py index c9e2e6897c..1b795273c9 100644 --- a/integrations/adk-middleware/python/tests/test_multi_instance_hitl.py +++ b/integrations/adk-middleware/python/tests/test_multi_instance_hitl.py @@ -26,6 +26,7 @@ from ag_ui_adk import ADKAgent from ag_ui_adk.session_manager import SessionManager +from tests.constants import LIVE_TEST_MODEL class TestMultiInstanceHITL: @@ -57,7 +58,7 @@ def sample_tool(self): @pytest.fixture def instance_a(self, shared_session_service): """First ADKAgent instance (Pod A). Initializes the SessionManager singleton.""" - agent = LlmAgent(name="test_agent", model="gemini-2.0-flash", instruction="Test") + agent = LlmAgent(name="test_agent", model=LIVE_TEST_MODEL, instruction="Test") return ADKAgent( adk_agent=agent, app_name="test_app", @@ -68,7 +69,7 @@ def instance_a(self, shared_session_service): @pytest.fixture def instance_b(self, shared_session_service, instance_a): """Second ADKAgent instance (Pod B). Depends on instance_a for singleton order.""" - agent = LlmAgent(name="test_agent", model="gemini-2.0-flash", instruction="Test") + agent = LlmAgent(name="test_agent", model=LIVE_TEST_MODEL, instruction="Test") return ADKAgent( adk_agent=agent, app_name="test_app", diff --git a/integrations/adk-middleware/python/tests/test_multi_lro_resume_gating.py b/integrations/adk-middleware/python/tests/test_multi_lro_resume_gating.py new file mode 100644 index 0000000000..e98cc1ca0c --- /dev/null +++ b/integrations/adk-middleware/python/tests/test_multi_lro_resume_gating.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python +"""Regression tests for resuming a turn with MULTIPLE long-running tool calls. + +When a single model turn emits more than one long-running (client / HITL) tool +call, the client returns the results independently — an instant frontend +``render`` tool resolves before a human-in-the-loop ``ask_user_choice`` tool, so +they arrive in separate submissions. Before the "all-results" gate, ag-ui-adk +treated each tool result as a standalone resume (``_handle_tool_result_submission``: +*"all tool results are standalone and should start new executions"*), so the +first result resumed the model while the other call was still unanswered. The +replayed turn then carried N function-call parts but fewer than N +function-response parts, which Gemini rejects with:: + + 400 INVALID_ARGUMENT: Please ensure that the number of function response + parts is equal to the number of function call parts of the function call + turn. + +The fix gates the resume: while any long-running call from the turn is still +pending, the arriving results are persisted (so they survive and ADK merges +them later) but the model is NOT resumed. It resumes once — when the last +result lands and ``pending_tool_calls`` is empty. + +These tests use a scripted LLM (no network) so the mismatch is caught +deterministically: the LLM records the function-call/function-response balance +of every request it receives, and we assert it is never handed a turn whose +responses don't match its calls. A single-call control test guards that the +gate does NOT defer the ordinary one-tool HITL case. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import AsyncGenerator, Dict, List, Tuple + +import pytest +import pytest_asyncio +from pydantic import Field + +from ag_ui.core import ( + AssistantMessage, + FunctionCall, + RunAgentInput, + Tool as AGUITool, + ToolCall, + ToolMessage, + UserMessage, +) + +from ag_ui_adk import ADKAgent +from ag_ui_adk.agui_toolset import AGUIToolset +from ag_ui_adk.session_manager import SessionManager + +from google.adk.agents import LlmAgent +from google.adk.apps import App, ResumabilityConfig +from google.adk.models.base_llm import BaseLlm +from google.adk.models.llm_response import LlmResponse +from google.adk.sessions import InMemorySessionService +from google.genai import types + + +TOOL_A = "render_card" # instant client tool (resolves immediately) +TOOL_B = "ask_choice" # HITL client tool (waits for the user) + + +def _count_calls_and_responses(llm_request) -> Tuple[int, int]: + """Count function_call vs function_response parts in an ADK LlmRequest.""" + fc = fr = 0 + for content in getattr(llm_request, "contents", None) or []: + for part in getattr(content, "parts", None) or []: + if getattr(part, "function_call", None) is not None: + fc += 1 + if getattr(part, "function_response", None) is not None: + fr += 1 + return fc, fr + + +class _LroThenTextLlm(BaseLlm): + """Turn 1: emit one function call per name in ``tool_names`` (all wired as + long-running client tools). Every later turn: emit final text. + + Records the ``(function_calls, function_responses)`` balance of each request + so a test can assert the model is never handed a turn whose function + responses don't match its function calls (the exact thing Gemini 400s on). + """ + + tool_names: List[str] = Field(default_factory=lambda: [TOOL_A, TOOL_B]) + turn_count: int = 0 + request_balances: List[Tuple[int, int]] = Field(default_factory=list) + + async def generate_content_async( + self, llm_request, stream: bool = False + ) -> AsyncGenerator[LlmResponse, None]: + self.turn_count += 1 + self.request_balances.append(_count_calls_and_responses(llm_request)) + if self.turn_count == 1: + yield LlmResponse( + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall(name=name, args={}) + ) + for name in self.tool_names + ], + ), + partial=False, + turn_complete=True, + ) + else: + yield LlmResponse( + content=types.Content( + role="model", + parts=[types.Part(text="All tools are done.")], + ), + partial=False, + turn_complete=True, + ) + + +def _tool(name: str) -> AGUITool: + return AGUITool( + name=name, + description=f"{name} tool", + parameters={"type": "object", "properties": {}}, + ) + + +@pytest_asyncio.fixture +async def reset_session_manager(): + SessionManager.reset_instance() + yield + SessionManager.reset_instance() + + +def _make_agent(llm: _LroThenTextLlm) -> ADKAgent: + return ADKAgent.from_app( + App( + name="multi_lro", + root_agent=LlmAgent( + name="MultiLroAgent", + model=llm, + tools=[AGUIToolset()], + instruction="Call the tools.", + ), + resumability_config=ResumabilityConfig(is_resumable=True), + ), + user_id="user_1", + session_service=InMemorySessionService(), + ) + + +async def _run(adk: ADKAgent, thread_id: str, run_id: str, messages): + """Drive one AG-UI run; return (tool_call_ids_by_name, run_error_or_None). + + The second element is the ``RunErrorEvent`` if one was emitted (falsy + otherwise), so callers can both ``assert not err`` and inspect ``err.code``. + """ + start_ids: Dict[str, str] = {} + run_error = None + async for event in adk.run( + RunAgentInput( + thread_id=thread_id, + run_id=run_id, + state={}, + messages=messages, + tools=[_tool(TOOL_A), _tool(TOOL_B)], + context=[], + forwarded_props={}, + ) + ): + name = type(event).__name__ + if name == "ToolCallStartEvent": + start_ids[event.tool_call_name] = event.tool_call_id + elif name == "RunErrorEvent": + run_error = event + return start_ids, run_error + + +def _assert_no_mismatch(llm: _LroThenTextLlm) -> None: + """The model must never be handed a turn whose function responses don't + match its function calls (a Gemini 400).""" + mismatched = [ + (fc, fr) for (fc, fr) in llm.request_balances if fr > 0 and fc != fr + ] + assert not mismatched, ( + f"Model received request(s) with mismatched function call/response " + f"counts {mismatched} (would 400 on Gemini). " + f"All balances seen: {llm.request_balances}" + ) + + +class TestMultiLroResumeGating: + @pytest.mark.asyncio + async def test_partial_result_does_not_resume_model( + self, reset_session_manager + ): + """Two long-running calls in one turn → the first result must NOT resume + the model; the model resumes once, after the second result.""" + llm = _LroThenTextLlm(model="scripted", tool_names=[TOOL_A, TOOL_B]) + adk = _make_agent(llm) + thread_id = str(uuid.uuid4()) + + # --- Run 1: one model turn emits two long-running tool calls --- + start_ids, err1 = await _run( + adk, thread_id, "r1", [UserMessage(id="u1", content="Use both tools.")] + ) + assert not err1 + assert set(start_ids) == {TOOL_A, TOOL_B}, start_ids + assert llm.turn_count == 1 + id_a, id_b = start_ids[TOOL_A], start_ids[TOOL_B] + + pending = await adk._get_pending_tool_call_ids(thread_id, "user_1") + assert set(pending or []) == {id_a, id_b}, ( + f"both LRO calls should be pending after run 1, got {pending}" + ) + + assistant = AssistantMessage( + id="a1", + content=None, + tool_calls=[ + ToolCall(id=id_a, function=FunctionCall(name=TOOL_A, arguments="{}")), + ToolCall(id=id_b, function=FunctionCall(name=TOOL_B, arguments="{}")), + ], + ) + history = [UserMessage(id="u1", content="Use both tools."), assistant] + + # --- Run 2: only tool_a's result (tool_b still pending) --- + _, err2 = await _run( + adk, + thread_id, + "r2", + history + [ToolMessage(id="t_a", content='{"ok": true}', tool_call_id=id_a)], + ) + assert not err2 + assert llm.turn_count == 1, ( + f"Model was resumed after only the first of two long-running results " + f"(turn_count={llm.turn_count}); that turn has 2 calls / 1 response " + f"→ Gemini 400." + ) + pending = await adk._get_pending_tool_call_ids(thread_id, "user_1") + assert set(pending or []) == {id_b}, ( + f"tool_a resolved, tool_b still pending; got {pending}" + ) + + # --- Run 3: tool_b's result → turn complete, resume once --- + _, err3 = await _run( + adk, + thread_id, + "r3", + history + + [ + ToolMessage(id="t_a", content='{"ok": true}', tool_call_id=id_a), + ToolMessage(id="t_b", content='{"ok": true}', tool_call_id=id_b), + ], + ) + assert not err3 + assert llm.turn_count == 2, ( + f"Model should resume exactly once, after BOTH results are in " + f"(turn_count={llm.turn_count})." + ) + pending = await adk._get_pending_tool_call_ids(thread_id, "user_1") + assert not (pending or []), f"no calls should remain pending, got {pending}" + + _assert_no_mismatch(llm) + + @pytest.mark.asyncio + async def test_single_lro_resumes_immediately(self, reset_session_manager): + """Control: a turn with ONE long-running call must resume as soon as its + result arrives — the gate must not defer the ordinary HITL case.""" + llm = _LroThenTextLlm(model="scripted", tool_names=[TOOL_A]) + adk = _make_agent(llm) + thread_id = str(uuid.uuid4()) + + start_ids, err1 = await _run( + adk, thread_id, "r1", [UserMessage(id="u1", content="Use one tool.")] + ) + assert not err1 + assert set(start_ids) == {TOOL_A}, start_ids + assert llm.turn_count == 1 + id_a = start_ids[TOOL_A] + + assistant = AssistantMessage( + id="a1", + content=None, + tool_calls=[ + ToolCall(id=id_a, function=FunctionCall(name=TOOL_A, arguments="{}")) + ], + ) + + # Submit the single result → the model resumes immediately. + _, err2 = await _run( + adk, + thread_id, + "r2", + [ + UserMessage(id="u1", content="Use one tool."), + assistant, + ToolMessage(id="t_a", content='{"ok": true}', tool_call_id=id_a), + ], + ) + assert not err2 + assert llm.turn_count == 2, ( + f"Single-call turn must resume on its result (turn_count={llm.turn_count})." + ) + pending = await adk._get_pending_tool_call_ids(thread_id, "user_1") + assert not (pending or []), f"no calls should remain pending, got {pending}" + + _assert_no_mismatch(llm) + + @pytest.mark.asyncio + async def test_orphaned_pending_call_does_not_gate_resume( + self, reset_session_manager, caplog + ): + """A leaked/orphaned ``pending_tool_calls`` entry from OUTSIDE the + arriving turn must not gate the resume forever. + + ``pending_tool_calls`` is thread-global. If a stale id lingers — e.g. a + call the model re-issued under a fresh id, orphaning the original + (observed on main) — the unscoped gate would treat every later + single-result submission as "still pending" and buffer it forever, so + the model silently stops responding. The gate is scoped to the arriving + turn's invocation, so an orphan that matches no FunctionCall in this turn + is ignored (and surfaced at WARNING for diagnosability), and the resume + proceeds. + """ + llm = _LroThenTextLlm(model="scripted", tool_names=[TOOL_A]) + adk = _make_agent(llm) + thread_id = str(uuid.uuid4()) + + # --- Run 1: a single long-running call --- + start_ids, err1 = await _run( + adk, thread_id, "r1", [UserMessage(id="u1", content="Use one tool.")] + ) + assert not err1 + id_a = start_ids[TOOL_A] + + # Inject a leaked pending entry belonging to NO call in this turn, + # simulating orphaned pending state left behind by an earlier turn. + session_id, app_name, user_id = adk._get_session_metadata(thread_id, "user_1") + await adk._add_pending_tool_call_with_context( + thread_id, "orphan-call-id", app_name, user_id + ) + pending = await adk._get_pending_tool_call_ids(thread_id, "user_1") + assert set(pending or []) == {id_a, "orphan-call-id"}, pending + + assistant = AssistantMessage( + id="a1", + content=None, + tool_calls=[ + ToolCall(id=id_a, function=FunctionCall(name=TOOL_A, arguments="{}")) + ], + ) + + # --- Run 2: submit the real call's result --- + # Pre-fix: the orphan keeps the (unscoped) pending set non-empty → the + # result is buffered forever and the model never resumes. Post-fix: the + # orphan isn't part of this turn, so it's dropped from the gate and the + # model resumes. + with caplog.at_level(logging.WARNING, logger="ag_ui_adk.adk_agent"): + _, err2 = await _run( + adk, + thread_id, + "r2", + [ + UserMessage(id="u1", content="Use one tool."), + assistant, + ToolMessage(id="t_a", content='{"ok": true}', tool_call_id=id_a), + ], + ) + assert not err2 + assert llm.turn_count == 2, ( + f"An orphaned pending entry must not gate the resume forever " + f"(turn_count={llm.turn_count})." + ) + # The orphan was surfaced (diagnosable, not a silent stall). + assert any( + r.levelno == logging.WARNING and "orphan-call-id" in r.getMessage() + for r in caplog.records + ), "expected a WARNING naming the orphaned pending id" + _assert_no_mismatch(llm) + + @pytest.mark.asyncio + async def test_buffer_failure_errors_without_mutating_state( + self, reset_session_manager + ): + """If persisting a buffered result fails, the submission must surface a + dedicated RUN_ERROR and mutate NOTHING — pending state untouched, the + message left unprocessed, the model not resumed — so the client can + resubmit cleanly. (Pre-fix the call was removed from pending and the + message marked processed before/around the append, so a failed or + no-op persist left the turn unable to ever balance with the result + silently dropped.) + """ + llm = _LroThenTextLlm(model="scripted", tool_names=[TOOL_A, TOOL_B]) + adk = _make_agent(llm) + thread_id = str(uuid.uuid4()) + + # --- Run 1: one turn emits two long-running tool calls --- + start_ids, err1 = await _run( + adk, thread_id, "r1", [UserMessage(id="u1", content="Use both tools.")] + ) + assert not err1 + id_a, id_b = start_ids[TOOL_A], start_ids[TOOL_B] + assistant = AssistantMessage( + id="a1", + content=None, + tool_calls=[ + ToolCall(id=id_a, function=FunctionCall(name=TOOL_A, arguments="{}")), + ToolCall(id=id_b, function=FunctionCall(name=TOOL_B, arguments="{}")), + ], + ) + history = [UserMessage(id="u1", content="Use both tools."), assistant] + + # Force the buffer persistence to fail. + async def _boom(*_args, **_kwargs): + raise RuntimeError("simulated persistence failure") + + original_buffer = adk._buffer_tool_results + adk._buffer_tool_results = _boom + + # --- Run 2: tool_a's result (tool_b pending) → buffer attempt fails --- + _, err2 = await _run( + adk, + thread_id, + "r2", + history + [ToolMessage(id="t_a", content='{"ok": true}', tool_call_id=id_a)], + ) + assert err2 is not None and err2.code == "TOOL_RESULT_BUFFER_ERROR", err2 + # Model not resumed. + assert llm.turn_count == 1, ( + f"buffer failure must not resume the model (turn_count={llm.turn_count})." + ) + # Mutate-nothing: BOTH calls remain pending (tool_a not removed). + pending = await adk._get_pending_tool_call_ids(thread_id, "user_1") + assert set(pending or []) == {id_a, id_b}, ( + f"buffer failure must not mutate pending state; got {pending}" + ) + # The message was not marked processed, so it is still re-extractable. + processed = adk._session_manager.get_processed_message_ids( + adk._get_session_metadata(thread_id, "user_1")[1], thread_id + ) + assert "t_a" not in processed, ( + "buffer failure must not mark the result message processed" + ) + + # --- Recovery: persistence restored, resubmit both together --- + adk._buffer_tool_results = original_buffer + _, err3 = await _run( + adk, + thread_id, + "r3", + history + + [ + ToolMessage(id="t_a", content='{"ok": true}', tool_call_id=id_a), + ToolMessage(id="t_b", content='{"ok": true}', tool_call_id=id_b), + ], + ) + assert not err3, f"recovery submission should succeed, got {err3}" + assert llm.turn_count == 2, ( + f"with all results answered the model resumes once " + f"(turn_count={llm.turn_count})." + ) + pending = await adk._get_pending_tool_call_ids(thread_id, "user_1") + assert not (pending or []), f"no calls should remain pending, got {pending}" + _assert_no_mismatch(llm) + + @pytest.mark.asyncio + async def test_user_message_while_call_pending_is_rejected_then_recovers( + self, reset_session_manager + ): + """A trailing user message that arrives while ANOTHER long-running call + from the same turn is still unanswered is rejected with a clear, + dedicated error — not resumed (which would 400) and not silently + dropped. State is left untouched so the client can resolve the pending + call and resubmit; once both results arrive together the message rides + along and the model resumes normally. + """ + llm = _LroThenTextLlm(model="scripted", tool_names=[TOOL_A, TOOL_B]) + adk = _make_agent(llm) + thread_id = str(uuid.uuid4()) + + # --- Run 1: one turn emits two long-running tool calls --- + start_ids, err1 = await _run( + adk, thread_id, "r1", [UserMessage(id="u1", content="Use both tools.")] + ) + assert not err1 + id_a, id_b = start_ids[TOOL_A], start_ids[TOOL_B] + assistant = AssistantMessage( + id="a1", + content=None, + tool_calls=[ + ToolCall(id=id_a, function=FunctionCall(name=TOOL_A, arguments="{}")), + ToolCall(id=id_b, function=FunctionCall(name=TOOL_B, arguments="{}")), + ], + ) + history = [UserMessage(id="u1", content="Use both tools."), assistant] + followup = UserMessage(id="u2", content="actually, do something else") + + # --- Run 2: tool_a's result + a trailing user message, tool_b pending --- + _, err2 = await _run( + adk, + thread_id, + "r2", + history + + [ + ToolMessage(id="t_a", content='{"ok": true}', tool_call_id=id_a), + followup, + ], + ) + # Rejected loudly with the dedicated code — not the opaque provider 400. + assert err2 is not None and err2.code == "PENDING_TOOL_CALLS", err2 + # The model was never resumed (an under-answered turn would 400). + assert llm.turn_count == 1, ( + f"Model must not resume on a turn that is still under-answered " + f"(turn_count={llm.turn_count})." + ) + # Mutate-nothing: BOTH calls remain pending (tool_a's result was not even + # consumed), so the client can resolve the rest and resubmit cleanly. + pending = await adk._get_pending_tool_call_ids(thread_id, "user_1") + assert set(pending or []) == {id_a, id_b}, ( + f"rejection must not mutate pending state; got {pending}" + ) + + # --- Run 3 (recovery): both results submitted together, message trails --- + _, err3 = await _run( + adk, + thread_id, + "r3", + history + + [ + ToolMessage(id="t_a", content='{"ok": true}', tool_call_id=id_a), + ToolMessage(id="t_b", content='{"ok": true}', tool_call_id=id_b), + followup, + ], + ) + assert not err3, f"recovery submission should succeed, got {err3}" + assert llm.turn_count == 2, ( + f"With all results answered, the model resumes once and the trailing " + f"message rides along (turn_count={llm.turn_count})." + ) + pending = await adk._get_pending_tool_call_ids(thread_id, "user_1") + assert not (pending or []), f"no calls should remain pending, got {pending}" + + _assert_no_mismatch(llm) diff --git a/integrations/adk-middleware/python/tests/test_multi_turn_conversation.py b/integrations/adk-middleware/python/tests/test_multi_turn_conversation.py index 416d80b403..23b4c52942 100644 --- a/integrations/adk-middleware/python/tests/test_multi_turn_conversation.py +++ b/integrations/adk-middleware/python/tests/test_multi_turn_conversation.py @@ -32,10 +32,11 @@ from ag_ui_adk.session_manager import SessionManager from google.adk.agents import Agent, LlmAgent from google.genai import types +from tests.constants import LIVE_TEST_MODEL # Default model for live tests -DEFAULT_MODEL = "gemini-2.0-flash" +DEFAULT_MODEL = LIVE_TEST_MODEL def create_mock_adk_event(text: str, is_final: bool = False, partial: bool = True): diff --git a/integrations/adk-middleware/python/tests/test_multimodal_e2e.py b/integrations/adk-middleware/python/tests/test_multimodal_e2e.py index a38647e374..1dba2c3f66 100644 --- a/integrations/adk-middleware/python/tests/test_multimodal_e2e.py +++ b/integrations/adk-middleware/python/tests/test_multimodal_e2e.py @@ -29,12 +29,13 @@ from ag_ui_adk import ADKAgent from ag_ui_adk.session_manager import SessionManager from google.adk.agents import LlmAgent +from tests.constants import LIVE_TEST_MODEL @pytest.fixture(autouse=True) def setup_llmock(llmock_server): """Ensure LLMock is running when no real API key is set.""" -DEFAULT_MODEL = "gemini-2.5-flash" +DEFAULT_MODEL = LIVE_TEST_MODEL # --------------------------------------------------------------------------- diff --git a/integrations/adk-middleware/python/tests/test_output_schema_suppression.py b/integrations/adk-middleware/python/tests/test_output_schema_suppression.py index 275040b4e6..16f463b91b 100644 --- a/integrations/adk-middleware/python/tests/test_output_schema_suppression.py +++ b/integrations/adk-middleware/python/tests/test_output_schema_suppression.py @@ -269,6 +269,29 @@ def test_nested_workflow_with_mixed_agents(self): result = ADKAgent._collect_output_schema_agent_names(root) assert result == {"classifier", "scorer"} + def test_workflow_graph_nodes_with_output_schema(self): + """ADK Workflow graph nodes are walked in addition to sub_agents.""" + from google.adk.agents import LlmAgent, BaseAgent + from ag_ui_adk.adk_agent import ADKAgent + + classifier = MagicMock(spec=LlmAgent) + classifier.name = "classifier" + classifier.output_schema = str + classifier.sub_agents = [] + + responder = MagicMock(spec=LlmAgent) + responder.name = "responder" + responder.output_schema = None + responder.sub_agents = [] + + workflow = MagicMock(spec=BaseAgent) + workflow.name = "wf" + workflow.sub_agents = [] + workflow.graph = MagicMock(nodes=[classifier, responder]) + + result = ADKAgent._collect_output_schema_agent_names(workflow) + assert result == {"classifier"} + def test_deeply_nested_agents(self): """output_schema agents are found at arbitrary depth.""" from google.adk.agents import LlmAgent, BaseAgent diff --git a/integrations/adk-middleware/python/tests/test_pending_tool_calls_gating.py b/integrations/adk-middleware/python/tests/test_pending_tool_calls_gating.py index b08b0f8d55..4a1b607236 100644 --- a/integrations/adk-middleware/python/tests/test_pending_tool_calls_gating.py +++ b/integrations/adk-middleware/python/tests/test_pending_tool_calls_gating.py @@ -68,8 +68,10 @@ from google.adk.sessions import DatabaseSessionService, InMemorySessionService from google.genai import types +from tests.constants import LIVE_TEST_MODEL + # Default model for live tests (Gemini Flash — cheap and fast). -DEFAULT_MODEL = "gemini-2.0-flash" +DEFAULT_MODEL = LIVE_TEST_MODEL STALE_MARKER = "The session has been modified in storage since it was loaded" @@ -664,8 +666,9 @@ async def test_hitl_client_tool_live_llm_with_database_session_service( - ``ResumabilityConfig(is_resumable=True)`` — the resumable HITL path keeps the runner alive after the LRO event, which is the configuration the original reporter was on (ADK >= 1.27) - - A real ``gemini-2.0-flash`` model that will be prompted to - call ``approve_action`` (a client/frontend tool) + - A real Gemini model (``LIVE_TEST_MODEL``, currently + ``gemini-3.5-flash``) that will be prompted to call + ``approve_action`` (a client/frontend tool) Assertions: 1. No stale-session error is logged (the #1732 regression). diff --git a/integrations/adk-middleware/python/tests/test_resumability_config.py b/integrations/adk-middleware/python/tests/test_resumability_config.py index bd2524d970..1d551e422f 100644 --- a/integrations/adk-middleware/python/tests/test_resumability_config.py +++ b/integrations/adk-middleware/python/tests/test_resumability_config.py @@ -20,6 +20,7 @@ from ag_ui_adk.session_manager import SessionManager from google.adk.apps import App, ResumabilityConfig from google.adk.agents import LlmAgent +from tests.constants import LIVE_TEST_MODEL class TestIsAdkResumable: @@ -37,7 +38,7 @@ def simple_agent(self): """Create a simple LlmAgent for testing.""" return LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You are a helpful assistant.", ) @@ -130,7 +131,7 @@ def agent_with_agui_toolset(self): """Create an agent with AGUIToolset.""" return LlmAgent( name="planner_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You are a planning assistant. Always use approve_plan tool.", tools=[AGUIToolset(tool_filter=["approve_plan"])], ) @@ -272,7 +273,7 @@ async def test_hitl_tool_call_emits_events_without_resumability(self, hitl_tool) """Test that HITL tool calls emit proper events without ResumabilityConfig.""" agent = LlmAgent( name="planner", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="""You are a planning assistant. When asked to plan something, ALWAYS use the approve_plan tool with a plan object. Example: approve_plan(plan={"topic": "requested topic", "sections": ["Section 1", "Section 2"]})""", @@ -324,7 +325,7 @@ async def test_hitl_tool_call_emits_events_with_resumability(self, hitl_tool): """Test that HITL tool calls emit proper events WITH ResumabilityConfig.""" agent = LlmAgent( name="planner", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="""You are a planning assistant. When asked to plan something, ALWAYS use the approve_plan tool with a plan object. Example: approve_plan(plan={"topic": "requested topic", "sections": ["Section 1", "Section 2"]})""", @@ -370,7 +371,7 @@ async def test_hitl_tool_result_submission_with_resumability(self, hitl_tool): """ agent = LlmAgent( name="planner", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="""You are a planning assistant. When asked to plan something, use the approve_plan tool. After receiving approval, confirm the plan was approved.""", @@ -486,7 +487,7 @@ def nested_agent_hierarchy(self): # Sub-agent with its own AGUIToolset sub_agent = LlmAgent( name="researcher", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You research topics and verify sources.", tools=[AGUIToolset(tool_filter=["verify_sources"])], ) @@ -494,7 +495,7 @@ def nested_agent_hierarchy(self): # Root agent with AGUIToolset and sub-agent root_agent = LlmAgent( name="planner", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="""You are a planning assistant. Use approve_plan to get user approval for plans. Delegate research to the researcher sub-agent.""", diff --git a/integrations/adk-middleware/python/tests/test_sequential_agent_hitl_resumption.py b/integrations/adk-middleware/python/tests/test_sequential_agent_hitl_resumption.py index 9b7316dd1b..95523227e6 100644 --- a/integrations/adk-middleware/python/tests/test_sequential_agent_hitl_resumption.py +++ b/integrations/adk-middleware/python/tests/test_sequential_agent_hitl_resumption.py @@ -30,6 +30,7 @@ from ag_ui_adk import ADKAgent from ag_ui_adk.session_manager import INVOCATION_ID_STATE_KEY, SessionManager +from tests.constants import LIVE_TEST_MODEL def _make_mock_event( @@ -104,12 +105,12 @@ def sequential_agent(self): """Create a SequentialAgent with two LlmAgent sub-agents.""" planner = LlmAgent( name="planner_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You are a planning agent. Create a plan using approve_plan.", ) executor = LlmAgent( name="executor_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You are an executor. Execute the approved plan.", ) return SequentialAgent( @@ -466,12 +467,12 @@ def llm_root_with_sequential_sub(self): """LlmAgent root with a SequentialAgent sub-agent.""" step1 = LlmAgent( name="step1_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Step 1: gather requirements.", ) step2 = LlmAgent( name="step2_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Step 2: execute the plan.", ) seq = SequentialAgent( @@ -480,7 +481,7 @@ def llm_root_with_sequential_sub(self): ) return LlmAgent( name="router", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Route to the pipeline.", sub_agents=[seq], ) @@ -659,15 +660,15 @@ def reset_session_manager(self): def test_detects_sequential_agent_two_levels_deep(self): """LlmAgent → LlmAgent → SequentialAgent should need invocation_id.""" - step1 = LlmAgent(name="step1", model="gemini-2.0-flash", instruction="Step 1") - step2 = LlmAgent(name="step2", model="gemini-2.0-flash", instruction="Step 2") + step1 = LlmAgent(name="step1", model=LIVE_TEST_MODEL, instruction="Step 1") + step2 = LlmAgent(name="step2", model=LIVE_TEST_MODEL, instruction="Step 2") pipeline = SequentialAgent(name="pipeline", sub_agents=[step1, step2]) specialist = LlmAgent( - name="specialist", model="gemini-2.0-flash", + name="specialist", model=LIVE_TEST_MODEL, instruction="Run the pipeline.", sub_agents=[pipeline], ) router = LlmAgent( - name="router", model="gemini-2.0-flash", + name="router", model=LIVE_TEST_MODEL, instruction="Route to specialist.", sub_agents=[specialist], ) app = App( @@ -680,10 +681,10 @@ def test_detects_sequential_agent_two_levels_deep(self): def test_standalone_llm_agents_still_return_false(self): """LlmAgent → LlmAgent (no composite anywhere) should NOT need invocation_id.""" target = LlmAgent( - name="target", model="gemini-2.0-flash", instruction="Handle task.", + name="target", model=LIVE_TEST_MODEL, instruction="Handle task.", ) router = LlmAgent( - name="router", model="gemini-2.0-flash", + name="router", model=LIVE_TEST_MODEL, instruction="Route.", sub_agents=[target], ) app = App( @@ -697,10 +698,10 @@ def test_detects_loop_agent_nested(self): """LlmAgent → LoopAgent should need invocation_id.""" from google.adk.agents import LoopAgent - inner = LlmAgent(name="worker", model="gemini-2.0-flash", instruction="Work") + inner = LlmAgent(name="worker", model=LIVE_TEST_MODEL, instruction="Work") loop = LoopAgent(name="retry_loop", sub_agents=[inner], max_iterations=3) root = LlmAgent( - name="root", model="gemini-2.0-flash", + name="root", model=LIVE_TEST_MODEL, instruction="Delegate.", sub_agents=[loop], ) app = App( @@ -712,8 +713,8 @@ def test_detects_loop_agent_nested(self): def test_composite_root_still_detected(self): """SequentialAgent as root should still return True (baseline).""" - step1 = LlmAgent(name="s1", model="gemini-2.0-flash", instruction="Step 1") - step2 = LlmAgent(name="s2", model="gemini-2.0-flash", instruction="Step 2") + step1 = LlmAgent(name="s1", model=LIVE_TEST_MODEL, instruction="Step 1") + step2 = LlmAgent(name="s2", model=LIVE_TEST_MODEL, instruction="Step 2") root = SequentialAgent(name="seq_root", sub_agents=[step1, step2]) app = App( name="test_composite_root", root_agent=root, diff --git a/integrations/adk-middleware/python/tests/test_session_memory.py b/integrations/adk-middleware/python/tests/test_session_memory.py index 69a1d6e022..246a6ee246 100644 --- a/integrations/adk-middleware/python/tests/test_session_memory.py +++ b/integrations/adk-middleware/python/tests/test_session_memory.py @@ -469,6 +469,95 @@ async def test_get_state_value_with_default(self, manager, mock_session_service, assert result == "default_value" + @pytest.mark.asyncio + async def test_session_read_cache_reuses_session( + self, manager, mock_session_service, mock_session + ): + """Test repeated reads in one execution share a fetched session.""" + mock_session_service.get_session.return_value = mock_session + + token = manager.start_session_read_cache() + try: + state = await manager.get_session_state( + session_id="test_session", + app_name="test_app", + user_id="test_user", + ) + value = await manager.get_state_value( + session_id="test_session", + app_name="test_app", + user_id="test_user", + key="counter", + ) + finally: + manager.stop_session_read_cache(token) + + assert state["counter"] == 42 + assert value == 42 + mock_session_service.get_session.assert_called_once_with( + session_id="test_session", + app_name="test_app", + user_id="test_user", + ) + + @pytest.mark.asyncio + async def test_session_read_cache_invalidates_after_state_update( + self, manager, mock_session_service, mock_session + ): + """Test state writes force the next read to fetch a fresh session.""" + mock_session_service.get_session.return_value = mock_session + + with patch('google.adk.events.Event'), patch('google.adk.events.EventActions'): + token = manager.start_session_read_cache() + try: + assert await manager.get_state_value( + session_id="test_session", + app_name="test_app", + user_id="test_user", + key="counter", + ) == 42 + assert await manager.update_session_state( + session_id="test_session", + app_name="test_app", + user_id="test_user", + state_updates={"counter": 43}, + ) + await manager.get_state_value( + session_id="test_session", + app_name="test_app", + user_id="test_user", + key="counter", + ) + finally: + manager.stop_session_read_cache(token) + + assert mock_session_service.get_session.call_count == 2 + + @pytest.mark.asyncio + async def test_session_read_cache_can_be_disabled( + self, manager, mock_session_service, mock_session + ): + """Test disabling the cache makes post-run reads hit the live service.""" + mock_session_service.get_session.return_value = mock_session + + token = manager.start_session_read_cache() + try: + await manager.get_session_state( + session_id="test_session", + app_name="test_app", + user_id="test_user", + ) + manager.disable_session_read_cache() + await manager.get_session_state( + session_id="test_session", + app_name="test_app", + user_id="test_user", + ) + finally: + manager.stop_session_read_cache(token) + + assert mock_session_service.get_session.call_count == 2 + @pytest.mark.asyncio async def test_get_state_value_session_not_found(self, manager, mock_session_service): """Test get state value when session doesn't exist.""" @@ -779,4 +868,4 @@ async def test_bulk_update_user_state_mixed_results(self, manager, mock_session_ # Either app1 gets True and app2 gets False, or vice versa assert len(result) == 2 assert set(result.values()) == {True, False} # One succeeded, one failed - assert mock_update.call_count == 2 \ No newline at end of file + assert mock_update.call_count == 2 diff --git a/integrations/adk-middleware/python/tests/test_stale_session_invocation_id.py b/integrations/adk-middleware/python/tests/test_stale_session_invocation_id.py index 3e19f69be5..d43cd7541e 100644 --- a/integrations/adk-middleware/python/tests/test_stale_session_invocation_id.py +++ b/integrations/adk-middleware/python/tests/test_stale_session_invocation_id.py @@ -23,6 +23,7 @@ from ag_ui_adk import ADKAgent from ag_ui_adk.session_manager import INVOCATION_ID_STATE_KEY, SessionManager +from tests.constants import LIVE_TEST_MODEL class TestInvocationIdNotPassedForStandaloneLlmAgent: @@ -39,7 +40,7 @@ def reset_session_manager(self): def simple_agent(self): return LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You are a helpful assistant.", ) @@ -473,17 +474,17 @@ def reset_session_manager(self): def llm_agent_with_transfer_targets(self): target_a = LlmAgent( name="agent_a", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You handle task A.", ) target_b = LlmAgent( name="agent_b", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="You handle task B.", ) return LlmAgent( name="router_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Route to the appropriate agent.", sub_agents=[target_a, target_b], ) diff --git a/integrations/adk-middleware/python/tests/test_temp_state_extraction.py b/integrations/adk-middleware/python/tests/test_temp_state_extraction.py index 4ad195f0eb..2aeddbfcc3 100644 --- a/integrations/adk-middleware/python/tests/test_temp_state_extraction.py +++ b/integrations/adk-middleware/python/tests/test_temp_state_extraction.py @@ -28,9 +28,10 @@ from google.adk.sessions import InMemorySessionService from google.adk.sessions.state import State as ADKState from google.adk.tools import ToolContext +from tests.constants import LIVE_TEST_MODEL -DEFAULT_MODEL = "gemini-2.0-flash" +DEFAULT_MODEL = LIVE_TEST_MODEL async def _collect(agent: ADKAgent, run_input: RunAgentInput) -> List[BaseEvent]: diff --git a/integrations/adk-middleware/python/tests/test_thought_to_thinking_integration.py b/integrations/adk-middleware/python/tests/test_thought_to_thinking_integration.py index 5d5bb2e61d..2b58ff7ba6 100644 --- a/integrations/adk-middleware/python/tests/test_thought_to_thinking_integration.py +++ b/integrations/adk-middleware/python/tests/test_thought_to_thinking_integration.py @@ -31,6 +31,7 @@ from google.adk.agents import LlmAgent from google.adk.planners import BuiltInPlanner from google.genai import types +from tests.constants import LIVE_TEST_MODEL @pytest.fixture(autouse=True) @@ -67,7 +68,7 @@ def thinking_agent(self): """Create an ADK agent with thinking enabled (include_thoughts=True).""" adk_agent = LlmAgent( name="thinking_agent", - model="gemini-2.5-flash", + model=LIVE_TEST_MODEL, instruction="""You are a careful reasoning assistant. For every question: 1. First, think through the problem systematically 2. Consider potential pitfalls or trick questions @@ -95,7 +96,7 @@ def non_thinking_agent(self): """Create an ADK agent without thinking enabled for comparison.""" adk_agent = LlmAgent( name="non_thinking_agent", - model="gemini-2.5-flash", + model=LIVE_TEST_MODEL, instruction="""You are a helpful assistant. Answer questions directly and concisely.""", ) diff --git a/integrations/adk-middleware/python/tests/test_tool_error_handling.py b/integrations/adk-middleware/python/tests/test_tool_error_handling.py index 6e7b85427f..48d4e10551 100644 --- a/integrations/adk-middleware/python/tests/test_tool_error_handling.py +++ b/integrations/adk-middleware/python/tests/test_tool_error_handling.py @@ -16,6 +16,7 @@ from ag_ui_adk.execution_state import ExecutionState from ag_ui_adk.client_proxy_tool import ClientProxyTool from ag_ui_adk.client_proxy_toolset import ClientProxyToolset +from tests.constants import LIVE_TEST_MODEL class TestToolErrorHandling: @@ -28,7 +29,7 @@ def mock_adk_agent(self): from google.adk.agents import LlmAgent return LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Test agent for error testing" ) diff --git a/integrations/adk-middleware/python/tests/test_tool_result_flow.py b/integrations/adk-middleware/python/tests/test_tool_result_flow.py index 4dd90985d8..1482571bb2 100644 --- a/integrations/adk-middleware/python/tests/test_tool_result_flow.py +++ b/integrations/adk-middleware/python/tests/test_tool_result_flow.py @@ -14,6 +14,7 @@ from ag_ui_adk import ADKAgent from ag_ui_adk.session_manager import SessionManager +from tests.constants import LIVE_TEST_MODEL class TestToolResultFlow: @@ -40,7 +41,7 @@ def mock_adk_agent(self): from google.adk.agents import LlmAgent return LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Test agent for tool flow testing" ) @@ -767,7 +768,7 @@ def mock_adk_agent(self): from google.adk.agents import LlmAgent return LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Test agent for confirm_changes filtering" ) @@ -1031,7 +1032,7 @@ def mock_adk_agent(self): from google.adk.agents import LlmAgent return LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Test agent for persistence testing" ) @@ -1413,7 +1414,7 @@ def mock_adk_agent(self): from google.adk.agents import LlmAgent return LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Test agent for DatabaseSessionService compatibility" ) diff --git a/integrations/adk-middleware/python/tests/test_tool_tracking_hitl.py b/integrations/adk-middleware/python/tests/test_tool_tracking_hitl.py index 25bbac5f6e..7d4299ca4b 100644 --- a/integrations/adk-middleware/python/tests/test_tool_tracking_hitl.py +++ b/integrations/adk-middleware/python/tests/test_tool_tracking_hitl.py @@ -13,6 +13,7 @@ from ag_ui_adk import ADKAgent from ag_ui_adk.execution_state import ExecutionState +from tests.constants import LIVE_TEST_MODEL class TestHITLToolTracking: @@ -32,7 +33,7 @@ def mock_adk_agent(self): from google.adk.agents import LlmAgent return LlmAgent( name="test_agent", - model="gemini-2.0-flash", + model=LIVE_TEST_MODEL, instruction="Test agent" ) @@ -201,6 +202,58 @@ async def mock_run_adk_in_background(*args, **kwargs): execution = adk_middleware._active_executions[("test_thread", "test_user")] assert execution.is_complete + @pytest.mark.asyncio + async def test_parent_cleanup_drops_stale_read_cache( + self, adk_middleware, sample_tool + ): + """The parent cleanup read must not use its pre-run session cache.""" + input_data = RunAgentInput( + thread_id="test_thread", + run_id="run_1", + messages=[UserMessage(id="1", role="user", content="Test")], + tools=[sample_tool], + context=[], + state={}, + forwarded_props={}, + ) + + cache_disabled = False + original_disable = ( + adk_middleware._session_manager.disable_session_read_cache + ) + + def disable_session_read_cache(): + nonlocal cache_disabled + cache_disabled = True + original_disable() + + async def mock_has_pending_tool_calls(*_args, **_kwargs): + return cache_disabled + + async def mock_run_adk_in_background(*args, **kwargs): + await kwargs["event_queue"].put(None) + + with patch.object( + adk_middleware._session_manager, + "disable_session_read_cache", + side_effect=disable_session_read_cache, + ), patch.object( + adk_middleware, + "_has_pending_tool_calls", + side_effect=mock_has_pending_tool_calls, + ), patch.object( + adk_middleware, + "_run_adk_in_background", + side_effect=mock_run_adk_in_background, + ): + async for _event in adk_middleware._start_new_execution( + input_data, + ): + pass + + assert cache_disabled + assert ("test_thread", "test_user") in adk_middleware._active_executions + @pytest.mark.asyncio async def test_session_not_cleaned_up_with_pending_tools(self, mock_adk_agent, sample_tool): """Test that executions with pending tool calls are not cleaned up.""" diff --git a/integrations/adk-middleware/python/tests/test_utils_converters.py b/integrations/adk-middleware/python/tests/test_utils_converters.py index 12f1ea1b05..69e4cd9281 100644 --- a/integrations/adk-middleware/python/tests/test_utils_converters.py +++ b/integrations/adk-middleware/python/tests/test_utils_converters.py @@ -358,10 +358,43 @@ def test_convert_assistant_message_with_text(self): assert len(adk_events) == 1 event = adk_events[0] assert event.id == "assistant_1" - assert event.author == "assistant" + assert event.author == "model" assert event.content.role == "model" # ADK uses "model" for assistant assert event.content.parts[0].text == "I'm doing well, thank you!" + def test_convert_named_assistant_message_uses_name_as_author(self): + """Test converting named AssistantMessage to ADK event author.""" + assistant_msg = AssistantMessage( + id="assistant_named_1", + role="assistant", + name="subagent1", + content="Handled by subagent1.", + ) + + adk_events = convert_ag_ui_messages_to_adk([assistant_msg]) + + assert len(adk_events) == 1 + event = adk_events[0] + assert event.id == "assistant_named_1" + assert event.author == "subagent1" + assert event.content.role == "model" + assert event.content.parts[0].text == "Handled by subagent1." + + def test_convert_unnamed_assistant_round_trip_does_not_synthesize_name(self): + """Test plain assistant messages round-trip without name='assistant'.""" + assistant_msg = AssistantMessage( + id="assistant_plain_1", + role="assistant", + content="Plain assistant response.", + ) + + adk_event = convert_ag_ui_messages_to_adk([assistant_msg])[0] + round_trip_message = convert_adk_event_to_ag_ui_message(adk_event) + + assert adk_event.author == "model" + assert isinstance(round_trip_message, AssistantMessage) + assert round_trip_message.name is None + def test_convert_assistant_message_with_tool_calls(self): """Test converting an AssistantMessage with tool calls.""" tool_call = ToolCall( @@ -718,8 +751,29 @@ def test_convert_assistant_event_with_text(self): assert result.id == "assistant_1" assert result.role == "assistant" assert result.content == "I can help you with that." + assert result.name is None assert result.tool_calls is None + def test_convert_agent_author_to_assistant_name(self): + """Test preserving concrete ADK agent authors as AssistantMessage.name.""" + mock_event = MagicMock() + mock_event.id = "assistant_agent_1" + mock_event.author = "subagent1" + mock_event.content = MagicMock() + + mock_part = MagicMock() + mock_part.text = "Handled by subagent1." + mock_part.function_call = None + mock_event.content.parts = [mock_part] + + result = convert_adk_event_to_ag_ui_message(mock_event) + + assert isinstance(result, AssistantMessage) + assert result.id == "assistant_agent_1" + assert result.role == "assistant" + assert result.name == "subagent1" + assert result.content == "Handled by subagent1." + def test_convert_assistant_event_with_function_call(self): """Test converting assistant event with function call.""" mock_event = MagicMock() @@ -739,6 +793,7 @@ def test_convert_assistant_event_with_function_call(self): assert isinstance(result, AssistantMessage) assert result.content is None + assert result.name is None assert len(result.tool_calls) == 1 tool_call = result.tool_calls[0] @@ -1121,4 +1176,4 @@ def test_create_error_message_exception_without_message(self): result = create_error_message(error) - assert result == "ValueError: " \ No newline at end of file + assert result == "ValueError: " diff --git a/integrations/adk-middleware/python/uv.lock b/integrations/adk-middleware/python/uv.lock index f8354ccf1b..fcf0d7c822 100644 --- a/integrations/adk-middleware/python/uv.lock +++ b/integrations/adk-middleware/python/uv.lock @@ -8,11 +8,57 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "a2a-sdk" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "culsans", marker = "python_full_version < '3.13'" }, + { name = "google-api-core" }, + { name = "googleapis-common-protos" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "json-rpc" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/7e/8ac10bbf8b15b16574355f39b17dbdf617a282c27b41c7ff2116e30336df/a2a_sdk-1.1.0.tar.gz", hash = "sha256:e8102dad1b36709dbdc3d19319e38e6dfa3b3a79c30416030eb2d482576be204", size = 375726, upload-time = "2026-05-29T09:34:43.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/ea/3a5b160cfd51c67759b08748051094d9365ceff18127633d0021950c9860/a2a_sdk-1.1.0-py3-none-any.whl", hash = "sha256:d7f5846caf18033d8bf3108b11ec827dd8dd32f867c98848ede0e39474be93be", size = 241886, upload-time = "2026-05-29T09:34:41.484Z" }, +] + +[[package]] +name = "a2ui-agent-sdk" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "a2a-sdk" }, + { name = "google-adk" }, + { name = "google-genai" }, + { name = "jsonschema" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/ed/0a67c72a3aa56b95cea95cdc921e208dbf501ccf5bf18aba310953932d62/a2ui_agent_sdk-0.2.4.tar.gz", hash = "sha256:6c92363ca028e5c75a541f913e4bb1e6aef0c217e5c7dc693bb12712069b1e23", size = 279673, upload-time = "2026-06-03T23:09:24.628Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/f1/cc3ad505425af8b5495313df3b6842697fcf1edbe879a7ae79dce983cfec/a2ui_agent_sdk-0.2.4-py3-none-any.whl", hash = "sha256:3d768c16b98216df4dbb76930b69e809c256a1d2be159d55461c6bb67b2bedab", size = 85675, upload-time = "2026-06-03T23:09:23.315Z" }, +] + +[[package]] +name = "ag-ui-a2ui-toolkit" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b1/ea7ad7f0b3d1b20388d072ffbe4416577b4d4ab5471d45dfc04791a91602/ag_ui_a2ui_toolkit-0.0.3.tar.gz", hash = "sha256:468f25473ac00d098878da54c0069b7fa27dc63b4c1ff61315d4349a324c2fb7", size = 14785, upload-time = "2026-06-09T06:18:18.163Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/75/fc87bdf81bb1bf6d0fac09179e8bb17807d1bc5b3c0e8640f32e843b0857/ag_ui_a2ui_toolkit-0.0.3-py3-none-any.whl", hash = "sha256:e0354bd361c09f342fbe671cf870cbd19fdcb1b27e7a5bb2d8a392a4f00c2ba9", size = 16739, upload-time = "2026-06-09T06:18:17.316Z" }, +] + [[package]] name = "ag-ui-adk" -version = "0.6.2" +version = "0.6.5" source = { editable = "." } dependencies = [ + { name = "a2ui-agent-sdk" }, + { name = "ag-ui-a2ui-toolkit" }, { name = "ag-ui-protocol" }, { name = "aiohttp" }, { name = "asyncio" }, @@ -27,6 +73,7 @@ dependencies = [ dev = [ { name = "black" }, { name = "flake8" }, + { name = "greenlet" }, { name = "isort" }, { name = "mypy" }, { name = "pluggy" }, @@ -38,11 +85,13 @@ dev = [ [package.metadata] requires-dist = [ + { name = "a2ui-agent-sdk", specifier = ">=0.2.4,<0.3.0" }, + { name = "ag-ui-a2ui-toolkit", specifier = ">=0.0.3" }, { name = "ag-ui-protocol", specifier = ">=0.1.15" }, - { name = "aiohttp", specifier = ">=3.12.0" }, + { name = "aiohttp", specifier = ">=3.14.1" }, { name = "asyncio", specifier = ">=3.4.3" }, { name = "fastapi", specifier = ">=0.115.2" }, - { name = "google-adk", specifier = ">=1.16.0,<2.0.0" }, + { name = "google-adk", specifier = ">=1.28.1,<3.0.0" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "sse-starlette", specifier = ">=2.1.0" }, { name = "uvicorn", specifier = ">=0.35.0" }, @@ -52,6 +101,7 @@ requires-dist = [ dev = [ { name = "black", specifier = ">=26.3.1" }, { name = "flake8", specifier = ">=7.3.0" }, + { name = "greenlet", specifier = ">=3.0" }, { name = "isort", specifier = ">=6.0.1" }, { name = "mypy", specifier = ">=1.16.1" }, { name = "pluggy", specifier = ">=1.6.0" }, @@ -84,7 +134,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -94,112 +144,143 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, - { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, - { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, - { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, - { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, - { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, - { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, - { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, - { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" }, + { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" }, + { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" }, + { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" }, + { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" }, + { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiologic" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/27/206615942005471499f6fbc36621582e24d0686f33c74b2d018fcfd4fe67/aiologic-0.16.0-py3-none-any.whl", hash = "sha256:e00ce5f68c5607c864d26aec99c0a33a83bdf8237aa7312ffbb96805af67d8b6", size = 135193, upload-time = "2025-11-27T23:48:40.099Z" }, ] [[package]] @@ -751,6 +832,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -762,11 +856,11 @@ wheels = [ [[package]] name = "docstring-parser" -version = "0.17.0" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] [[package]] @@ -943,7 +1037,7 @@ wheels = [ [[package]] name = "google-adk" -version = "1.26.0" +version = "1.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -957,6 +1051,7 @@ dependencies = [ { name = "google-cloud-bigquery" }, { name = "google-cloud-bigquery-storage" }, { name = "google-cloud-bigtable" }, + { name = "google-cloud-dataplex" }, { name = "google-cloud-discoveryengine" }, { name = "google-cloud-pubsub" }, { name = "google-cloud-secret-manager" }, @@ -991,9 +1086,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/b2/09b9ee1374b767eaba29e693b0b867fb587a9a131ea159300c9f9fa97d61/google_adk-1.26.0.tar.gz", hash = "sha256:29ec8636025848716246228b595749f785ddc83fb3982052ec92ae871f12fcd8", size = 2250703, upload-time = "2026-02-26T23:39:15.614Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/a7/8cba69e86af4f25b73f0bd4cbce9b0ca990a6a779cedee9a242264fca259/google_adk-1.35.0.tar.gz", hash = "sha256:c3f36447d29c1a3400ba45b344f232d857db9b18d1224517a00b267da1f51dff", size = 2432700, upload-time = "2026-06-10T05:32:34.778Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/a0/0ca4174ad1ad5f8a81b26e0d67bdff509e18ecc2ae79ca7a87e6f16dd394/google_adk-1.26.0-py3-none-any.whl", hash = "sha256:1a74c6b25f8f4d4098e1a01118b8eefcdf7b3741ba07993093a773bc6775b4d5", size = 2621967, upload-time = "2026-02-26T23:39:13.026Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9a/dc5192a79bea70730c9261b8ca54ee4103265a260444d3bffdd2eab47876/google_adk-1.35.0-py3-none-any.whl", hash = "sha256:f4c10f86c37e4fba157868d6884d4493bbb88a53fea00004d900dc03a3347f85", size = 2877569, upload-time = "2026-06-10T05:32:37.085Z" }, ] [[package]] @@ -1020,7 +1115,7 @@ grpc = [ [[package]] name = "google-api-python-client" -version = "2.192.0" +version = "2.197.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -1029,9 +1124,9 @@ dependencies = [ { name = "httplib2" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/d8/489052a40935e45b9b5b3d6accc14b041360c1507bdc659c2e1a19aaa3ff/google_api_python_client-2.192.0.tar.gz", hash = "sha256:d48cfa6078fadea788425481b007af33fe0ab6537b78f37da914fb6fc112eb27", size = 14209505, upload-time = "2026-03-05T15:17:01.598Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/09/081d66357118bd260f8f182cb1b2dd5bd32ca88e3714d7c93896cab946fc/google_api_python_client-2.197.0.tar.gz", hash = "sha256:32e03977eda4a66eafc6ae58dc9ec46426b6025636d5ef019c5703013eddd4e5", size = 14707398, upload-time = "2026-05-28T20:23:12.498Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/76/ec4128f00fefb9011635ae2abc67d7dacd05c8559378f8f05f0c907c38d8/google_api_python_client-2.192.0-py3-none-any.whl", hash = "sha256:63a57d4457cd97df1d63eb89c5fda03c5a50588dcbc32c0115dd1433c08f4b62", size = 14783267, upload-time = "2026-03-05T15:16:58.804Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/e9cc221fd75230974d4ef45eb72d2261feca3c110d5554215d516bfe6534/google_api_python_client-2.197.0-py3-none-any.whl", hash = "sha256:0f8b89aa75768161dd4f5092d6bcb386c13236b32e0d9a938c02f71342094d14", size = 15287302, upload-time = "2026-05-28T20:23:09.683Z" }, ] [[package]] @@ -1058,22 +1153,23 @@ requests = [ [[package]] name = "google-auth-httplib2" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, { name = "httplib2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134, upload-time = "2025-12-15T22:13:51.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/b3/f192c8bc7e41e0ebdbd95afcae4783417a34b6a6af62d22daf22c3fd38fc/google_auth_httplib2-0.4.0.tar.gz", hash = "sha256:d5b030a204b7a4b4d553ba9ca701b62481ee2b74419325580be70f7d85ffed35", size = 11161, upload-time = "2026-05-07T08:03:46.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529, upload-time = "2025-12-15T22:13:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/97/be/954c35a62b9e31de66b0a43c225c9b6bb9e0f98d6b1dc110a2308e3644f5/google_auth_httplib2-0.4.0-py3-none-any.whl", hash = "sha256:8e55cfafa3358cba85f6cad4a886138e88e158d71e7e5c9ee5936a5c1507fb91", size = 9529, upload-time = "2026-05-07T08:02:12.375Z" }, ] [[package]] name = "google-cloud-aiplatform" -version = "1.140.0" +version = "1.157.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "certifi" }, { name = "docstring-parser" }, { name = "google-api-core", extra = ["grpc"] }, { name = "google-auth" }, @@ -1087,13 +1183,14 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/14/1c223faf986afffdd61c994a10c30a04985ed5ba072201058af2c6e1e572/google_cloud_aiplatform-1.140.0.tar.gz", hash = "sha256:ea7eb1870b4cf600f8c2472102e21c3a1bcaf723d6e49f00ed51bc6b88d54fff", size = 10146640, upload-time = "2026-03-04T00:56:38.95Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/e2a5f5a8535bbc8f68729796f3fc2d68d59a72818fb44f6544edbc2592e4/google_cloud_aiplatform-1.157.0.tar.gz", hash = "sha256:ce8413ed3584c4896f7656b663214c24e91c2c89426f1c91fbd1d220ffda23af", size = 11064992, upload-time = "2026-06-10T00:19:33.643Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/5c/bb64aee2da24895d57611eed00fac54739bfa34f98ab344020a6605875bf/google_cloud_aiplatform-1.140.0-py2.py3-none-any.whl", hash = "sha256:e94493a2682b9d17efa7146a53bb3665bf1595c3394fd3d0f45d18f71623fddc", size = 8355660, upload-time = "2026-03-04T00:56:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e3/82/3ec2ba56dc1fa71ef783348a0c519721879dbc8f1e568534e6d4b4856ccd/google_cloud_aiplatform-1.157.0-py2.py3-none-any.whl", hash = "sha256:0ca499ac5648988916fc089f9e94bd99667eefba13f6936475247f4a0bf86634", size = 9200777, upload-time = "2026-06-10T00:19:30.181Z" }, ] [package.optional-dependencies] agent-engines = [ + { name = "aiohttp" }, { name = "cloudpickle" }, { name = "google-cloud-iam" }, { name = "google-cloud-logging" }, @@ -1109,7 +1206,7 @@ agent-engines = [ [[package]] name = "google-cloud-appengine-logging" -version = "1.8.0" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1118,27 +1215,27 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/38/89317773c64b5a7e9b56b9aecb2e39ac02d8d6d09fb5b276710c6892e690/google_cloud_appengine_logging-1.8.0.tar.gz", hash = "sha256:84b705a69e4109fc2f68dfe36ce3df6a34d5c3d989eee6d0ac1b024dda0ba6f5", size = 18071, upload-time = "2026-01-15T13:14:40.024Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/b9/fcafc8d2dc68975a65cdff74807547cff9b2a7b00e738d3f5ff0bd112867/google_cloud_appengine_logging-1.10.0.tar.gz", hash = "sha256:b5563e76010a36e6adf1cc489620c29ee4fb3b986b006d237e9a061eb0f0abb7", size = 17744, upload-time = "2026-06-03T14:52:40.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/66/4a9be8afb1d0bf49472478cec20fefe4f4cb3a6e67be2231f097041e7339/google_cloud_appengine_logging-1.8.0-py3-none-any.whl", hash = "sha256:a4ce9ce94a9fd8c89ed07fa0b06fcf9ea3642f9532a1be1a8c7b5f82c0a70ec6", size = 18380, upload-time = "2026-01-09T14:52:58.154Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/4eeb9f59c4e7e07e1f08704b6508249eea5760878810014e636026300416/google_cloud_appengine_logging-1.10.0-py3-none-any.whl", hash = "sha256:193675caaf062c41688a3e2c744b73614db82408bc7fb060353b6878d7134492", size = 18143, upload-time = "2026-06-03T14:51:55.174Z" }, ] [[package]] name = "google-cloud-audit-log" -version = "0.4.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/d2/ad96950410f8a05e921a6da2e1a6ba4aeca674bbb5dda8200c3c7296d7ad/google_cloud_audit_log-0.4.0.tar.gz", hash = "sha256:8467d4dcca9f3e6160520c24d71592e49e874838f174762272ec10e7950b6feb", size = 44682, upload-time = "2025-10-17T02:33:44.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/46/b971191224557091cc865b47d527e61da180e33b9397904bdefdae1dcacd/google_cloud_audit_log-0.6.0.tar.gz", hash = "sha256:4dd343683c0bb31187ebef3426803f13159e950fbea3fe60a864855cfed959b8", size = 44674, upload-time = "2026-06-03T14:52:48.095Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/25/532886995f11102ad6de290496de5db227bd3a73827702445928ad32edcb/google_cloud_audit_log-0.4.0-py3-none-any.whl", hash = "sha256:6b88e2349df45f8f4cc0993b687109b1388da1571c502dc1417efa4b66ec55e0", size = 44890, upload-time = "2025-10-17T02:30:55.11Z" }, + { url = "https://files.pythonhosted.org/packages/bc/99/27c70286bfa3503e43f845578ed5c2ab30c0cc68e525c168286f05f9a51c/google_cloud_audit_log-0.6.0-py3-none-any.whl", hash = "sha256:8c5ecbc341ad3b3daf776981f6d7fd7ab5ff5a29c5dce3172c669b570e0f6717", size = 44853, upload-time = "2026-06-03T14:52:03.775Z" }, ] [[package]] name = "google-cloud-bigquery" -version = "3.40.1" +version = "3.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1149,14 +1246,14 @@ dependencies = [ { name = "python-dateutil" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/0c/153ee546c288949fcc6794d58811ab5420f3ecad5fa7f9e73f78d9512a6e/google_cloud_bigquery-3.40.1.tar.gz", hash = "sha256:75afcfb6e007238fe1deefb2182105249321145ff921784fe7b1de2b4ba24506", size = 511761, upload-time = "2026-02-12T18:44:18.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/f5/081cf5b90adfe524ae0d671781b0d497a75a0f2601d075af518828e22d8f/google_cloud_bigquery-3.40.1-py3-none-any.whl", hash = "sha256:9082a6b8193aba87bed6a2c79cf1152b524c99bb7e7ac33a785e333c09eac868", size = 262018, upload-time = "2026-02-12T18:44:16.913Z" }, + { url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" }, ] [[package]] name = "google-cloud-bigquery-storage" -version = "2.36.2" +version = "2.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1165,14 +1262,14 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/fa/877e0059349369be38a64586b135c59ceadb87d0386084043d8c440ef929/google_cloud_bigquery_storage-2.36.2.tar.gz", hash = "sha256:ad49d8c09ad6cd82da4efe596fcfcdbc1458bf05b93915e3c5c00f1e700ae128", size = 308672, upload-time = "2026-02-19T16:03:10.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/85/c998751fb4182b84872df7eafcdd2f68e325c791102b65d416975c020020/google_cloud_bigquery_storage-2.39.0.tar.gz", hash = "sha256:d5afd90ad06cf24d9167316cca70ab5b344e880fc13031d7392aa78ee76b8bb6", size = 309852, upload-time = "2026-06-03T15:13:01.874Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/07/62dbe78ef773569be0a1d2c1b845e9214889b404e506126519b4d33ee999/google_cloud_bigquery_storage-2.36.2-py3-none-any.whl", hash = "sha256:823a73db0c4564e8ad3eedcfd5049f3d5aa41775267863b5627211ec36be2dbf", size = 304398, upload-time = "2026-02-19T16:02:55.112Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f6/4157466c10181907d07786fb41df5d0a9ff339c1770b9e2a15cfe483e845/google_cloud_bigquery_storage-2.39.0-py3-none-any.whl", hash = "sha256:8c192b6263804f7bdd6f57a17e763ba7f03fa4e53d7ecafca0187e0fd6467d48", size = 305958, upload-time = "2026-06-03T15:12:15.889Z" }, ] [[package]] name = "google-cloud-bigtable" -version = "2.35.0" +version = "2.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1180,25 +1277,43 @@ dependencies = [ { name = "google-cloud-core" }, { name = "google-crc32c" }, { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/c9/aceae21411b1a77fb4d3cde6e6f461321ee33c65fb8dc53480d4e47e1a55/google_cloud_bigtable-2.35.0.tar.gz", hash = "sha256:f5699012c5fea4bd4bdf7e80e5e3a812a847eb8f41bf8dc2f43095d6d876b83b", size = 775613, upload-time = "2025-12-17T15:18:14.303Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/2c/a62b2108459518914d75b8455dd69bac838d6bf276fe902320f5f16cf9cb/google_cloud_bigtable-2.38.0.tar.gz", hash = "sha256:0ad24f0106c2eb0f38e278b1641052e65882a4da0141d1f9ad78ea691724aaa3", size = 800955, upload-time = "2026-05-07T19:32:53.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/69/03eed134d71f6117ffd9efac2d1033bb2fa2522e9e82545a0828061d32f4/google_cloud_bigtable-2.35.0-py3-none-any.whl", hash = "sha256:f355bfce1f239453ec2bb3839b0f4f9937cf34ef06ef29e1ca63d58fd38d0c50", size = 540341, upload-time = "2025-12-17T15:18:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/46/9d/9c0a81aa9cf6c058b02d3be194d70bcd7e4bd82f631c8110560c3908dbc4/google_cloud_bigtable-2.38.0-py3-none-any.whl", hash = "sha256:9f6a4bdbefb34d0420f41c574d9805d8a63d080d10be5a176205e3b322c122a1", size = 556168, upload-time = "2026-05-07T19:32:51.48Z" }, ] [[package]] name = "google-cloud-core" -version = "2.5.0" +version = "2.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, { name = "google-auth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" }, +] + +[[package]] +name = "google-cloud-dataplex" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/41/695b333dad5c3bda1df09c0744b574d14ed1cc5f8d933863723d95476ea5/google_cloud_dataplex-2.20.0.tar.gz", hash = "sha256:cbdc55ec184a58c6d444f6d37fcc9070664a345a8e110f34dd7233ed37f92047", size = 894255, upload-time = "2026-06-03T15:28:01.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9f/ca0ca400de2a1a1dbf264a5c7b1c67deb17ddf0e941598a90da759c97751/google_cloud_dataplex-2.20.0-py3-none-any.whl", hash = "sha256:920bbc466eea3ce0168f9fefc4a16fd33e6ddb70537588666ce8e6609f1e1553", size = 691436, upload-time = "2026-06-03T15:27:10.355Z" }, ] [[package]] @@ -1218,7 +1333,7 @@ wheels = [ [[package]] name = "google-cloud-iam" -version = "2.21.0" +version = "2.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1228,14 +1343,14 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/0b/037b1e1eb601646d6f49bc06d62094c1d0996b373dcbf70c426c6c51572e/google_cloud_iam-2.21.0.tar.gz", hash = "sha256:fc560527e22b97c6cbfba0797d867cf956c727ba687b586b9aa44d78e92281a3", size = 499038, upload-time = "2026-01-15T13:15:08.243Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5f/128a1462354e0f8f0b7baff34b5a1a4e5cd7aee100d8db0eb39843b43d1d/google_cloud_iam-2.23.0.tar.gz", hash = "sha256:49246f6221026d381cff4f8d804daf1bb6416153f2504bf5ef54d4af2450b828", size = 561685, upload-time = "2026-05-07T08:04:16.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/44/02ac4e147ea034a3d641c11b54c9d8d0b80fc1ea6a8b7d6c1588d208d42a/google_cloud_iam-2.21.0-py3-none-any.whl", hash = "sha256:1b4a21302b186a31f3a516ccff303779638308b7c801fb61a2406b6a0c6293c4", size = 458958, upload-time = "2026-01-15T13:13:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ee/470f0c337a235b12c6a880df25809b8b11b33986510d66450cb5ef540a83/google_cloud_iam-2.23.0-py3-none-any.whl", hash = "sha256:a123ac45080a5c1735218a6b3db4c6e6ea12a1cdc86feec1c30ad1ede6c91fc6", size = 515952, upload-time = "2026-05-07T08:02:48.144Z" }, ] [[package]] name = "google-cloud-logging" -version = "3.14.0" +version = "3.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1249,14 +1364,14 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/ce/0d3539008dc33b436e7c5c644abc8f8a7ec5900911d14a8e34e145f0ebe5/google_cloud_logging-3.14.0.tar.gz", hash = "sha256:361e83cd692fecc7da10351f641c474591f586f234fc49394db4ba5c8c5994a7", size = 293452, upload-time = "2026-03-06T21:53:07.526Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/ba/e749846f13c8d1c6c01eb6317e8b09abc130fe67b5d72081a48d1bf96971/google_cloud_logging-3.16.0.tar.gz", hash = "sha256:08a3076b8f0f724219d6f73b2a242ef69d51e8bce226133aebe41a25f23f5400", size = 293703, upload-time = "2026-06-03T15:28:23.862Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/3e/01795fc20f1b5f8b1d1d22eeb425c9c3396046f1761c4f6b4cc7d8dcab90/google_cloud_logging-3.14.0-py3-none-any.whl", hash = "sha256:4767ebdb3b46a3052d5185a7d5cf02829d33ea12a0aab1d57221110d581b9e1a", size = 232961, upload-time = "2026-03-06T21:52:48.393Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d5/91035dd77e0033dfb00d52b2bcad1e4f7408eb931981f86a1584301670a8/google_cloud_logging-3.16.0-py3-none-any.whl", hash = "sha256:9e5bfbdfe7b5315ece00e1703a2ea25fe42ca35e0b4750127b019f50d069b01b", size = 234188, upload-time = "2026-06-03T15:27:37.407Z" }, ] [[package]] name = "google-cloud-monitoring" -version = "2.29.1" +version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1265,14 +1380,14 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/06/9fc0a34bed4221a68eef3e0373ae054de367dc42c0b689d5d917587ef61b/google_cloud_monitoring-2.29.1.tar.gz", hash = "sha256:86cac55cdd2608561819d19544fb3c129bbb7dcecc445d8de426e34cd6fa8e49", size = 404383, upload-time = "2026-02-05T18:59:13.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/9d/9522e169db3887e7f354bb9aa544a6e26c435ce19337e32432598db18c6f/google_cloud_monitoring-2.31.0.tar.gz", hash = "sha256:b4c9d3528c8643d4eb4b9d688cbb3c5914bc5f69b314ff7c5e1b47bdc073a9ae", size = 404747, upload-time = "2026-06-03T15:28:24.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/97/7c27aa95eccf8b62b066295a7c4ad04284364b696d3e7d9d47152b255a24/google_cloud_monitoring-2.29.1-py3-none-any.whl", hash = "sha256:944a57031f20da38617d184d5658c1f938e019e8061f27fd944584831a1b9d5a", size = 387922, upload-time = "2026-02-05T18:58:54.964Z" }, + { url = "https://files.pythonhosted.org/packages/55/30/aa6635296da9c1c14d2e64f64e1cacd4f4debf8ab7e646c0559545f0f70d/google_cloud_monitoring-2.31.0-py3-none-any.whl", hash = "sha256:64f3d56ead48f0a0674f650cb2828c47b936582a02a27c55f2836681a86281c3", size = 391010, upload-time = "2026-06-03T15:27:39.536Z" }, ] [[package]] name = "google-cloud-pubsub" -version = "2.35.0" +version = "2.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1285,14 +1400,14 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/ad/dde4c0b014247190a4df0dfa9c90de81b47909e22e2e442198f449a3593f/google_cloud_pubsub-2.35.0.tar.gz", hash = "sha256:2c0d1d7ccda52fa12fb73f34b7eb9899381e2fd931c7d47b10f724cdfac06f95", size = 396812, upload-time = "2026-02-05T22:29:14.584Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/2b/4bf2c17e319ff65340389565b0e1b4d72696d87802b2f5f94390fbefa73c/google_cloud_pubsub-2.39.0.tar.gz", hash = "sha256:eed65e25f57f95bf3e02d96d7ee171688b23922471f9f21b5a91ed90e1282c0f", size = 402096, upload-time = "2026-06-03T15:28:26.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/cb/b783f4e910f0ec4010d279bafce0cd1ed8a10bac41970eb5c6a6416008ab/google_cloud_pubsub-2.35.0-py3-none-any.whl", hash = "sha256:c32e4eb29e532ec784b5abb5d674807715ec07895b7c022b9404871dec09970d", size = 320973, upload-time = "2026-02-05T22:29:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/93/20/dd0b27d4ad4577c062e77ff968ca3e2d404186cd78c8a2a53a0ef5fe5389/google_cloud_pubsub-2.39.0-py3-none-any.whl", hash = "sha256:7210d691a46d7a66559696899ebe6eb731e63de29b624964b3be4dd2d12d3e19", size = 324665, upload-time = "2026-06-03T15:27:41.119Z" }, ] [[package]] name = "google-cloud-resource-manager" -version = "1.16.0" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1302,14 +1417,14 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/7f/db00b2820475793a52958dc55fe9ec2eb8e863546e05fcece9b921f86ebe/google_cloud_resource_manager-1.16.0.tar.gz", hash = "sha256:cc938f87cc36c2672f062b1e541650629e0d954c405a4dac35ceedee70c267c3", size = 459840, upload-time = "2026-01-15T13:04:07.726Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/1a/13060cabf553d52d151d2afc26b39561e82853380d499dd525a0d422d9f0/google_cloud_resource_manager-1.17.0.tar.gz", hash = "sha256:0f486b62e2c58ff992a3a50fa0f4a96eef7750aa6c971bb373398ccb91828660", size = 464971, upload-time = "2026-03-26T22:17:29.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/ff/4b28bcc791d9d7e4ac8fea00fbd90ccb236afda56746a3b4564d2ae45df3/google_cloud_resource_manager-1.16.0-py3-none-any.whl", hash = "sha256:fb9a2ad2b5053c508e1c407ac31abfd1a22e91c32876c1892830724195819a28", size = 400218, upload-time = "2026-01-15T13:02:47.378Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f7/661d7a9023e877a226b5683429c3662f75a29ef45cb1464cf39adb689218/google_cloud_resource_manager-1.17.0-py3-none-any.whl", hash = "sha256:e479baf4b014a57f298e01b8279e3290b032e3476d69c8e5e1427af8f82739a5", size = 404403, upload-time = "2026-03-26T22:15:26.57Z" }, ] [[package]] name = "google-cloud-secret-manager" -version = "2.26.0" +version = "2.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1319,21 +1434,23 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/9c/a6c7144bc96df77376ae3fcc916fb639c40814c2e4bba2051d31dc136cd0/google_cloud_secret_manager-2.26.0.tar.gz", hash = "sha256:0d1d6f76327685a0ed78a4cf50f289e1bfbbe56026ed0affa98663b86d6d50d6", size = 277603, upload-time = "2025-12-18T00:29:31.065Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/7c/5c88cdde9664f6c75fb68aa11e0af4309a92bef38dd38df0456ffb0f469b/google_cloud_secret_manager-2.29.0.tar.gz", hash = "sha256:ee64133af8fdb3780affb65ec6ccf10ab15a0113d8edeba388665f4be87ce1be", size = 278437, upload-time = "2026-06-03T16:13:43.149Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/30/a58739dd12cec0f7f761ed1efb518aed2250a407d4ed14c5a0eeee7eaaf9/google_cloud_secret_manager-2.26.0-py3-none-any.whl", hash = "sha256:940a5447a6ec9951446fd1a0f22c81a4303fde164cd747aae152c5f5c8e6723e", size = 223623, upload-time = "2025-12-18T00:29:29.311Z" }, + { url = "https://files.pythonhosted.org/packages/b6/c2/fc3275bc42a522757cb5141d7dae51f048b93d2f5fe4574fcee5392cef03/google_cloud_secret_manager-2.29.0-py3-none-any.whl", hash = "sha256:21bac2d0adb0bb3c13c346d7223832f197c2266534528a1bf1402774e06395a3", size = 225042, upload-time = "2026-06-03T16:12:20.162Z" }, ] [[package]] name = "google-cloud-spanner" -version = "3.63.0" +version = "3.68.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, { name = "google-cloud-core" }, { name = "google-cloud-monitoring" }, { name = "grpc-google-iam-v1" }, { name = "grpc-interceptor" }, + { name = "grpcio" }, { name = "mmh3" }, { name = "opentelemetry-api" }, { name = "opentelemetry-resourcedetector-gcp" }, @@ -1343,14 +1460,14 @@ dependencies = [ { name = "protobuf" }, { name = "sqlparse" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/ee/9ae0794d32ec271b2b2326f17d977d29801e5b960e7a0f03d721aeffe824/google_cloud_spanner-3.63.0.tar.gz", hash = "sha256:e2a4fb3bdbad4688645f455d498705d3f935b7c9011f5c94c137b77569b47a62", size = 729522, upload-time = "2026-02-13T07:35:13.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/2d/b857929745f57bb5b90f44970c02fdfbfb1184505ce4aa6e6c32550afb5f/google_cloud_spanner-3.68.0.tar.gz", hash = "sha256:90c55751cfc35bd58554c5715eab8be544095e21e40a805eb4d0c61a2bf07091", size = 904630, upload-time = "2026-06-12T18:03:27.665Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/72/e16c4fe5a7058c5526461ade670a4bec0922bc02c2690df27300e9955925/google_cloud_spanner-3.63.0-py3-none-any.whl", hash = "sha256:6ffae0ed589bbbd2d8831495e266198f3d069005cfe65c664448c9a727c88e7b", size = 518799, upload-time = "2026-02-13T07:35:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/df/f4/02ff12ebd23bb5af763b2b165deffe0dc78f933921903eb394a6ce4e0ed3/google_cloud_spanner-3.68.0-py3-none-any.whl", hash = "sha256:ad4aaf15e718fe0c54effbf510e1d9c7259f1252194c7192107848b06d8d2af8", size = 620018, upload-time = "2026-06-12T18:03:10.159Z" }, ] [[package]] name = "google-cloud-speech" -version = "2.37.0" +version = "2.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1359,14 +1476,14 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/f4/ba24128f860639ac7ddef3c1bd2f44b390f3bb0386dda65b3a65948beeed/google_cloud_speech-2.37.0.tar.gz", hash = "sha256:1b2debf721954f1157fb2631d19b29fbeeba5736e58b71aaf10734d6365add59", size = 402950, upload-time = "2026-02-27T14:12:59.384Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/c1/5dc9795314f4aefea0b01b02e9f5486a198341ecc15fe47f89a61c68df63/google_cloud_speech-2.40.0.tar.gz", hash = "sha256:e89e688e4ce0b926754038bf992d0d0f065c5f1c3503bb20e6c46d08b63658fc", size = 404366, upload-time = "2026-06-03T16:13:59.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c5/7a0a0f6b64cd5b23a4d573d820b03b9569730a9d3dfe5aedb00f8e8a914f/google_cloud_speech-2.37.0-py3-none-any.whl", hash = "sha256:370abd51244ffc68062d655d3063e083fad525416e0cb31737f4804e3cd8588c", size = 343295, upload-time = "2026-02-27T14:12:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/cc/78/afeca8d597fab54bdd823f857aad15d6f9c4628ff3cb72aa237d01700721/google_cloud_speech-2.40.0-py3-none-any.whl", hash = "sha256:7cc0302b3b9ca33d2eae9669da94a44316601a240942895362ac70e765b9f39c", size = 345427, upload-time = "2026-06-03T16:12:40.909Z" }, ] [[package]] name = "google-cloud-storage" -version = "3.9.0" +version = "3.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -1376,14 +1493,14 @@ dependencies = [ { name = "google-resumable-media" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/b1/4f0798e88285b50dfc60ed3a7de071def538b358db2da468c2e0deecbb40/google_cloud_storage-3.9.0.tar.gz", hash = "sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc", size = 17298544, upload-time = "2026-02-02T13:36:34.119Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/72/86f94e1639a8bcd9d33e8e01b49afcaa1c3a13bda7683c681717e0901e15/google_cloud_storage-3.12.0.tar.gz", hash = "sha256:03ae9847c6babb368f35f054126b8a08cbc0e3266efb990eb17b9926a45cf3be", size = 17338620, upload-time = "2026-06-12T18:03:29.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/0b/816a6ae3c9fd096937d2e5f9670558908811d57d59ddf69dd4b83b326fd1/google_cloud_storage-3.9.0-py3-none-any.whl", hash = "sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066", size = 321324, upload-time = "2026-02-02T13:36:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/a89eaebd2f9db5f92ddcc8e4f23c266be1dbd11058bb83451d8dd029f34c/google_cloud_storage-3.12.0-py3-none-any.whl", hash = "sha256:3880773754ddf7c27567b04e2a4d193950b6b99429f37b9097d873686e95b09c", size = 340605, upload-time = "2026-06-12T18:03:12.677Z" }, ] [[package]] name = "google-cloud-trace" -version = "1.18.0" +version = "1.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1392,9 +1509,9 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/34/b1883f4682f1681941100df0e411cb0185013f7c349489ab1330348d7c5c/google_cloud_trace-1.18.0.tar.gz", hash = "sha256:46d42b90273da3bc4850bb0d6b9a205eb826a54561ff1b30ca33cc92174c3f37", size = 103347, upload-time = "2026-01-15T13:04:56.441Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/7b/c2a5848c4722373c92b500b65e6308ad89ca0c7c01054e0d948c58c107f2/google_cloud_trace-1.19.0.tar.gz", hash = "sha256:58293c6efcee6c74bb854ff01b008823bef66845c14f15ffa5209d545098a65d", size = 103875, upload-time = "2026-03-26T22:18:18.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/15/366fd8b028a50a9018c933270d220a4e53dca8022ce9086618b72978ab90/google_cloud_trace-1.18.0-py3-none-any.whl", hash = "sha256:52c002d8d3da802e031fee62cd49a1baf899932d4f548a150f685af6815b5554", size = 107488, upload-time = "2026-01-15T12:17:21.519Z" }, + { url = "https://files.pythonhosted.org/packages/a4/91/0090acafa7d2caf1bf0d7222d42935e118164a539f9f9a00a814afa63fa1/google_cloud_trace-1.19.0-py3-none-any.whl", hash = "sha256:59604c4c775c40af31b367df6bada0af34518cc35ac8cfedecd43898a120c51d", size = 108454, upload-time = "2026-03-26T22:14:32.631Z" }, ] [[package]] @@ -1434,7 +1551,7 @@ wheels = [ [[package]] name = "google-genai" -version = "1.66.0" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1448,21 +1565,21 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/ba/0b343b0770d4710ad2979fd9301d7caa56c940174d5361ed4a7cc4979241/google_genai-1.66.0.tar.gz", hash = "sha256:ffc01647b65046bca6387320057aa51db0ad64bcc72c8e3e914062acfa5f7c49", size = 504386, upload-time = "2026-03-04T22:15:28.156Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/59/3ed61240ef20b3ae6ed54e82c6f8b6d1f194947bc6679679dd6cdb037594/google_genai-1.75.0.tar.gz", hash = "sha256:56bac3991b311c93f980c0a2abcd287b672146905df1fbd71c92ed633d5a07cf", size = 539039, upload-time = "2026-05-04T22:48:54.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/dd/403949d922d4e261b08b64aaa132af4e456c3b15c8e2a2d9e6ef693f66e2/google_genai-1.66.0-py3-none-any.whl", hash = "sha256:7f127a39cf695277104ce4091bb26e417c59bb46e952ff3699c3a982d9c474ee", size = 732174, upload-time = "2026-03-04T22:15:26.63Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b6/552d40e96da22921eb1fead7c14b00b5b5473a20e45959488660fab35ee2/google_genai-1.75.0-py3-none-any.whl", hash = "sha256:8dc4c096e7d6288c3087f6893f582fe52468932464781edb8193bd92b9fefb2c", size = 793726, upload-time = "2026-05-04T22:48:53.033Z" }, ] [[package]] name = "google-resumable-media" -version = "2.8.0" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-crc32c" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265, upload-time = "2025-11-17T15:38:06.659Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/f8/1ca5781d6be9cb9f73f7d40f4958c4bd1226a60598e3e39e1d6aaf838c4b/google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee", size = 2164570, upload-time = "2026-06-03T16:14:26.103Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340, upload-time = "2025-11-17T15:38:05.594Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d8/00c6854ac1512bb9eaf13bd3f8f28222f7674947fc510a4ff7616f2efc80/google_resumable_media-2.10.0-py3-none-any.whl", hash = "sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c", size = 81533, upload-time = "2026-06-03T16:13:12.51Z" }, ] [[package]] @@ -1500,6 +1617,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -1507,6 +1625,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -1515,6 +1634,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -1523,6 +1643,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -1531,6 +1652,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -1539,6 +1661,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -1547,16 +1670,16 @@ wheels = [ [[package]] name = "grpc-google-iam-v1" -version = "0.14.3" +version = "0.14.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos", extra = ["grpc"] }, { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, + { url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" }, ] [[package]] @@ -1573,77 +1696,77 @@ wheels = [ [[package]] name = "grpcio" -version = "1.78.0" +version = "1.81.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, - { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, - { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, - { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, - { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, - { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, - { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, - { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, - { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, - { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, - { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, - { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, - { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, - { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, - { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, - { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, - { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, - { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, - { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, - { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, - { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, - { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, - { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, - { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, - { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, - { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, - { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/d5/f2b159d8eec08be2a855ef698f5b6f7f9fdda022e4dd9e4f5d968affd678/grpcio-1.81.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:6f9a0c9c1cc15c112d1c053064fd032b64917062292c3d70aea280e02ae10b77", size = 6086868, upload-time = "2026-06-11T12:44:19.364Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/9c95232b94b219ed8b14029d9cd000e0381cafba869c451dda60af84f4ba/grpcio-1.81.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:69ef28e54fc85397f91b8c19592b8ef3d81952080366914823bd8572a2958120", size = 12062291, upload-time = "2026-06-11T12:44:27.142Z" }, + { url = "https://files.pythonhosted.org/packages/83/8b/bd9284bdd665ddf877a3e8bc2930d1bcf6ebdbae7b0da5c783dc26bd6e33/grpcio-1.81.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15641444eca4a29358107b3dceb74c1c6305c55c822fd199b458aaea4068a7fb", size = 6635242, upload-time = "2026-06-11T12:44:30.741Z" }, + { url = "https://files.pythonhosted.org/packages/60/24/78fa025517a925f1a17da71c4ef9d5f1c6f9fa65af22dfb523c5c6317a21/grpcio-1.81.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d4b2dddfc219f54f956ccd53cf76a1d338ffe68fc7f2849ec9c7feb9927ff692", size = 7332974, upload-time = "2026-06-11T12:44:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/f7/11/402295b388dd35861007f8a26a37c2e2f284212d57bdf407c31f36043746/grpcio-1.81.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca1cc11d82677b9662082e5478b7528e2b7db7beaa6bdff42bd62789d81be399", size = 6836597, upload-time = "2026-06-11T12:44:36.108Z" }, + { url = "https://files.pythonhosted.org/packages/4d/71/37b10fd4fd579ffade6e695c14e9df5e8cba9e2365b81c131da438b67c34/grpcio-1.81.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa2ba7d2ad6df4d80127cea65e5b8d5e2c3adbf153ff4804452836328aca7c54", size = 7440660, upload-time = "2026-06-11T12:44:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d5/40203f828abc83d458b634666df6df13778032f178c03845ad5a93682388/grpcio-1.81.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:592b5fee597faa91cce2dd294dd7d9a1c83d76c4dbf877e33ec1adb866b2fbed", size = 8443171, upload-time = "2026-06-11T12:44:41.678Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2c/0ed82ea35b5ec595e10444940c1db8c0e0ef57aa46bc8797d5ff838a219e/grpcio-1.81.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62481553b1793a27e9b9c3cf9e5bd483ef045ca72462592074b46d42b0c4d9b9", size = 7868905, upload-time = "2026-06-11T12:44:44.854Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/dcbdc1a68a07cc2b631c3098953794f17d75f93426a019240b90ce5423d6/grpcio-1.81.1-cp310-cp310-win32.whl", hash = "sha256:bb693b1e3d9a2f3fd228e2110daf4b5aeedb36761ca1e4282f74725f6d89f611", size = 4202215, upload-time = "2026-06-11T12:44:47.165Z" }, + { url = "https://files.pythonhosted.org/packages/75/a1/d7ab9f1f42efcb7d9e6111d38be6b367737a72ea2c534e1f55c81e1b6436/grpcio-1.81.1-cp310-cp310-win_amd64.whl", hash = "sha256:88268ca418cacea64cecb0d1d600d3c6b3a8038fcba02e1e205178c5b1f47661", size = 4936582, upload-time = "2026-06-11T12:44:49.479Z" }, + { url = "https://files.pythonhosted.org/packages/52/ea/1c2fa386b718ff493225e61cfc052ef400b4d6ffc54cbe261026432624b5/grpcio-1.81.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:d71d30f2d92f67d944631c523713934fee37292469e182ebcd2c1dd8a64ce53f", size = 6093112, upload-time = "2026-06-11T12:44:52.131Z" }, + { url = "https://files.pythonhosted.org/packages/2b/18/acf45fa8bd1bc5d7b0c2fd3dc4c209379fbd5bb396b440b68a83342226b7/grpcio-1.81.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b137f4bf3ada9dc44d411478decc6ff09a79ed30b306cd2abaa98408c3588137", size = 12074277, upload-time = "2026-06-11T12:44:55.354Z" }, + { url = "https://files.pythonhosted.org/packages/48/d7/ee86a60699b7db039f772a2c4a7e4facc7138984ff42c0130933a0063884/grpcio-1.81.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3acb384427816dd5d470f47e62137b87f74da694faa8a50147012cf40df276a", size = 6640348, upload-time = "2026-06-11T12:44:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/26/ee/d2de5e47378ffc207d476c230fea3be4d2601edbce9995f4fe45535d4896/grpcio-1.81.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f9a0ebbe45c29b5e5866593c12b78bd9035f0f0f0d4bc8361680cd580d99db49", size = 7331842, upload-time = "2026-06-11T12:45:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/23/d6/abeda5c2b896a0b341584fe5ac411bbf72e197a9a374c355fb90965e08d2/grpcio-1.81.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a37165cc80b1a368384b383e63a4c38116a10467ae44c904d2d7468c4470ec2", size = 6842229, upload-time = "2026-06-11T12:45:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/10/1c/1f0da7d590b4aeee006826ba568d0e419ca14b23e18f901a3da3e9fba613/grpcio-1.81.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6282caffb41ec326d4cb67ca9cf53b739d1b2f975a2acb498c7418e9f7d9a416", size = 7446096, upload-time = "2026-06-11T12:45:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/5c505d508f7c887aa7982d21443a4126597c80d34b0bcf40f9cec576d7f3/grpcio-1.81.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a35009284d0d3d5c2c9601c164a911b8b4331608d98a9a66d47d97bb2f522b70", size = 8445238, upload-time = "2026-06-11T12:45:10.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b2/524847365122ee509ca17bcc4e092198b700e94af7bfd5bb5e6dd9f3ee66/grpcio-1.81.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1b22c80559854b789a01fd89e8929b3798a156c0829b5282a8939f33ad4115ad", size = 7873989, upload-time = "2026-06-11T12:45:13.102Z" }, + { url = "https://files.pythonhosted.org/packages/18/fa/07c037c50b006909d1d13a5848774f8aa7b242f70dc03a035c64eea0e6db/grpcio-1.81.1-cp311-cp311-win32.whl", hash = "sha256:428bec0161b48d8cf583c068591bc0016d0d9cfff52462b72b3884861ea768c5", size = 4202223, upload-time = "2026-06-11T12:45:16.166Z" }, + { url = "https://files.pythonhosted.org/packages/41/ed/6bff15376920942fac6b95b9802752b837437172c9e8fc2d3170546b89cc/grpcio-1.81.1-cp311-cp311-win_amd64.whl", hash = "sha256:30e825f6848d9f18bba350ed6c75c1b02a0b5184474a31db9a32b1fa66fd8c79", size = 4941303, upload-time = "2026-06-11T12:45:18.724Z" }, + { url = "https://files.pythonhosted.org/packages/85/07/9a979c81738863a738dc23d65177056e71fbb2db817740ed870b33434e7a/grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115", size = 6053264, upload-time = "2026-06-11T12:45:21.017Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/539706ca0d3bd40dbad583dc56fd883da941f37556b629132da5762781b9/grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3", size = 12052560, upload-time = "2026-06-11T12:45:23.652Z" }, + { url = "https://files.pythonhosted.org/packages/e0/44/f257b7e0bd69c93b06c6cb8ac8d1b901ccb42bedabd83c1a4c77a71f8810/grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2", size = 6595983, upload-time = "2026-06-11T12:45:26.963Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f3/19782aa04c960968bef8c5539329d8e3bbc3364e2e46d19eb5e5cc5e43b7/grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7", size = 7303455, upload-time = "2026-06-11T12:45:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/dea020b6d91508cd84463917a63149ec196ee7db505d032ae43fcb3303b9/grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0", size = 6809167, upload-time = "2026-06-11T12:45:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/3030dd940408083bd32cd95d634777a71605ade4887154d93e8a89244946/grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3", size = 7412536, upload-time = "2026-06-11T12:45:35.403Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/1172a9e42b168edcafefad6115346ef619a3fc02158bb170e66ced24bcdd/grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf", size = 8408276, upload-time = "2026-06-11T12:45:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" }, + { url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" }, + { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, ] [[package]] name = "grpcio-status" -version = "1.78.0" +version = "1.81.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/cd/89ce482a931b543b92cdd9b2888805518c4620e0094409acb8c81dd4610a/grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", size = 13808, upload-time = "2026-02-06T10:01:48.034Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/26/0aa9168c87882381fd810d140c279a2490ed6aee655f0515d6f56c5ca404/grpcio_status-1.81.1.tar.gz", hash = "sha256:9389a03e746017b10f0630c064289201458f3ce01f5d7ef4b0bebc1ef6cf82ad", size = 13923, upload-time = "2026-06-11T12:58:48.636Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/8a/1241ec22c41028bddd4a052ae9369267b4475265ad0ce7140974548dc3fa/grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34", size = 14523, upload-time = "2026-02-06T10:01:32.584Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5e/5abfec5f7e89d3b7993d57cfb025ca5f968a2c18656d7fcda2b6919440b9/grpcio_status-1.81.1-py3-none-any.whl", hash = "sha256:08072fa9995f4a95c647fc6f4f85e2411573d00087bcabdf30f260114338f232", size = 14638, upload-time = "2026-06-11T12:58:31.982Z" }, ] [[package]] @@ -1743,6 +1866,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, ] +[[package]] +name = "json-rpc" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -1857,14 +1989,14 @@ wheels = [ [[package]] name = "mako" -version = "1.3.10" +version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] @@ -1963,7 +2095,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1981,9 +2113,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, ] [[package]] @@ -2323,7 +2455,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-gcp-monitoring" -version = "1.11.0a0" +version = "1.12.0a0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-cloud-monitoring" }, @@ -2331,14 +2463,14 @@ dependencies = [ { name = "opentelemetry-resourcedetector-gcp" }, { name = "opentelemetry-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/48/d1c7d2380bb1754d1eb6a011a2e0de08c6868cb6c0f34bcda0444fa0d614/opentelemetry_exporter_gcp_monitoring-1.11.0a0.tar.gz", hash = "sha256:386276eddbbd978a6f30fafd3397975beeb02a1302bdad554185242a8e2c343c", size = 20828, upload-time = "2025-11-04T19:32:14.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/f82b2858d00be6f91b917dc67ccf71688fa822448b2d26ace69b809f5835/opentelemetry_exporter_gcp_monitoring-1.12.0a0.tar.gz", hash = "sha256:2b285078cddd4af78a363a55b5478e89f7df6f15bba9139d3f484099e534df4c", size = 20839, upload-time = "2026-04-28T20:59:40.982Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/8c/03a6e73e270a9c890dbd6cc1c47c83d86b8a8a974a9168d92e043c6277cc/opentelemetry_exporter_gcp_monitoring-1.11.0a0-py3-none-any.whl", hash = "sha256:b6740cba61b2f9555274829fe87a58447b64d0378f1067a4faebb4f5b364ca22", size = 13611, upload-time = "2025-11-04T19:32:08.212Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b5/1623886d049095bb5abcec0cd67a0e40c00ff1672a25f82ed9867f88c1e7/opentelemetry_exporter_gcp_monitoring-1.12.0a0-py3-none-any.whl", hash = "sha256:1a7daf8c9350d55010fa33d2c2f646655a03a81d0d8073a2ae0e066791d6177d", size = 13608, upload-time = "2026-04-28T20:59:36.315Z" }, ] [[package]] name = "opentelemetry-exporter-gcp-trace" -version = "1.11.0" +version = "1.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-cloud-trace" }, @@ -2346,9 +2478,9 @@ dependencies = [ { name = "opentelemetry-resourcedetector-gcp" }, { name = "opentelemetry-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/9c/4c3b26e5494f8b53c7873732a2317df905abe2b8ab33e9edfcbd5a8ff79b/opentelemetry_exporter_gcp_trace-1.11.0.tar.gz", hash = "sha256:c947ab4ab53e16517ade23d6fe71fe88cf7ca3f57a42c9f0e4162d2b929fecb6", size = 18770, upload-time = "2025-11-04T19:32:15.109Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/55/32922e72d88421505383dfdba9c1ee6ad67253f94f2358f6e9dbc4ac3749/opentelemetry_exporter_gcp_trace-1.12.0.tar.gz", hash = "sha256:18c6e56fe123eed020d5005fdd819b196d64f651545bce1ca7e2e2cbaf9d343b", size = 18779, upload-time = "2026-04-28T20:59:41.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/4a/876703e8c5845198d95cd4006c8d1b2e3b129a9e288558e33133360f8d5d/opentelemetry_exporter_gcp_trace-1.11.0-py3-none-any.whl", hash = "sha256:b3dcb314e1a9985e9185cb7720b693eb393886fde98ae4c095ffc0893de6cefa", size = 14016, upload-time = "2025-11-04T19:32:09.009Z" }, + { url = "https://files.pythonhosted.org/packages/8c/68/c60e79992918eecb6de167e782c86946fdd5492bb163fe320f1a18959c3d/opentelemetry_exporter_gcp_trace-1.12.0-py3-none-any.whl", hash = "sha256:1538dab654bcb25e757ed34c94f27a2e30d90dc7deb3630f8d46d1111fcb3bad", size = 14013, upload-time = "2026-04-28T20:59:37.518Z" }, ] [[package]] @@ -2395,7 +2527,7 @@ wheels = [ [[package]] name = "opentelemetry-resourcedetector-gcp" -version = "1.11.0a0" +version = "1.12.0a0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -2403,9 +2535,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/5d/2b3240d914b87b6dd9cd5ca2ef1ccaf1d0626b897d4c06877e22c8c10fcf/opentelemetry_resourcedetector_gcp-1.11.0a0.tar.gz", hash = "sha256:915a1d6fd15daca9eedd3fc52b0f705375054f2ef140e2e7a6b4cca95a47cdb1", size = 18796, upload-time = "2025-11-04T19:32:16.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/ae/b62c5e986c9c7f908a15682ea173bcfcdc00403c0c85243ccbd30eca7fc2/opentelemetry_resourcedetector_gcp-1.12.0a0.tar.gz", hash = "sha256:d5e3f78283a272eb92547e00bbeff45b7332a34ae791a70ab4eba81af9bc3baf", size = 18797, upload-time = "2026-04-28T20:59:43.195Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6c/1e13fe142a7ca3dc6489167203a1209d32430cca12775e1df9c9a41c54b2/opentelemetry_resourcedetector_gcp-1.11.0a0-py3-none-any.whl", hash = "sha256:5d65a2a039b1d40c6f41421dbb08d5f441368275ac6de6e76a8fccd1f6acb67e", size = 18798, upload-time = "2025-11-04T19:32:10.915Z" }, + { url = "https://files.pythonhosted.org/packages/df/84/9db2999adbc41505af3e6717e8d958746778cbfc9e07ed9c670bf9d1e6db/opentelemetry_resourcedetector_gcp-1.12.0a0-py3-none-any.whl", hash = "sha256:e803688d14e2969fe816077be81f7b034368314d485863f12ce49daba7c81919", size = 18798, upload-time = "2026-04-28T20:59:39.257Z" }, ] [[package]] @@ -2614,59 +2746,59 @@ wheels = [ [[package]] name = "pyarrow" -version = "23.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/a8/24e5dc6855f50a62936ceb004e6e9645e4219a8065f304145d7fb8a79d5d/pyarrow-23.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:3fab8f82571844eb3c460f90a75583801d14ca0cc32b1acc8c361650e006fd56", size = 34307390, upload-time = "2026-02-16T10:08:08.654Z" }, - { url = "https://files.pythonhosted.org/packages/bc/8e/4be5617b4aaae0287f621ad31c6036e5f63118cfca0dc57d42121ff49b51/pyarrow-23.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3f91c038b95f71ddfc865f11d5876c42f343b4495535bd262c7b321b0b94507c", size = 35853761, upload-time = "2026-02-16T10:08:17.811Z" }, - { url = "https://files.pythonhosted.org/packages/2e/08/3e56a18819462210432ae37d10f5c8eed3828be1d6c751b6e6a2e93c286a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d0744403adabef53c985a7f8a082b502a368510c40d184df349a0a8754533258", size = 44493116, upload-time = "2026-02-16T10:08:25.792Z" }, - { url = "https://files.pythonhosted.org/packages/f8/82/c40b68001dbec8a3faa4c08cd8c200798ac732d2854537c5449dc859f55a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c33b5bf406284fd0bba436ed6f6c3ebe8e311722b441d89397c54f871c6863a2", size = 47564532, upload-time = "2026-02-16T10:08:34.27Z" }, - { url = "https://files.pythonhosted.org/packages/20/bc/73f611989116b6f53347581b02177f9f620efdf3cd3f405d0e83cdf53a83/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ddf743e82f69dcd6dbbcb63628895d7161e04e56794ef80550ac6f3315eeb1d5", size = 48183685, upload-time = "2026-02-16T10:08:42.889Z" }, - { url = "https://files.pythonhosted.org/packages/b0/cc/6c6b3ecdae2a8c3aced99956187e8302fc954cc2cca2a37cf2111dad16ce/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e052a211c5ac9848ae15d5ec875ed0943c0221e2fcfe69eee80b604b4e703222", size = 50605582, upload-time = "2026-02-16T10:08:51.641Z" }, - { url = "https://files.pythonhosted.org/packages/8d/94/d359e708672878d7638a04a0448edf7c707f9e5606cee11e15aaa5c7535a/pyarrow-23.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5abde149bb3ce524782d838eb67ac095cd3fd6090eba051130589793f1a7f76d", size = 27521148, upload-time = "2026-02-16T10:08:58.077Z" }, - { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230, upload-time = "2026-02-16T10:09:03.859Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050, upload-time = "2026-02-16T10:09:11.877Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918, upload-time = "2026-02-16T10:09:18.144Z" }, - { url = "https://files.pythonhosted.org/packages/0b/62/96459ef5b67957eac38a90f541d1c28833d1b367f014a482cb63f3b7cd2d/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", size = 47562811, upload-time = "2026-02-16T10:09:25.792Z" }, - { url = "https://files.pythonhosted.org/packages/7d/94/1170e235add1f5f45a954e26cd0e906e7e74e23392dcb560de471f7366ec/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", size = 48183766, upload-time = "2026-02-16T10:09:34.645Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/39a42af4570377b99774cdb47f63ee6c7da7616bd55b3d5001aa18edfe4f/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", size = 50607669, upload-time = "2026-02-16T10:09:44.153Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/db94101c187f3df742133ac837e93b1f269ebdac49427f8310ee40b6a58f/pyarrow-23.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", size = 27527698, upload-time = "2026-02-16T10:09:50.263Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, - { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, - { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, - { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, - { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, - { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, - { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, - { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, - { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, - { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, - { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, - { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, - { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, - { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, - { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, - { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, - { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, - { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, - { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, - { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, - { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, - { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, - { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, - { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, - { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/bf/a34fee1d624152124fa8355c42f34195ad5fe5233ce5bb87946432047d52/pyarrow-24.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb", size = 35076681, upload-time = "2026-04-21T08:51:46.845Z" }, + { url = "https://files.pythonhosted.org/packages/1d/41/64180033d7027afce12dc96d0fe1f504c6fa112190582b458acea2399530/pyarrow-24.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147", size = 36684260, upload-time = "2026-04-21T08:51:53.642Z" }, + { url = "https://files.pythonhosted.org/packages/57/02/9b9320e673dd8a99411fac78690f3df92f6dd6f59754c750110bca66d64e/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c", size = 45698566, upload-time = "2026-04-21T10:46:02.133Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/f75e91b9a64c3f33c787e263c93b871ad91b8a4a68c1d5cebddd9840e835/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041", size = 48835562, upload-time = "2026-04-21T10:46:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/a5/63/097510448e47e4091faa41c43ba92f97cecaab8f4535b56a3d149578f634/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491", size = 49394997, upload-time = "2026-04-21T10:46:18.08Z" }, + { url = "https://files.pythonhosted.org/packages/60/6b/c047d6222ab279024a062742d1807e2fbaf27bba88a98637299ff47b9236/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1", size = 51911424, upload-time = "2026-04-21T10:46:25.347Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ba/464cc70761c2a525d97ebd84e21c31ebd47f3ef4bdcee117009f51c46f24/pyarrow-24.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591", size = 27251730, upload-time = "2026-04-21T10:46:30.913Z" }, + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] [[package]] @@ -2843,16 +2975,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] @@ -2875,14 +3007,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -3042,24 +3174,24 @@ wheels = [ [[package]] name = "pywin32" -version = "311" +version = "312" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, - { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, ] [[package]] @@ -3309,76 +3441,71 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.48" +version = "2.0.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/67/1235676e93dd3b742a4a8eddfae49eea46c85e3eed29f0da446a8dd57500/sqlalchemy-2.0.48-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89", size = 2157384, upload-time = "2026-03-02T15:38:26.781Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d7/fa728b856daa18c10e1390e76f26f64ac890c947008284387451d56ca3d0/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0", size = 3236981, upload-time = "2026-03-02T15:58:53.53Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ad/6c4395649a212a6c603a72c5b9ab5dce3135a1546cfdffa3c427e71fd535/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10853a53a4a00417a00913d270dddda75815fcb80675874285f41051c094d7dd", size = 3235232, upload-time = "2026-03-02T15:52:25.654Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/58f845e511ac0509765a6f85eb24924c1ef0d54fb50de9d15b28c3601458/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fac0fa4e4f55f118fd87177dacb1c6522fe39c28d498d259014020fec9164c29", size = 3188106, upload-time = "2026-03-02T15:58:55.193Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f9/6dcc7bfa5f5794c3a095e78cd1de8269dfb5584dfd4c2c00a50d3c1ade44/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3713e21ea67bca727eecd4a24bf68bcd414c403faae4989442be60994301ded0", size = 3209522, upload-time = "2026-03-02T15:52:27.407Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/b632875ab35874d42657f079529f0745410604645c269a8c21fb4272ff7a/sqlalchemy-2.0.48-cp310-cp310-win32.whl", hash = "sha256:d404dc897ce10e565d647795861762aa2d06ca3f4a728c5e9a835096c7059018", size = 2117695, upload-time = "2026-03-02T15:46:51.389Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/9752eb2a41afdd8568e41ac3c3128e32a0a73eada5ab80483083604a56d1/sqlalchemy-2.0.48-cp310-cp310-win_amd64.whl", hash = "sha256:841a94c66577661c1f088ac958cd767d7c9bf507698f45afffe7a4017049de76", size = 2140928, upload-time = "2026-03-02T15:46:52.992Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" }, - { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" }, - { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" }, - { url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" }, - { url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" }, - { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, - { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, - { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, - { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, - { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, - { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, - { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, - { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, - { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, - { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, - { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, - { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, - { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, - { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, - { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/a9/812a775bd8c1af0966d660238d005baf25e9bced1f038c8e71f00aa637a7/sqlalchemy-2.0.50-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7af6eeb84985bf840ba779018ff9424d61ff69b52e66b8789d3c8da7bf5341b2", size = 2161617, upload-time = "2026-05-24T20:00:00.761Z" }, + { url = "https://files.pythonhosted.org/packages/d5/74/5a6bc5496e9be8f740fbf80f9e6bd4ab965c8a80870eb07ab015e360957a/sqlalchemy-2.0.50-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fe7822866f3a9fc5f3db21a290ce8961a53050115f05edf9402b6a5feb92a9f", size = 3244104, upload-time = "2026-05-24T20:07:38.158Z" }, + { url = "https://files.pythonhosted.org/packages/81/55/b260d8df2adc9bb0bf294f67b5f802ff0d84d99442b536b9efd0ea72d447/sqlalchemy-2.0.50-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e1b0f6a4dcd9b4839e2320afb5df37a6981cbc20ff9c423ae11c5537bdbd21", size = 3243039, upload-time = "2026-05-24T20:14:23.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/6d/58714005cbf370f16c3f30d30324a43be10069efcfe764f7236a2e851947/sqlalchemy-2.0.50-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e195687f1af431c9515416288373b323b6eb599f774409814e89e9d603a56e39", size = 3195017, upload-time = "2026-05-24T20:07:40.086Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/67527fee039bd3e1a6ce3f03d2b62fd87ab9099c17052810d79496727b66/sqlalchemy-2.0.50-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ea1a8a2db4b2217d456c8d7a873bfc605f06fe3584d315264ea18c2a17585d0b", size = 3215308, upload-time = "2026-05-24T20:14:26.034Z" }, + { url = "https://files.pythonhosted.org/packages/94/b2/dd3155a6a6706cb89adecf5ee6e0512f7b0ee5cf3e6f4cde67d3c20ebfda/sqlalchemy-2.0.50-cp310-cp310-win32.whl", hash = "sha256:68b154b08088b4ec32bb4d2958bfbb50e57549f91a4cd3e7f928e3553ed69031", size = 2121637, upload-time = "2026-05-24T20:08:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/93/a1/a09c463ee3e7764b5ce5bd19a7f0b6eefbde62e637439ab58498cdbd6b47/sqlalchemy-2.0.50-cp310-cp310-win_amd64.whl", hash = "sha256:66e374271ecb7101273f57af1a62446a953d327eec4f8089147de57c591bbacc", size = 2144673, upload-time = "2026-05-24T20:08:07.936Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5d/3172686af1770e4de2805f919a51441085f589ddadf3dd76ec582f84f497/sqlalchemy-2.0.50-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508", size = 2161366, upload-time = "2026-05-24T20:00:02.061Z" }, + { url = "https://files.pythonhosted.org/packages/0f/90/e98dedea3c3e663a17afcd003a34ba45efdac2cea3b6f2e4585e2b1e2537/sqlalchemy-2.0.50-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3", size = 3318926, upload-time = "2026-05-24T20:07:42.369Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/501308c2babb62c11753ecb4ee88ba9eef019419a4d6cbf7cb13e2bad353/sqlalchemy-2.0.50-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c", size = 3319199, upload-time = "2026-05-24T20:14:28.551Z" }, + { url = "https://files.pythonhosted.org/packages/ac/39/d88996c5e03ed6248c3a788d20f0b8d8b376b9f8a495e4bab9df7c72d2f8/sqlalchemy-2.0.50-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4", size = 3270301, upload-time = "2026-05-24T20:07:44.917Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/1ae0e65161b51cc43e5ca75430ef79d80e23b5042d645586c2c342c3b92e/sqlalchemy-2.0.50-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86", size = 3293465, upload-time = "2026-05-24T20:14:30.501Z" }, + { url = "https://files.pythonhosted.org/packages/83/29/17c0003f2c0dfa6d1b97672475707e3ec5980db09defd7fa20beb6833bbd/sqlalchemy-2.0.50-cp311-cp311-win32.whl", hash = "sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22", size = 2120694, upload-time = "2026-05-24T20:08:09.237Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/280d00654cc19d1fccf236fa5070f6dd04b84dde6f1b2e637bde0ff340a7/sqlalchemy-2.0.50-cp311-cp311-win_amd64.whl", hash = "sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5", size = 2145315, upload-time = "2026-05-24T20:08:10.952Z" }, + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, ] [[package]] name = "sqlalchemy-spanner" -version = "1.17.2" +version = "1.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alembic" }, { name = "google-cloud-spanner" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/29/21698bb83e542f32e3581886671f39d94b1f7e8b190c24a8bfa994e62fd6/sqlalchemy_spanner-1.17.2.tar.gz", hash = "sha256:56ce4da7168a27442d80ffd71c29ed639b5056d7e69b1e69bb9c1e10190b67c4", size = 82745, upload-time = "2025-12-15T23:30:08.622Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/b6/ce05f1b8a9c486bbac26d7348625c78ba6e751decc25009f28880504c29d/sqlalchemy_spanner-1.19.0.tar.gz", hash = "sha256:834cec66fb418e5085a44c68cee570c594c66dd8535b67dd5e8be3571d172136", size = 82914, upload-time = "2026-06-03T16:14:49.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/87/05be45a086116cea32cfa00fa0059d31b5345360dba7902ee640a1db793b/sqlalchemy_spanner-1.17.2-py3-none-any.whl", hash = "sha256:18713d4d78e0bf048eda0f7a5c80733e08a7b678b34349496415f37652efb12f", size = 31917, upload-time = "2025-12-15T23:30:07.356Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/8150a0022174d02956b0f6b586777006af2fc794b1baa72748a11fde039f/sqlalchemy_spanner-1.19.0-py3-none-any.whl", hash = "sha256:3367a89388d9b7106111fc48c7fac441163602c414ad157f62e18b5705cc760e", size = 31919, upload-time = "2026-06-03T16:13:39.522Z" }, ] [[package]] @@ -3644,6 +3771,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/8b/84bc1ea68b620fe0e2696a8cff07e82f4b962d952ab14efee8955997bb70/wrapt-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0f68f478004475d97906686e702ddbddeaf717c0b68ad2794384308f2dc713ae", size = 80093, upload-time = "2026-05-22T14:47:27.074Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/64ec81194a0bc708d9720174c998c8a32116e82b5b32c04e20a7fe01176c/wrapt-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e422b2d647a65d6b080cad5accd09055d3809bdff00c76fba8dca00ca935572a", size = 81183, upload-time = "2026-05-22T14:47:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/3d186944aae923631d1def58f4c4ff8f0b6309906afc0b6978de3e69b3e0/wrapt-2.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:036dfb40128819a751c6f451c6b9c10172c49e4c401aebcdb8ecf2aec1683598", size = 152494, upload-time = "2026-05-22T14:47:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/01/d1/6b3d0ea995b867d2862aad5619bd5e17de09a9d64a821f46832dcd272d40/wrapt-2.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ac16c081bebfd15d8e4dfa5bdc805990bbd52249ecff22530da7a129d6120b", size = 154310, upload-time = "2026-05-22T14:47:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4b/37ecb90a8c3753e580327fb40731a984b754e3df65d2ef932bf359fe4adc/wrapt-2.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07be671fa8875971222b0ba9059ed8b4dc738631122feba17c93aa36b4213e9a", size = 149002, upload-time = "2026-05-22T14:47:34.021Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/918884d9dfa84d0d135b42a51c00910f5c5447fe7a5e211a8e16ac324dd4/wrapt-2.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93fc2bf40cd7f4a0256010dce073d44eeb4a351b9bca94d0477ce2b6e62532b3", size = 153185, upload-time = "2026-05-22T14:47:35.722Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/382299d8ced610b29b59b099a89eda821e8c489aa152b7183748ac83f32a/wrapt-2.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba519b2d765df9871a25879e6f7fa78948ea59a2a31f9c1a257e34b651994afc", size = 148040, upload-time = "2026-05-22T14:47:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/62a79b79e35bbebb1207ca5d15b81192f37f20cc5659cf4e3ce955b7fcc8/wrapt-2.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9011395be8db1827d106c6449b4bb6dd17e331ff6ec521f227e4588f1c78e46f", size = 151773, upload-time = "2026-05-22T14:47:38.713Z" }, + { url = "https://files.pythonhosted.org/packages/a1/db/95c152151d206d4b430516c89725306e92484072f38e65492afde63f6d19/wrapt-2.2.1-cp310-cp310-win32.whl", hash = "sha256:a8f7176b83664af44567e9cc06e0d3827823fcc1a5e52307ebb8ac3aa95860b9", size = 77393, upload-time = "2026-05-22T14:47:40.061Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/882d50452c6fbd13f24fe5d2644b97cdad2565a7e1522cbb6312de8a52cf/wrapt-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:d7f513d3185e6fec82d0c3518f2e6365d8b4e49f5f45f29640d5162d56a23b54", size = 80350, upload-time = "2026-05-22T14:47:41.194Z" }, + { url = "https://files.pythonhosted.org/packages/58/0f/148376523b4e370692286a9ba14d5715cf3c5b86da3bd3630926367b6b73/wrapt-2.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:44255c84bc57554fed822e83e70036b51afa9edb56fc7ca56c54410ece7898c9", size = 79149, upload-time = "2026-05-22T14:47:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + [[package]] name = "yarl" version = "1.23.0" diff --git a/integrations/adk-middleware/typescript/LICENSE b/integrations/adk-middleware/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/adk-middleware/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/adk-middleware/typescript/README.md b/integrations/adk-middleware/typescript/README.md index 99b2058941..cf70a92f18 100644 --- a/integrations/adk-middleware/typescript/README.md +++ b/integrations/adk-middleware/typescript/README.md @@ -1,4 +1,89 @@ -# ADK Middleware for AG-UI Protocol +# @ag-ui/adk + +AG-UI integration for [Google ADK](https://google.github.io/adk-docs/) (Agent Development Kit). This package ships a thin TypeScript client, `ADKAgent`, that connects an AG-UI front end to an ADK-backed agent endpoint served by the companion Python middleware (`ag_ui_adk`). + +`ADKAgent` extends `HttpAgent` from `@ag-ui/client`, so it speaks the full AG-UI protocol over HTTP/SSE out of the box. On top of that it adds a `getCapabilities()` method that fetches and validates the agent's advertised capabilities. + +## Installation + +```bash +npm install @ag-ui/adk +# or +pnpm add @ag-ui/adk +``` + +### Peer Dependencies + +- `@ag-ui/client` (>=0.0.55) +- `@ag-ui/core` (>=0.0.55) +- `rxjs` (7.8.1) + +## TypeScript Client Usage + +### Connect to an ADK-backed agent + +```typescript +import { ADKAgent } from "@ag-ui/adk"; + +// `threadId` and `initialMessages` are constructor options (AgentConfig), +// not run-time parameters. +const agent = new ADKAgent({ + url: "http://localhost:8000/chat", + threadId: "thread-123", + initialMessages: [{ id: "1", role: "user", content: "Hello!" }], +}); + +// `run(input)` returns an RxJS Observable of AG-UI events. It takes a full +// `RunAgentInput`, so reuse the agent's `threadId`/`messages`/`state` and +// supply the remaining required fields. +agent + .run({ + threadId: agent.threadId, + runId: "run-456", + messages: agent.messages, + state: agent.state, + tools: [], + context: [], + forwardedProps: {}, + }) + .subscribe({ + next: (event) => { + switch (event.type) { + case "TEXT_MESSAGE_CONTENT": + process.stdout.write(event.delta); + break; + case "TOOL_CALL_START": + console.log("Calling tool:", event.toolCallName); + break; + } + }, + error: (err) => console.error("Run failed:", err), + complete: () => console.log("Done"), + }); +``` + +`ADKAgent` accepts the same configuration as `HttpAgent` (`url`, `headers`, `agentId`, `threadId`, `initialMessages`, etc.). Only `run(input)` is the Observable API — it takes a full `RunAgentInput` and returns an `Observable`. The Promise-based `runAgent(parameters?, subscriber?)` is the alternative: it manages `threadId`/`messages`/`state` for you, accepts only `runId`/`tools`/`context`/`forwardedProps`/`resume`, and resolves to a `RunAgentResult` (it is not subscribable). + +### Discover agent capabilities + +`getCapabilities()` issues a `GET` against the agent's `/capabilities` endpoint (derived from the configured `url`), parses the JSON response, and validates it against the AG-UI `AgentCapabilitiesSchema`. It rejects on HTTP errors, unparseable bodies, or schema-invalid responses. + +```typescript +import { ADKAgent } from "@ag-ui/adk"; + +const agent = new ADKAgent({ url: "http://localhost:8000/chat" }); + +const capabilities = await agent.getCapabilities(); +console.log(capabilities); +``` + +To customize how capabilities are fetched (auth, headers, credentials, or the URL itself), subclass `ADKAgent` and override the protected `capabilitiesUrl()` and/or `capabilitiesRequestInit()` methods. + +--- + +The remainder of this document covers the companion **Python middleware** (`ag_ui_adk`) that serves the ADK agent endpoint the TypeScript client above connects to. + +## Python Middleware This Python middleware enables [Google ADK](https://google.github.io/adk-docs/) agents to be used with the AG-UI Protocol, providing a bridge between the two frameworks. @@ -24,7 +109,7 @@ To use this integration you need to: cd integrations/adk-middleware/python ``` -3. Install the `adk-middleware` package from the local directory. For example, +3. Install the `ag_ui_adk` package from the local directory. For example, ```bash pip install . @@ -37,17 +122,12 @@ To use this integration you need to: ``` This installs the package from the current directory which contains: - - `src/adk_middleware/` - The middleware source code + - `src/ag_ui_adk/` - The middleware source code - `examples/` - Example servers and agents - `tests/` - Test suite -4. Install the requirements for the `examples`, for example: - - ```bash - uv pip install -r requirements.txt - ``` - -5. Run the example fast_api server. +4. Run the example FastAPI server. The example project pulls in its own + dependencies (including the local middleware) via `uv sync`. ```bash export GOOGLE_API_KEY= @@ -56,42 +136,31 @@ To use this integration you need to: uv run dev ``` -6. Open another terminal in the root directory of the ag-ui repository clone. +5. Open another terminal in the root directory of the ag-ui repository clone. -7. Start the integration ag-ui dojo: +6. Start the integration ag-ui dojo: ```bash pnpm install && pnpm run dev ``` -8. Visit [http://localhost:3000/adk-middleware](http://localhost:3000/adk-middleware). +7. Visit [http://localhost:3000/adk-middleware](http://localhost:3000/adk-middleware). -9. Select View `ADK Middleware` from the sidebar. +8. Select View `ADK Middleware` from the sidebar. ### Development Setup -If you want to contribute to ADK Middleware development, you'll need to take some additional steps. You can either use the following script of the manual development setup. +If you want to contribute to ADK Middleware development, install the package in +editable mode with its dev dependencies: ```bash -# From the adk-middleware directory -chmod +x setup_dev.sh -./setup_dev.sh -``` - -### Manual Development Setup - -```bash -# Create virtual environment -python -m venv venv -source venv/bin/activate +# From the integrations/adk-middleware/python directory # Install this package in editable mode pip install -e . # For development (includes testing and linting tools) pip install -e ".[dev]" -# OR -pip install -r requirements-dev.txt ``` This installs the ADK middleware in editable mode for development. @@ -99,11 +168,11 @@ This installs the ADK middleware in editable mode for development. ## Testing ```bash -# Run tests (271 comprehensive tests) +# Run the test suite pytest # With coverage -pytest --cov=src/adk_middleware +pytest --cov=src/ag_ui_adk # Specific test file pytest tests/test_adk_agent.py @@ -112,7 +181,7 @@ pytest tests/test_adk_agent.py ### Option 1: Direct Usage ```python -from adk_middleware import ADKAgent +from ag_ui_adk import ADKAgent from google.adk.agents import Agent # 1. Create your ADK agent @@ -137,7 +206,7 @@ async for event in agent.run(input_data): ```python from fastapi import FastAPI -from adk_middleware import ADKAgent, add_adk_fastapi_endpoint +from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint from google.adk.agents import Agent # 1. Create your ADK agent @@ -160,18 +229,21 @@ add_adk_fastapi_endpoint(app, agent, path="/chat") # Run with: uvicorn your_module:app --host 0.0.0.0 --port 8000 ``` -For detailed configuration options, see [CONFIGURATION.md](./CONFIGURATION.md) +For detailed configuration options, see [CONFIGURATION.md](https://github.com/ag-ui-protocol/ag-ui/blob/main/integrations/adk-middleware/python/CONFIGURATION.md). ## Running the ADK Backend Server for Dojo App -To run the ADK backend server that works with the Dojo app, use the following command: +To run the ADK backend server that works with the Dojo app, run the example +server from the `integrations/adk-middleware/python/examples` directory: ```bash -python -m examples.fastapi_server +cd examples +uv sync +uv run dev ``` -This will start a FastAPI server that connects your ADK middleware to the Dojo application. +This starts a FastAPI server (the `server:main` entrypoint) that connects your ADK middleware to the Dojo application. ## Examples @@ -179,7 +251,7 @@ This will start a FastAPI server that connects your ADK middleware to the Dojo a ```python import asyncio -from adk_middleware import ADKAgent +from ag_ui_adk import ADKAgent from google.adk.agents import Agent from ag_ui.core import RunAgentInput, UserMessage @@ -239,7 +311,7 @@ creative_agent_wrapper = ADKAgent( # Use different endpoints for each agent from fastapi import FastAPI -from adk_middleware import add_adk_fastapi_endpoint +from ag_ui_adk import add_adk_fastapi_endpoint app = FastAPI() add_adk_fastapi_endpoint(app, general_agent_wrapper, path="/agents/general") @@ -251,11 +323,13 @@ add_adk_fastapi_endpoint(app, creative_agent_wrapper, path="/agents/creative") The middleware provides complete bidirectional tool support, enabling AG-UI Protocol tools to execute within Google ADK agents. All tools supplied by the client are currently implemented as long-running tools that emit events to the client for execution and can be combined with backend tools provided by the agent to create a hybrid combined toolset. -For detailed information about tool support, see [TOOLS.md](./TOOLS.md). +For detailed information about tool support, see [TOOLS.md](https://github.com/ag-ui-protocol/ag-ui/blob/main/integrations/adk-middleware/python/TOOLS.md). ## Additional Documentation -- **[CONFIGURATION.md](./CONFIGURATION.md)** - Complete configuration guide -- **[TOOLS.md](./TOOLS.md)** - Tool support documentation -- **[USAGE.md](./USAGE.md)** - Usage examples and patterns -- **[ARCHITECTURE.md](./ARCHITECTURE.md)** - Technical architecture and design details +These guides live in the companion Python middleware directory: + +- **[CONFIGURATION.md](https://github.com/ag-ui-protocol/ag-ui/blob/main/integrations/adk-middleware/python/CONFIGURATION.md)** - Complete configuration guide +- **[TOOLS.md](https://github.com/ag-ui-protocol/ag-ui/blob/main/integrations/adk-middleware/python/TOOLS.md)** - Tool support documentation +- **[USAGE.md](https://github.com/ag-ui-protocol/ag-ui/blob/main/integrations/adk-middleware/python/USAGE.md)** - Usage examples and patterns +- **[ARCHITECTURE.md](https://github.com/ag-ui-protocol/ag-ui/blob/main/integrations/adk-middleware/python/ARCHITECTURE.md)** - Technical architecture and design details diff --git a/integrations/adk-middleware/typescript/package.json b/integrations/adk-middleware/typescript/package.json index 3eb5f12561..c18bc44b95 100644 --- a/integrations/adk-middleware/typescript/package.json +++ b/integrations/adk-middleware/typescript/package.json @@ -1,13 +1,22 @@ { "name": "@ag-ui/adk", - "author": "Mark Fogle ", - "version": "0.0.1", + "version": "0.0.2", + "description": "AG-UI integration for Google ADK (Agent Development Kit) - thin TypeScript client for ADK-backed AG-UI agents", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "sideEffects": false, "files": [ - "dist/**" + "dist/**", + "README.md" + ], + "keywords": [ + "ag-ui", + "adk", + "google", + "agent-development-kit", + "agents", + "ai" ], "scripts": { "build": "tsdown", @@ -22,8 +31,8 @@ "unlink:global": "pnpm unlink --global" }, "peerDependencies": { - "@ag-ui/core": ">=0.0.37", - "@ag-ui/client": ">=0.0.37", + "@ag-ui/core": ">=0.0.55", + "@ag-ui/client": ">=0.0.55", "rxjs": "7.8.1" }, "devDependencies": { @@ -36,6 +45,17 @@ "typescript": "^5.3.3", "vitest": "^4.0.18" }, + "repository": { + "type": "git", + "url": "git+https://github.com/ag-ui-protocol/ag-ui.git", + "directory": "integrations/adk-middleware/typescript" + }, + "author": "Mark Fogle ", + "license": "MIT", + "private": false, + "publishConfig": { + "access": "public" + }, "exports": { ".": { "require": "./dist/index.js", diff --git a/integrations/ag2/python/examples/server/api/backend_tool_rendering.py b/integrations/ag2/python/examples/server/api/backend_tool_rendering.py index e704400ae1..8f68a03a1e 100644 --- a/integrations/ag2/python/examples/server/api/backend_tool_rendering.py +++ b/integrations/ag2/python/examples/server/api/backend_tool_rendering.py @@ -6,6 +6,7 @@ """ import json +import os import httpx from fastapi import FastAPI @@ -42,6 +43,23 @@ def get_weather_condition(code: int) -> str: return conditions.get(code, "Unknown") +def _mock_weather(location: str) -> str: + """Return deterministic canned weather data for tests. + + Used when ``AG_UI_MOCK_WEATHER`` is set so e2e runs don't depend on the + live open-meteo API (which rate-limits CI's shared egress IPs). + """ + return json.dumps({ + "temperature": 21.0, + "feels_like": 20.0, + "humidity": 65.0, + "wind_speed": 12.0, + "wind_gust": 18.0, + "conditions": get_weather_condition(1), + "location": location, + }) + + async def get_weather(location: str) -> str: """Get current weather for a location. @@ -51,6 +69,9 @@ async def get_weather(location: str) -> str: Returns: Dictionary with temperature, conditions, humidity, wind_speed, feels_like, location. """ + if os.getenv("AG_UI_MOCK_WEATHER"): + return _mock_weather(location) + async with httpx.AsyncClient() as client: geocoding_url = ( f"https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1" diff --git a/integrations/ag2/typescript/LICENSE b/integrations/ag2/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/ag2/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/ag2/typescript/package.json b/integrations/ag2/typescript/package.json index 4105dbc5c6..6b79248e06 100644 --- a/integrations/ag2/typescript/package.json +++ b/integrations/ag2/typescript/package.json @@ -1,6 +1,7 @@ { "name": "@ag-ui/ag2", "version": "0.0.1", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/integrations/agent-spec/python/LICENSE-APACHE b/integrations/agent-spec/python/LICENSE-APACHE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/integrations/agent-spec/python/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/integrations/agent-spec/python/LICENSE-UPL b/integrations/agent-spec/python/LICENSE-UPL new file mode 100644 index 0000000000..e35163cfeb --- /dev/null +++ b/integrations/agent-spec/python/LICENSE-UPL @@ -0,0 +1,37 @@ +Copyright (c) [year] [copyright holders] + +The Universal Permissive License (UPL), Version 1.0 + +Subject to the condition set forth below, permission is hereby granted to any +person obtaining a copy of this software, associated documentation and/or data +(collectively the "Software"), free of charge and under any and all copyright +rights in the Software, and any and all patent rights owned or freely +licensable by each licensor hereunder covering either (i) the unmodified +Software as contributed to or provided by such licensor, or (ii) the Larger +Works (as defined below), to deal in both + +(a) the Software, and + +(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +one is included with the Software (each a "Larger Work" to which the Software +is contributed by such licensors), + +without restriction, including without limitation the rights to copy, create +derivative works of, display, perform, and distribute the Software and make, +use, sell, offer for sale, import, export, have made, and have sold the +Software and the Larger Work(s), and to sublicense the foregoing rights on +either these or other terms. + +This license is subject to the following condition: + +The above copyright notice and either this complete permission notice or at a +minimum a reference to the UPL must be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/agent-spec/python/ag_ui_agentspec/agentspec_tracing_exporter.py b/integrations/agent-spec/python/ag_ui_agentspec/agentspec_tracing_exporter.py index 97f1dab8d9..6b1b739f72 100644 --- a/integrations/agent-spec/python/ag_ui_agentspec/agentspec_tracing_exporter.py +++ b/integrations/agent-spec/python/ag_ui_agentspec/agentspec_tracing_exporter.py @@ -6,12 +6,16 @@ telemetry package but adapts to the event shapes defined under `pyagentspec.tracing.events`. -Notes/limitations for the pyagentspec.tracing version: -- LLM streaming uses `LlmGenerationChunkReceived` with chunk_type MESSAGE only; - tool-call streaming chunks are not available in this event set. -- Tool execution events in this namespace do not include `message_id` nor - `tool_call_id`; therefore, we do not emit AG-UI tool call lifecycle or - result events here. +Notes for the pyagentspec.tracing version: +- LLM streaming uses `LlmGenerationChunkReceived`, which may carry text content + and/or tool-call chunks; both are translated to AG-UI events. +- Tool execution events (`ToolExecutionRequest`/`ToolExecutionResponse`) do not + carry a stable AG-UI `tool_call_id` of their own. We therefore correlate them: + for the langgraph runtime the AG-UI `tool_call_id` is recovered from the + request span's `tcid__` description, and for other runtimes the run-level + `request_id` is used directly. Given that correlation, we DO emit AG-UI tool + call lifecycle (`ToolCallChunkEvent`) and result (`ToolCallResultEvent`) + events here. """ from __future__ import annotations @@ -264,7 +268,28 @@ def _gather_events_for_event(self, event: Event, span: Span) -> List[Any]: self._tool_run_id_to_tool_call_id[event.request_id] = tool_call_id case ToolExecutionResponse(): if self._runtime == "langgraph": - tool_call_id = self._tool_run_id_to_tool_call_id[event.request_id] + # The correlation map is populated from the matching + # ToolExecutionRequest. If that request was never seen + # (out-of-order events, or a request span lacking a + # ``tcid__`` description), fall back to the run-level + # request_id rather than raising a KeyError. + if event.request_id in self._tool_run_id_to_tool_call_id: + tool_call_id = self._tool_run_id_to_tool_call_id[event.request_id] + else: + # Correlation miss: no matching ToolExecutionRequest was + # recorded for this request_id, so we cannot recover the + # AG-UI tool_call_id the frontend issued. We surrogate the + # raw request_id to avoid crashing, but the resulting tool + # result will be orphaned (it references an id the client + # never saw). Log it so the miss is observable. + logger.warning( + "AG-UI tool-call correlation miss: no ToolExecutionRequest " + "recorded for request_id=%r; using the raw request_id as a " + "surrogate tool_call_id. The emitted tool result may be " + "orphaned because the frontend never saw this id.", + event.request_id, + ) + tool_call_id = event.request_id else: tool_call_id = event.request_id content = _normalize_tool_output(event.outputs) diff --git a/integrations/agent-spec/python/pyproject.toml b/integrations/agent-spec/python/pyproject.toml index 8b51315e4a..68f0fb7521 100644 --- a/integrations/agent-spec/python/pyproject.toml +++ b/integrations/agent-spec/python/pyproject.toml @@ -1,38 +1,50 @@ [project] name = "ag-ui-agent-spec" -version = "0.1.0" +version = "0.1.1" description = "AG-UI FastAPI adapter for Agent-Spec (LangGraph/Wayflow)" -license = "MIT" +license = "Apache-2.0 OR UPL-1.0" readme = "README.md" requires-python = ">=3.10,<3.14.0" dependencies = [ "fastapi>=0.115.0", "ag-ui-protocol>=0.1.10", - "pyagentspec", + "pyagentspec>=26.1.2", "json-repair>=0.30.0,<0.45.0", ] authors = [{ name = "Agent Spec team" }] +[dependency-groups] +dev = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.21.0", + "langgraph>=0.2.0", + "langchain-core>=0.3.0", +] + [tool.ag-ui.scripts] -test = "python -c \"print('Warning: no tests configured for ag-ui-agent-spec')\"" +test = "python -m pytest" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["setuptools>=77.0.0"] +build-backend = "setuptools.build_meta" -[tool.hatch.build.targets.wheel] -packages = ["ag_ui_agentspec"] +[tool.setuptools] +license-files = ["LICENSE-APACHE", "LICENSE-UPL"] -[tool.hatch.metadata] -allow-direct-references = true +[tool.setuptools.packages.find] +include = ["ag_ui_agentspec*"] [tool.uv.sources] -wayflowcore = { git = "https://github.com/oracle/wayflow.git ", rev = "main", subdirectory = "wayflowcore" } -pyagentspec = { git = "https://github.com/oracle/agent-spec.git ", rev = "main", subdirectory = "pyagentspec" } +wayflowcore = { git = "https://github.com/oracle/wayflow.git", rev = "wayflow-26.1.2", subdirectory = "wayflowcore" } +pyagentspec = { git = "https://github.com/oracle/agent-spec.git", rev = "agent-spec-26.1.2", subdirectory = "pyagentspec" } [project.optional-dependencies] langgraph = ["pyagentspec[langgraph]"] -wayflow = ["wayflowcore"] +wayflow = ["wayflowcore>=26.1.2"] [tool.uv] package = true diff --git a/integrations/agent-spec/python/tests/__init__.py b/integrations/agent-spec/python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/integrations/agent-spec/python/tests/conftest.py b/integrations/agent-spec/python/tests/conftest.py new file mode 100644 index 0000000000..c960a7cfdc --- /dev/null +++ b/integrations/agent-spec/python/tests/conftest.py @@ -0,0 +1,166 @@ +# Copyright © 2025 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. +"""Shared fixtures and lightweight fakes for the Agent-Spec AG-UI adapter tests. + +These tests exercise the *translation* layer (pyagentspec tracing spans/events +-> AG-UI protocol events) and the runner input-preparation helpers. None of +them call an LLM API: the span processor is fed pre-constructed pyagentspec +tracing events and the runners are fed fake LangGraph/Wayflow objects, so the +network is never touched and no aimock recording is required. + +Real pyagentspec event/span classes are used (built with ``model_construct`` to +bypass their heavy required-field validation) because the span processor +dispatches on event *type* via structured ``match``/``case`` pattern matching -- +duck-typed stand-ins would not match those cases. +""" + +import asyncio +from typing import Any, Optional + +import pytest + +from ag_ui.core import RunAgentInput + + +# --------------------------------------------------------------------------- +# Real pyagentspec tracing event / span builders. +# +# The span processor keys off the concrete event class (``case +# LlmGenerationResponse():`` etc.), so we must hand it genuine instances. Their +# constructors require complex ``tool``/``llm_config`` components we do not +# need for the translation paths under test, so we use ``model_construct`` to +# stamp out a real-typed instance carrying only the attributes the processor +# actually reads. +# --------------------------------------------------------------------------- + +from pyagentspec.tracing.events.tool import ( # noqa: E402 + ToolExecutionRequest, + ToolExecutionResponse, +) +from pyagentspec.tracing.events.llmgeneration import ( # noqa: E402 + LlmGenerationChunkReceived, + LlmGenerationResponse, +) +from pyagentspec.tracing.events.exception import ExceptionRaised # noqa: E402 +from pyagentspec.tracing.spans.span import Span # noqa: E402 + + +def make_span(*, id: str = "span-1", description: str = "", node_name: Optional[str] = None) -> Span: + """Build a real tracing ``Span`` carrying only the attributes the processor reads.""" + span = Span.model_construct(id=id, description=description) + return span + + +class FakeToolCall: + """Stand-in for a pyagentspec streamed/returned tool call. + + The processor reads ``.tool_name``, ``.call_id`` and ``.arguments`` off of + the objects in ``event.tool_calls``; the real container type is internal to + pyagentspec, so a tiny duck-typed object is the cleanest fake here. + """ + + def __init__(self, *, call_id: str, tool_name: str, arguments: str): + self.call_id = call_id + self.tool_name = tool_name + self.arguments = arguments + + +class FakeTool: + """Stand-in for the ``event.tool`` component (only ``.name`` is read).""" + + def __init__(self, name: str): + self.name = name + + +def llm_chunk(*, content: str = "", request_id: str = "req-1", + completion_id: Optional[str] = None, tool_calls=None) -> LlmGenerationChunkReceived: + return LlmGenerationChunkReceived.model_construct( + content=content, + request_id=request_id, + completion_id=completion_id, + tool_calls=tool_calls or [], + ) + + +def llm_response(*, content: str = "", request_id: str = "req-1", + completion_id: Optional[str] = None, tool_calls=None) -> LlmGenerationResponse: + return LlmGenerationResponse.model_construct( + content=content, + request_id=request_id, + completion_id=completion_id, + tool_calls=tool_calls or [], + ) + + +def tool_request(*, request_id: str, tool_name: str = "get_weather", inputs=None) -> ToolExecutionRequest: + return ToolExecutionRequest.model_construct( + request_id=request_id, + tool=FakeTool(tool_name), + inputs=inputs or {}, + ) + + +def tool_response(*, request_id: str, outputs: Any) -> ToolExecutionResponse: + return ToolExecutionResponse.model_construct(request_id=request_id, outputs=outputs) + + +def exception_raised(*, message: str = "boom") -> ExceptionRaised: + return ExceptionRaised.model_construct(exception_message=message) + + +# --------------------------------------------------------------------------- +# AG-UI input factory +# --------------------------------------------------------------------------- + +@pytest.fixture +def make_input(): + """Factory for RunAgentInput with sensible defaults.""" + + def _make( + *, + thread_id: str = "thread-1", + run_id: str = "run-1", + messages=None, + tools=None, + state=None, + context=None, + forwarded_props=None, + ) -> RunAgentInput: + return RunAgentInput( + thread_id=thread_id, + run_id=run_id, + messages=messages or [], + tools=tools or [], + state=state if state is not None else None, + context=context or [], + forwarded_props=forwarded_props or {}, + ) + + return _make + + +@pytest.fixture +def event_queue(): + """An asyncio.Queue wired into the processor's EVENT_QUEUE ContextVar. + + Yields a (queue, drain) pair. ``drain()`` returns every non-sentinel item + currently buffered without blocking. + """ + from ag_ui_agentspec.agentspec_tracing_exporter import EVENT_QUEUE + + queue: asyncio.Queue = asyncio.Queue() + token = EVENT_QUEUE.set(queue) + + def drain(): + items = [] + while not queue.empty(): + items.append(queue.get_nowait()) + return items + + try: + yield queue, drain + finally: + EVENT_QUEUE.reset(token) diff --git a/integrations/agent-spec/python/tests/test_agentspecloader.py b/integrations/agent-spec/python/tests/test_agentspecloader.py new file mode 100644 index 0000000000..6477de7c46 --- /dev/null +++ b/integrations/agent-spec/python/tests/test_agentspecloader.py @@ -0,0 +1,20 @@ +# Copyright © 2025 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. +"""Tests for the runtime dispatch in load_agent_spec. + +The langgraph/wayflow branches need the heavy framework loaders, but the +dispatch's error handling for an unknown runtime is pure and worth pinning. +""" + +import pytest + +from ag_ui_agentspec.agentspecloader import load_agent_spec + + +class TestLoadAgentSpecDispatch: + def test_unsupported_runtime_raises_value_error(self): + with pytest.raises(ValueError, match="Unsupported runtime"): + load_agent_spec("crewai", "{}") # type: ignore[arg-type] diff --git a/integrations/agent-spec/python/tests/test_langgraph_runner.py b/integrations/agent-spec/python/tests/test_langgraph_runner.py new file mode 100644 index 0000000000..6561781829 --- /dev/null +++ b/integrations/agent-spec/python/tests/test_langgraph_runner.py @@ -0,0 +1,98 @@ +# Copyright © 2025 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. +"""Behaviour tests for the LangGraph runner's input-preparation helpers. + +These exercise the pure message-shaping logic and the new-message filter with a +fake CompiledStateGraph; no LLM or LangGraph runtime is invoked. +""" + +from types import SimpleNamespace + +import pytest + +from ag_ui.core import ( + AssistantMessage, + SystemMessage, + ToolMessage, + UserMessage, +) + +from ag_ui_agentspec.runtimes.langgraph_runner import ( + filter_only_new_messages, + prepare_langgraph_agent_inputs, +) + + +class TestPrepareLangGraphAgentInputs: + def test_empty_messages_returns_empty(self, make_input): + assert prepare_langgraph_agent_inputs(make_input(messages=[])) == [] + + def test_user_message_name_is_stripped(self, make_input): + inp = make_input(messages=[UserMessage(id="1", role="user", content="hi", name="alice")]) + out = prepare_langgraph_agent_inputs(inp) + assert "name" not in out[0] + assert out[0]["content"] == "hi" + + def test_assistant_message_name_is_stripped(self, make_input): + inp = make_input( + messages=[AssistantMessage(id="1", role="assistant", content="hi", name="bot")] + ) + out = prepare_langgraph_agent_inputs(inp) + assert "name" not in out[0] + + def test_assistant_none_content_becomes_empty_string(self, make_input): + inp = make_input( + messages=[AssistantMessage(id="1", role="assistant", content=None)] + ) + out = prepare_langgraph_agent_inputs(inp) + assert out[0]["content"] == "" + + def test_tool_message_error_key_is_stripped(self, make_input): + inp = make_input( + messages=[ToolMessage(id="1", role="tool", content="r", tool_call_id="tc1", error="oops")] + ) + out = prepare_langgraph_agent_inputs(inp) + assert "error" not in out[0] + + def test_system_message_is_passed_through(self, make_input): + inp = make_input(messages=[SystemMessage(id="1", role="system", content="be nice")]) + out = prepare_langgraph_agent_inputs(inp) + assert out[0]["role"] == "system" + assert out[0]["content"] == "be nice" + + +class _FakeGraph: + """Minimal stand-in for a CompiledStateGraph exposing only ``aget_state``.""" + + def __init__(self, existing_messages): + self._existing = existing_messages + + async def aget_state(self, config): + return SimpleNamespace(values={"messages": self._existing}) + + +class TestFilterOnlyNewMessages: + async def test_filters_out_already_seen_ids(self): + existing = [SimpleNamespace(id="m1"), SimpleNamespace(id="m2")] + graph = _FakeGraph(existing) + incoming = [{"id": "m1", "content": "old"}, {"id": "m3", "content": "new"}] + out = await filter_only_new_messages(graph, "thread-1", incoming) + assert [m["id"] for m in out] == ["m3"] + + async def test_keeps_all_when_state_empty(self): + graph = _FakeGraph([]) + incoming = [{"id": "m1"}, {"id": "m2"}] + out = await filter_only_new_messages(graph, "thread-1", incoming) + assert [m["id"] for m in out] == ["m1", "m2"] + + async def test_handles_none_messages_in_state(self): + # state_snapshot.values.get("messages") may be None. + class _NoneGraph(_FakeGraph): + async def aget_state(self, config): + return SimpleNamespace(values={"messages": None}) + + out = await filter_only_new_messages(_NoneGraph([]), "t", [{"id": "x"}]) + assert [m["id"] for m in out] == ["x"] diff --git a/integrations/agent-spec/python/tests/test_tracing_exporter.py b/integrations/agent-spec/python/tests/test_tracing_exporter.py new file mode 100644 index 0000000000..a42013dbdf --- /dev/null +++ b/integrations/agent-spec/python/tests/test_tracing_exporter.py @@ -0,0 +1,375 @@ +# Copyright © 2025 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. +"""Behaviour tests for the AG-UI span processor and its pure helpers. + +The span processor is the load-bearing translation layer: it turns pyagentspec +tracing events into AG-UI protocol events. These tests feed it genuine +pyagentspec events and assert on the AG-UI events it produces. +""" + +import json +import logging + +import pytest + +from ag_ui.core.events import ( + EventType, + TextMessageChunkEvent, + ToolCallChunkEvent, + ToolCallResultEvent, +) + +from ag_ui_agentspec.agentspec_tracing_exporter import ( + AgUiSpanProcessor, + _escape_html, + _normalize_tool_output, + jsonable, + repair_a2ui_json, +) + +from tests.conftest import ( + FakeToolCall, + exception_raised, + llm_chunk, + llm_response, + make_span, + tool_request, + tool_response, +) + + +# --------------------------------------------------------------------------- +# Pure helpers +# --------------------------------------------------------------------------- + +class TestEscapeHtml: + def test_escapes_angle_brackets_and_amp(self): + assert _escape_html(" & ") == "<a> & </a>" + + def test_amp_escaped_before_brackets(self): + # & must be escaped first so bracket entities aren't double-escaped. + assert _escape_html("<") == "<" + assert _escape_html("<") == "&lt;" + + def test_none_becomes_empty_string(self): + assert _escape_html(None) == "" + + def test_plain_text_unchanged(self): + assert _escape_html("hello") == "hello" + + +class TestJsonable: + def test_valid_json_string(self): + assert jsonable('{"a": 1}') is True + + def test_invalid_json_string(self): + assert jsonable("not json") is False + + +class TestNormalizeToolOutput: + def test_unwraps_single_key_dict_with_dict_inner(self): + out = _normalize_tool_output({"weather_result": {"temp": 72}}) + assert json.loads(out) == {"temp": 72} + + def test_unwraps_single_key_dict_with_scalar_inner(self): + # scalar inner is unwrapped then stringified + assert _normalize_tool_output({"result": 42}) == "42" + + def test_multi_key_dict_serialized_once(self): + out = _normalize_tool_output({"a": 1, "b": 2}) + assert json.loads(out) == {"a": 1, "b": 2} + + def test_list_serialized_once(self): + out = _normalize_tool_output([1, 2, 3]) + assert json.loads(out) == [1, 2, 3] + + def test_json_string_passthrough_not_double_encoded(self): + # A string that is already valid JSON must pass through unchanged. + assert _normalize_tool_output('{"temp": 72}') == '{"temp": 72}' + + def test_python_repr_string_parsed_to_json(self): + # ast.literal_eval path: a python-dict repr becomes JSON. + out = _normalize_tool_output("{'temp': 72}") + assert json.loads(out) == {"temp": 72} + + def test_plain_primitive_string(self): + assert _normalize_tool_output("sunny") == "sunny" + + +class TestRepairA2uiJson: + def test_dict_passthrough(self): + assert json.loads(repair_a2ui_json({"a": 1})) == {"a": 1} + + def test_valid_json_string(self): + assert json.loads(repair_a2ui_json('{"a": 1}')) == {"a": 1} + + def test_repairs_broken_json_string(self): + # Missing closing brace -> json_repair fixes it. + out = repair_a2ui_json('{"a": 1') + assert json.loads(out) == {"a": 1} + + def test_unexpected_type_raises(self): + with pytest.raises(NotImplementedError): + repair_a2ui_json(42) + + +# --------------------------------------------------------------------------- +# Run lifecycle +# --------------------------------------------------------------------------- + +class TestRunLifecycle: + def test_startup_emits_run_started(self, event_queue): + _, drain = event_queue + proc = AgUiSpanProcessor(runtime="langgraph") + proc.startup() + events = drain() + assert len(events) == 1 + assert events[0].type == EventType.RUN_STARTED + + def test_shutdown_emits_run_finished(self, event_queue): + _, drain = event_queue + proc = AgUiSpanProcessor(runtime="langgraph") + proc.shutdown() + events = drain() + assert len(events) == 1 + assert events[0].type == EventType.RUN_FINISHED + + def test_run_started_and_finished_share_ids(self, event_queue): + _, drain = event_queue + proc = AgUiSpanProcessor(runtime="langgraph") + proc.startup() + proc.shutdown() + started, finished = drain() + assert started.thread_id == finished.thread_id + assert started.run_id == finished.run_id + + def test_emit_without_queue_raises(self): + # No EVENT_QUEUE set in this (non-fixtured) context. + proc = AgUiSpanProcessor(runtime="langgraph") + with pytest.raises(RuntimeError, match="event queue is not set"): + proc.startup() + + +# --------------------------------------------------------------------------- +# LLM text streaming +# --------------------------------------------------------------------------- + +class TestLlmTextStreaming: + def test_chunk_emits_text_message_chunk(self): + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="llm-1") + events = proc._gather_events_for_event( + llm_chunk(content="hello", completion_id="msg-1"), span + ) + assert len(events) == 1 + assert isinstance(events[0], TextMessageChunkEvent) + assert events[0].delta == "hello" + assert events[0].message_id == "msg-1" + + def test_chunk_content_is_html_escaped(self): + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="llm-1") + events = proc._gather_events_for_event( + llm_chunk(content="", completion_id="msg-1"), span + ) + assert events[0].delta == "<b>" + + def test_chunk_falls_back_to_request_id_when_no_completion_id(self): + # WayFlow does not assign completion_id in streaming. + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="llm-1") + events = proc._gather_events_for_event( + llm_chunk(content="hi", request_id="req-9", completion_id=None), span + ) + assert events[0].message_id == "req-9" + + def test_chunk_without_message_id_raises(self): + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="llm-1") + with pytest.raises(ValueError, match="assistant message id"): + proc._gather_events_for_event( + llm_chunk(content="hi", request_id="", completion_id=None), span + ) + + def test_response_without_completion_id_raises(self): + # Unlike the chunk path (which falls back to request_id), the response + # path REQUIRES completion_id and raises if it is absent. + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="llm-1") + with pytest.raises(ValueError, match="assistant message id in LLM response"): + proc._gather_events_for_event( + llm_response(content="answer", request_id="req-1", completion_id=None), span + ) + + def test_response_emits_full_text_when_no_chunks_streamed(self): + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="llm-1") + events = proc._gather_events_for_event( + llm_response(content="full answer", completion_id="msg-1"), span + ) + assert len(events) == 1 + assert isinstance(events[0], TextMessageChunkEvent) + assert events[0].delta == "full answer" + + def test_response_suppresses_text_when_chunks_already_streamed(self): + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="llm-1") + # First a streamed chunk marks the span as having emitted text... + proc._gather_events_for_event( + llm_chunk(content="partial", completion_id="msg-1"), span + ) + # ...so the final response must not re-emit the (now duplicate) text. + events = proc._gather_events_for_event( + llm_response(content="partial", completion_id="msg-1"), span + ) + text_events = [e for e in events if isinstance(e, TextMessageChunkEvent)] + assert text_events == [] + + +# --------------------------------------------------------------------------- +# Tool-call streaming / emission +# --------------------------------------------------------------------------- + +class TestToolCallEmission: + def test_response_tool_call_emits_chunk(self): + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="llm-1") + tc = FakeToolCall(call_id="tc-1", tool_name="get_weather", arguments='{"city": "SF"}') + events = proc._gather_events_for_event( + llm_response(content="", completion_id="msg-1", tool_calls=[tc]), span + ) + tool_events = [e for e in events if isinstance(e, ToolCallChunkEvent)] + assert len(tool_events) == 1 + assert tool_events[0].tool_call_id == "tc-1" + assert tool_events[0].tool_call_name == "get_weather" + assert json.loads(tool_events[0].delta) == {"city": "SF"} + + def test_response_repairs_a2ui_json_argument(self): + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="llm-1") + # a2ui_json nested as a broken JSON string should be repaired in place. + args = json.dumps({"a2ui_json": '{"component": "Card"'}) # missing closing brace + tc = FakeToolCall(call_id="tc-1", tool_name="render", arguments=args) + events = proc._gather_events_for_event( + llm_response(content="", completion_id="msg-1", tool_calls=[tc]), span + ) + delta = json.loads(events[0].delta) + assert json.loads(delta["a2ui_json"]) == {"component": "Card"} + + def test_response_does_not_double_emit_already_started_tool_call(self): + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="llm-1") + tc = FakeToolCall(call_id="tc-1", tool_name="get_weather", arguments="{}") + # Streamed chunk starts the tool call... + proc._gather_events_for_event( + llm_chunk(content="", completion_id="msg-1", tool_calls=[tc]), span + ) + # ...so the final response must not emit it again. + events = proc._gather_events_for_event( + llm_response(content="", completion_id="msg-1", tool_calls=[tc]), span + ) + assert [e for e in events if isinstance(e, ToolCallChunkEvent)] == [] + + +# --------------------------------------------------------------------------- +# Tool execution: result correlation. This is the langgraph KeyError path. +# --------------------------------------------------------------------------- + +class TestToolExecutionLangGraph: + def test_request_then_response_correlates_tool_call_id(self): + proc = AgUiSpanProcessor(runtime="langgraph") + # The request span carries the AG-UI tool_call_id in its description. + req_span = make_span(id="span-req", description="tcid__client-tc-7") + proc._gather_events_for_event(tool_request(request_id="run-1"), req_span) + + resp_span = make_span(id="span-resp") + events = proc._gather_events_for_event( + tool_response(request_id="run-1", outputs={"weather_result": "sunny"}), resp_span + ) + results = [e for e in events if isinstance(e, ToolCallResultEvent)] + assert len(results) == 1 + # The emitted result must reference the *client* tool_call_id, not the run id. + assert results[0].tool_call_id == "client-tc-7" + assert results[0].content == "sunny" + assert results[0].role == "tool" + + def test_response_for_unseen_request_id_does_not_raise_keyerror(self): + """REGRESSION: a ToolExecutionResponse whose request_id was never + recorded by a preceding ToolExecutionRequest (out-of-order events, or a + request span lacking a ``tcid__`` description) must not crash with a + KeyError. It must still emit a ToolCallResultEvent, falling back to the + run-level request_id as the tool_call_id.""" + proc = AgUiSpanProcessor(runtime="langgraph") + resp_span = make_span(id="span-resp") + events = proc._gather_events_for_event( + tool_response(request_id="UNSEEN", outputs={"r": "ok"}), resp_span + ) + results = [e for e in events if isinstance(e, ToolCallResultEvent)] + assert len(results) == 1 + assert results[0].tool_call_id == "UNSEEN" + assert results[0].content == "ok" + + def test_unseen_request_id_logs_correlation_miss_warning(self, caplog): + """The fallback path (request_id never correlated) silently surrogates + the raw request_id as the tool_call_id, which orphans the tool result on + the frontend. That degraded path must be observable: a WARNING naming + the missed request_id is emitted only on the genuine fallback.""" + proc = AgUiSpanProcessor(runtime="langgraph") + resp_span = make_span(id="span-resp") + with caplog.at_level(logging.WARNING, logger="ag_ui_agentspec.tracing"): + proc._gather_events_for_event( + tool_response(request_id="UNSEEN", outputs={"r": "ok"}), resp_span + ) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "UNSEEN" in warnings[0].getMessage() + + def test_correlated_request_id_does_not_log_warning(self, caplog): + """The happy path (request correlated via tcid__ description) must NOT + emit the correlation-miss warning.""" + proc = AgUiSpanProcessor(runtime="langgraph") + req_span = make_span(id="span-req", description="tcid__client-tc-7") + proc._gather_events_for_event(tool_request(request_id="run-1"), req_span) + + resp_span = make_span(id="span-resp") + with caplog.at_level(logging.WARNING, logger="ag_ui_agentspec.tracing"): + proc._gather_events_for_event( + tool_response(request_id="run-1", outputs={"r": "ok"}), resp_span + ) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert warnings == [] + + +class TestToolExecutionWayflow: + def test_request_emits_tool_call_chunk(self): + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="span-req") + events = proc._gather_events_for_event( + tool_request(request_id="req-1", tool_name="get_weather", inputs={"city": "SF"}), span + ) + chunks = [e for e in events if isinstance(e, ToolCallChunkEvent)] + assert len(chunks) == 1 + assert chunks[0].tool_call_id == "req-1" + assert chunks[0].tool_call_name == "get_weather" + assert json.loads(chunks[0].delta) == {"city": "SF"} + + def test_response_uses_request_id_directly(self): + proc = AgUiSpanProcessor(runtime="wayflow") + span = make_span(id="span-resp") + events = proc._gather_events_for_event( + tool_response(request_id="req-1", outputs={"weather_result": "sunny"}), span + ) + results = [e for e in events if isinstance(e, ToolCallResultEvent)] + assert len(results) == 1 + assert results[0].tool_call_id == "req-1" + + +class TestExceptionRaised: + def test_exception_event_raises_runtime_error(self): + proc = AgUiSpanProcessor(runtime="langgraph") + span = make_span(id="span-1") + with pytest.raises(RuntimeError, match="ExceptionRaised occurred"): + proc._gather_events_for_event(exception_raised(message="kaboom"), span) diff --git a/integrations/agent-spec/python/uv.lock b/integrations/agent-spec/python/uv.lock new file mode 100644 index 0000000000..8995e21743 --- /dev/null +++ b/integrations/agent-spec/python/uv.lock @@ -0,0 +1,3248 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.14.0" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "ag-ui-agent-spec" +version = "0.1.1" +source = { editable = "." } +dependencies = [ + { name = "ag-ui-protocol" }, + { name = "fastapi" }, + { name = "json-repair" }, + { name = "pyagentspec" }, +] + +[package.optional-dependencies] +langgraph = [ + { name = "pyagentspec", extra = ["langgraph"] }, +] +wayflow = [ + { name = "wayflowcore" }, +] + +[package.dev-dependencies] +dev = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "ag-ui-protocol", specifier = ">=0.1.10" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "json-repair", specifier = ">=0.30.0,<0.45.0" }, + { name = "pyagentspec", git = "https://github.com/oracle/agent-spec.git?subdirectory=pyagentspec&rev=agent-spec-26.1.2" }, + { name = "pyagentspec", extras = ["langgraph"], marker = "extra == 'langgraph'", git = "https://github.com/oracle/agent-spec.git?subdirectory=pyagentspec&rev=agent-spec-26.1.2" }, + { name = "wayflowcore", marker = "extra == 'wayflow'", git = "https://github.com/oracle/wayflow.git?subdirectory=wayflowcore&rev=wayflow-26.1.2" }, +] +provides-extras = ["langgraph", "wayflow"] + +[package.metadata.requires-dev] +dev = [ + { name = "langchain-core", specifier = ">=0.3.0" }, + { name = "langgraph", specifier = ">=0.2.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, +] + +[[package]] +name = "ag-ui-protocol" +version = "0.1.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720, upload-time = "2026-06-02T17:26:15.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/0a/bcad8116eb058e4b4a305e3fc37ebd7efc879deeb86b854f1c5b8b6e97dd/ag_ui_protocol-0.1.19-py3-none-any.whl", hash = "sha256:898843b1410d378824da0c6a776486288b9c5828689d0bf563118868e37f390f", size = 13490, upload-time = "2026-06-02T17:26:16.313Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/f0/f81190ba488cd106c2fc6d92680e56bb223bbbbf1e6908c2617011290112/aiohttp-3.14.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:692e409052e7436029bbb32977cd7c5bf806ac5fa4085b973996785ffadad33c", size = 760606, upload-time = "2026-06-01T19:36:39.054Z" }, + { url = "https://files.pythonhosted.org/packages/f6/54/444d37eebf0f15db661ca44ec7caf93962f3c5ca92eb4c9a5d888b70aaa2/aiohttp-3.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:40af7ebe53c7990e110dc4ad03566b12c3ac996254298a3d39046dd69cfcb2c2", size = 514677, upload-time = "2026-06-01T19:36:42.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d1/da280e23321c132c0a3fa7c8cc2830621d79174edc64c829443346489a36/aiohttp-3.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02cb2ffbb7da32f82e21ad9952669c45bd88a80e0878264c2f59fe1c6fb2badd", size = 510155, upload-time = "2026-06-01T19:36:44.072Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/2e36d54d0991ec5bba451444004591ee0af58cb1662a3a81c562878b9c1f/aiohttp-3.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2514cb7195f6d7c219339635bea71ae47d1569b051300d32df9dcfabcdb869", size = 1699947, upload-time = "2026-06-01T19:36:45.762Z" }, + { url = "https://files.pythonhosted.org/packages/57/95/a31d8ea1a0b9ecc084f5a7dd0b431ce64ef585918bb7bdc82afe11843877/aiohttp-3.14.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:30e8b7eeb42d02c120ca90d6c6e076a221a16b70a6dac9ae44c7ab5104cc7fe4", size = 1664364, upload-time = "2026-06-01T19:36:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/01/f6/5de3ddffc87a9e8d09b3be38fbd6dd1a736b2ad477a7e787dcb85f57f338/aiohttp-3.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63e38be0d75a654deaa06be32fb4cab883a4222940be1d05861b6717679cbadb", size = 1761186, upload-time = "2026-06-01T19:36:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/03c5438ec35d7e3a4f33fe895d6c3ec7540a7cec46065f21851211e1ee4d/aiohttp-3.14.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1210d4c87cc00128160c7384ab41877a701295b97cffa6362f908a49b6e8a7ca", size = 1849727, upload-time = "2026-06-01T19:36:51.478Z" }, + { url = "https://files.pythonhosted.org/packages/22/32/5a05303b0874458920b73f48b8779cc3a93d503f121b38dcc0456dbd698c/aiohttp-3.14.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a78a77366ed158a0a54b076990e575d7b7cdb728cbfd02711eadab150f2269f", size = 1708197, upload-time = "2026-06-01T19:36:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/478f169488d61414c0a05e7fe423b59ae3d9dcc933d1f0e4acc2c5d5bc3e/aiohttp-3.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f4d2038c64f36df96cfd3fa0937910e231eafbf897e70a06c155a817bb632fa6", size = 1578147, upload-time = "2026-06-01T19:36:55.154Z" }, + { url = "https://files.pythonhosted.org/packages/1d/af/b20af85765658972d3337834bd5eebba91b962794f2b4fc3e0ee8c85c0e1/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4714c70067a08b604d0bf3bc4dfdf82e52944afab41d0428d460862763d2f79b", size = 1665836, upload-time = "2026-06-01T19:36:56.94Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a3/771879cfd59948f4544b172189048905feff802f20f1c6c5411e998a3e06/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f79bfd2847513a7ac801bbafd1de02348a37926ac439eeb4bfe96fcff4eada15", size = 1680335, upload-time = "2026-06-01T19:36:58.642Z" }, + { url = "https://files.pythonhosted.org/packages/f4/16/582e36ad1d32133cd40659f3bc98e71c22179665a1cfbbb4713bce339c06/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:25e9f1d2465a210d60edb64d7b204a147e85d4c194eecef3d1604fb5ace678ce", size = 1731180, upload-time = "2026-06-01T19:37:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/11/bc/80708fe3f64a07a2c306a42fc7b009118a952709761d215f6d1b4c57195b/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b5314743ebe926c2fda35d0a298c565c885505f6635c2a30936363404cf274a7", size = 1565805, upload-time = "2026-06-01T19:37:02.446Z" }, + { url = "https://files.pythonhosted.org/packages/57/8f/8d25897f8273a32fe4ad40a8885eec4f397377ed46e8e383078169f60316/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:28eee8de1d69711c53116df8202f1c2aa0e3f80ef912a88fc18d159d53e7110b", size = 1742496, upload-time = "2026-06-01T19:37:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7d/c341d32ab2dec56c8478740695743dc6c21b383cace9376a3eab16311a07/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89ed35666c95d3efe1955056afcde09e62a57a34e2a4398b17f9f6c1564f0b25", size = 1691240, upload-time = "2026-06-01T19:37:06.277Z" }, + { url = "https://files.pythonhosted.org/packages/37/0f/a81207dd7a2d4a4f645b3a3f8b5a1da1159dc63117ffb137b698fd6df50f/aiohttp-3.14.0-cp310-cp310-win32.whl", hash = "sha256:5e4646e9a6af29af354204011bf5769cb0276ec5b64653e42f90b3e13845169f", size = 454686, upload-time = "2026-06-01T19:37:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/842357f2afb9c915715c6f5775239d987f5d0f845abf7675fa794e0a9d40/aiohttp-3.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:22a8d06f204e0518a586d770032db3c7043c9ba3693081b3e3ad425e1458d594", size = 478677, upload-time = "2026-06-01T19:37:09.652Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d1/330fb22c9535ec177b52396905131c6e39447244b6ca876262939af668ef/aiohttp-3.14.0-cp310-cp310-win_arm64.whl", hash = "sha256:4acfc34bd4d3c58754fc9f22ff1b5e92aabce68f3d4bf7b71a0b732d9bceb78a", size = 450364, upload-time = "2026-06-01T19:37:11.279Z" }, + { url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" }, + { url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" }, + { url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" }, + { url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" }, + { url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" }, + { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" }, + { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" }, + { url = "https://files.pythonhosted.org/packages/21/61/d11f7d9a3144bffe825247d6367cd93053666da50b94707c9129c78868d5/aiohttp-3.14.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:25400d710641a8040bf022a8a99f579e581ffa1c5bd42c33255d7d6f3957c127", size = 502399, upload-time = "2026-06-01T19:38:25.955Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9b/a7e317625d36356844f8bb022cabd305b541f968856cc3c2e0b58e53ee6e/aiohttp-3.14.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:c5492b9929826e07cc3fcb9739ae87aab05dff6b5e67a9b73fd1700c6d008981", size = 510068, upload-time = "2026-06-01T19:38:27.828Z" }, + { url = "https://files.pythonhosted.org/packages/11/41/cc2d2cfbfbdc3126ba258f3cd27d1ac8a33492ae3c35a4583ee21f0ba7f1/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3366751d68d237c621264233a32f3078bbc21b7904ab90a77e03d21390c742c6", size = 481670, upload-time = "2026-06-01T19:38:29.836Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/381f4023c3b08cb616e520f566d8c58957abad54e56441d41fe67cfb0195/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:57ea07d28695a7a40304d42251892a8df765e5588c10ee32afeddcd5df33c0a2", size = 487591, upload-time = "2026-06-01T19:38:31.704Z" }, + { url = "https://files.pythonhosted.org/packages/fb/4d/4506fdb7a022bdf70011a3bbb4ca00c5c570026ef6a3c5bd7bc70c39089c/aiohttp-3.14.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:076cb014191ae2e65d949e1ad01f1dcfe33e32789b5172510f3e79c79fc04d50", size = 496503, upload-time = "2026-06-01T19:38:33.6Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7d/c814111e04894a45d9e2defc94443879a6f118d9633d5fedfe6e2e8af5f0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f3fc37054564dee64a855b5b092d87ec35dcddfaabf7dacb1c8a2b1f83dc0a9", size = 745870, upload-time = "2026-06-01T19:38:36.013Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ee/80eee0efddfe187e7cd05027086b7ce1c0e492e82a4eda58f5c5543a44a0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8fcaef74d2ab0f607d7ff85a0d15e21bb5a258c4a58df1908396eb50d7f4ed3c", size = 505588, upload-time = "2026-06-01T19:38:38.282Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f8/0f28f04eef75d52fc9c715dde7ce9c0abb810fd20cfeb0fea7afd2ab1e98/aiohttp-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4c01b0bfc6209590960e68eac083cd22d5d87c21f974dd6208cafa5d3542bc8", size = 504492, upload-time = "2026-06-01T19:38:40.611Z" }, + { url = "https://files.pythonhosted.org/packages/ff/db/44c755232085545065c94378dfce38641b1aee647f4939fcd32f5b32e719/aiohttp-3.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f12eb7896e81caf403a2b18c9406426f1207361e7239c057ab29c076d4257e83", size = 1752111, upload-time = "2026-06-01T19:38:42.682Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6a/42e030a46743841414402a3b00cd3d78419055e86c66fb5822c14b5abfc6/aiohttp-3.14.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c79a044cacf360ec46738d863d2f41c9300d2a06ef4a7402ea0df306a350e61", size = 1729674, upload-time = "2026-06-01T19:38:44.79Z" }, + { url = "https://files.pythonhosted.org/packages/34/26/3199beb415202e3108e7b83ecebe10914d806d33fb9860c3e4aa60a19be3/aiohttp-3.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85e0675f47be4eff0636bf88c02140ea89168ae0df3ff1f3f464e9de9610d277", size = 1798808, upload-time = "2026-06-01T19:38:47.01Z" }, + { url = "https://files.pythonhosted.org/packages/bd/94/b9b6fcf0ee17c21d0d19fb8c22bf83ad18f82e702a9c3bd901a868f5e446/aiohttp-3.14.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b33e751cab03fdc960095b1e326cb5a03f5ee577d6ded59f3d1c100f8668882", size = 1891921, upload-time = "2026-06-01T19:38:49.233Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a3/3800dbd095cb2bb165a7ea5d94d790914677e27f45638c7d80e3f34c8945/aiohttp-3.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9224c6dd7f5c749aba4f61315a894601448b28d94d12f4dea0903e26d2096", size = 1777241, upload-time = "2026-06-01T19:38:52.04Z" }, + { url = "https://files.pythonhosted.org/packages/21/2a/45be91ad1b860508557448d4cc2e165a2ee68dd865657b73bf66cc5a00fb/aiohttp-3.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6281aecdf2732940f4fe06bd6adec5ae4d59b78b080b8e3a6b81467301010988", size = 1579554, upload-time = "2026-06-01T19:38:54.508Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/dc94df99ed1511fdf28314f722643ed334112643cab00223577085e788c4/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23e8314e7aed8576fbe33314d218bd81447a3adbc91dc36f1163bf583cd3084c", size = 1714864, upload-time = "2026-06-01T19:38:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e4/1f1c8acbb3acd5c8f795473b92c9c3d44eb60a5692c6104256c8a1c83a0c/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3b54fbff46127aeafdd764cecd0d99fa2f24a0e37ea5c18a7c3a4ac450df1db3", size = 1749803, upload-time = "2026-06-01T19:38:59.367Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c8/c45ea6e7ed84cebba939b9c334498a045ba19d79c61b0110df5f21580de3/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b27d89af91a555f58e08e4902dbcbc48862fd40095720ca705990476bd93b7ac", size = 1765023, upload-time = "2026-06-01T19:39:01.651Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a1/a932941784432962fe390e1066823aaef64b4e5ac9fa595df57b5fe472a9/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:25d2326a4967bf705a9f9913a13005e93b6020ad8a9f6bd6bd78850d5171332e", size = 1571671, upload-time = "2026-06-01T19:39:04.044Z" }, + { url = "https://files.pythonhosted.org/packages/b0/01/e1280feac522597a4d46eb67a0cdfa053cfae263033030b761ab146f29fb/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a1d209375c503472b3c0a340cdf3c55fcd82e84b46dda7caeaced59faba373ec", size = 1789904, upload-time = "2026-06-01T19:39:06.294Z" }, + { url = "https://files.pythonhosted.org/packages/fa/10/ab28818262f4d26bdb47ed5f1fc7999b69e2fc6e0370b02d0f49011f45ea/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:666c7c5036df57b693026398b69b41874a1931ac5b3485fd910e57bfac253869", size = 1754516, upload-time = "2026-06-01T19:39:08.788Z" }, + { url = "https://files.pythonhosted.org/packages/af/cc/c122eabd7a1b7e0c9bbdd6be60e4715905b858399145d9df872bb94f1427/aiohttp-3.14.0-cp313-cp313-win32.whl", hash = "sha256:23f094a1ef64823fd35854ddf5c7a80a078162f37f9d2f7c6142b51a6affa456", size = 448656, upload-time = "2026-06-01T19:39:11.171Z" }, + { url = "https://files.pythonhosted.org/packages/41/a5/bab07d79848a00eedd8ed979ccb302aaea3ac6eb9fa16bd0ed87135869b4/aiohttp-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e03abdaa17d553f17e1d1d06bb266b3970106c78051d06795723e748d8e49d11", size = 475803, upload-time = "2026-06-01T19:39:13.439Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/f03ade8566c153666a3871afccbedf6d99911da006325e1fc6cf72a2de99/aiohttp-3.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:acdb400538cf4769543548bb5d1eb23d39bed4f96554a6078cb728c7cb2c268b", size = 443889, upload-time = "2026-06-01T19:39:15.945Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/b2/731a6696e37cd20eed353f69a09f37a984a43c9713764ee3f7ad5f57f7f9/fastuuid-0.14.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6e6243d40f6c793c3e2ee14c13769e341b90be5ef0c23c82fa6515a96145181a", size = 516760, upload-time = "2025-10-19T22:25:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/c5/79/c73c47be2a3b8734d16e628982653517f80bbe0570e27185d91af6096507/fastuuid-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:13ec4f2c3b04271f62be2e1ce7e95ad2dd1cf97e94503a3760db739afbd48f00", size = 264748, upload-time = "2025-10-19T22:41:52.873Z" }, + { url = "https://files.pythonhosted.org/packages/24/c5/84c1eea05977c8ba5173555b0133e3558dc628bcf868d6bf1689ff14aedc/fastuuid-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b2fdd48b5e4236df145a149d7125badb28e0a383372add3fbaac9a6b7a394470", size = 254537, upload-time = "2025-10-19T22:33:55.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/23/4e362367b7fa17dbed646922f216b9921efb486e7abe02147e4b917359f8/fastuuid-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f74631b8322d2780ebcf2d2d75d58045c3e9378625ec51865fe0b5620800c39d", size = 278994, upload-time = "2025-10-19T22:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/b2/72/3985be633b5a428e9eaec4287ed4b873b7c4c53a9639a8b416637223c4cd/fastuuid-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83cffc144dc93eb604b87b179837f2ce2af44871a7b323f2bfed40e8acb40ba8", size = 280003, upload-time = "2025-10-19T22:23:45.415Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6d/6ef192a6df34e2266d5c9deb39cd3eea986df650cbcfeaf171aa52a059c3/fastuuid-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a771f135ab4523eb786e95493803942a5d1fc1610915f131b363f55af53b219", size = 303583, upload-time = "2025-10-19T22:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/9d/11/8a2ea753c68d4fece29d5d7c6f3f903948cc6e82d1823bc9f7f7c0355db3/fastuuid-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4edc56b877d960b4eda2c4232f953a61490c3134da94f3c28af129fb9c62a4f6", size = 460955, upload-time = "2025-10-19T22:36:25.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/42/7a32c93b6ce12642d9a152ee4753a078f372c9ebb893bc489d838dd4afd5/fastuuid-0.14.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bcc96ee819c282e7c09b2eed2b9bd13084e3b749fdb2faf58c318d498df2efbe", size = 480763, upload-time = "2025-10-19T22:24:28.451Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e9/a5f6f686b46e3ed4ed3b93770111c233baac87dd6586a411b4988018ef1d/fastuuid-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7a3c0bca61eacc1843ea97b288d6789fbad7400d16db24e36a66c28c268cfe3d", size = 452613, upload-time = "2025-10-19T22:25:06.827Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c9/18abc73c9c5b7fc0e476c1733b678783b2e8a35b0be9babd423571d44e98/fastuuid-0.14.0-cp310-cp310-win32.whl", hash = "sha256:7f2f3efade4937fae4e77efae1af571902263de7b78a0aee1a1653795a093b2a", size = 155045, upload-time = "2025-10-19T22:28:32.732Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8a/d9e33f4eb4d4f6d9f2c5c7d7e96b5cdbb535c93f3b1ad6acce97ee9d4bf8/fastuuid-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:ae64ba730d179f439b0736208b4c279b8bc9c089b102aec23f86512ea458c8a4", size = 156122, upload-time = "2025-10-19T22:23:15.59Z" }, + { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" }, + { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978, upload-time = "2025-10-19T22:35:41.306Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692, upload-time = "2025-10-19T22:25:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384, upload-time = "2025-10-19T22:29:46.578Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921, upload-time = "2025-10-19T22:36:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575, upload-time = "2025-10-19T22:28:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317, upload-time = "2025-10-19T22:25:32.75Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804, upload-time = "2025-10-19T22:24:15.615Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099, upload-time = "2025-10-19T22:24:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, + { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, + { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" }, + { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" }, + { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/65/9826515abb600b5722bcf53f8b4a2fb58340b1f8bfcaee19f83561c13a44/huggingface_hub-1.17.0.tar.gz", hash = "sha256:fad842b6763ef70ebc3919665b1b9273645203185400a7d6c5eddc2323cc3435", size = 797082, upload-time = "2026-05-28T15:12:13.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/28/d7cef5e477b855c25d415b8f57e5bc7347c7a90cad3acf1725d0c92ca294/huggingface_hub-1.17.0-py3-none-any.whl", hash = "sha256:3b8156d23118e87f6a587648bfbc04f04a12a757ccb4ed298b35c4ae638bf24c", size = 671546, upload-time = "2026-05-28T15:12:11.441Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/f9/97f2ca8bb3ec6e4b1d64f983ebe98b9a192faddff67fac3d6303a537e670/importlib_metadata-8.9.0-py3-none-any.whl", hash = "sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f", size = 27220, upload-time = "2026-03-20T16:56:25.07Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/da/76a2c7e510ba15fe323d9509c223ab272da79ea59f54488f4a78da6426db/jiter-0.15.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:edebcf7d1f601199084bb6e844d7dc67e03e04f6ac786b0332d616635c4ff7a4", size = 310849, upload-time = "2026-05-19T10:06:51.944Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8e/827be942883a4dc0862c48626ff41af3320b1902d136a0bf4b9041f2c567/jiter-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f924585cdacf631cd382b657966847bb537bf9ed0a6f9b991da5f05a631480f", size = 314991, upload-time = "2026-05-19T10:06:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/be2832be361ba1b9517c76f46d30b64e985be1dd43c974f4c3a4b1844436/jiter-0.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abbf258599526ad0326fe51e252e24f2bd6f24f1852681b4b78feda3808f1d18", size = 340843, upload-time = "2026-05-19T10:06:55.071Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/90f01fb83c0c7ba509303ec93e32a308fbfa167d264860b01c0fd0dbbd06/jiter-0.15.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c468136b8bd6bb18c8786e4236a1fa27362f24cb23450ba0cb204ab379b8e6f", size = 365116, upload-time = "2026-05-19T10:06:56.893Z" }, + { url = "https://files.pythonhosted.org/packages/91/38/94593d34f8c67a0b6f6cbc027f016ffa9780b3a858a7a86f6fd7a15bcc1e/jiter-0.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05906b93d72f03339e6bb7cf8dc10ebda64a0266126eed6beba79e20abcf5fd4", size = 457970, upload-time = "2026-05-19T10:06:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/df/04/d79962dd49d00c97e2a9b4cacea1947904d02135936960351f9a96d4c1a6/jiter-0.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30ce785d2adb8e32c3f7741442370a74834ec4c01f3c48f0750227a0b4ef27d6", size = 375744, upload-time = "2026-05-19T10:07:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2e/5d37abe2be0e819c21e2338bebd410e481763ce526a9138c8c3652fa0123/jiter-0.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd73e3da91a0a722d67165e849ce2cdc10de0e0d48738c142be8c6c5f310f4c", size = 349609, upload-time = "2026-05-19T10:07:01.829Z" }, + { url = "https://files.pythonhosted.org/packages/7a/90/98768ad2ed90c1fda15d64157de2dfbf73c1c074d4b1bfaca915480bc7cf/jiter-0.15.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:ceb8fc27d38793f9c97149be8302720c5b22e5c195a37bf2c45dc36c4600a512", size = 354366, upload-time = "2026-05-19T10:07:03.587Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c4/fbfb806209f1fe4b7dccdfb07bc62bb044300734a945b06fd64db446ef6a/jiter-0.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d726e3ceeb337191324b49de298142f27c3ad10886341555d1d5315b5f252c6a", size = 393519, upload-time = "2026-05-19T10:07:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/37/1c/b9c257cd70cb453b6d10f3ebf0402cdb11669ab455389096f09839670290/jiter-0.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c8aea7781d2a372227871de4e1a1332aa96f5a89fd76c5e835dafdbad102887", size = 519952, upload-time = "2026-05-19T10:07:06.589Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1a/aa85027db7ab15829c12feebbc33b404f53fc399bd559d85fd0d6365ff0d/jiter-0.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf4bd113a69c0a740e27cb962ce10630c36d2b8f59d759a651b955ee9d18a823", size = 550770, upload-time = "2026-05-19T10:07:08.228Z" }, + { url = "https://files.pythonhosted.org/packages/d4/54/8c3f65c8a5687925e84708f19d63f7f37d28e2b86a48d951702ad94424d8/jiter-0.15.0-cp310-cp310-win32.whl", hash = "sha256:d92a5cd21fdb083931d546c207aa29633787c5dc5b02daab2d32b843f88a2c53", size = 209303, upload-time = "2026-05-19T10:07:10.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/0528a1eb9f42dd2d8228a0711458628f35924d131f623eaebc35fd23d3d4/jiter-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:e58585a58209d72691ce2d62a9147445f5a87beb0bde97fde284c96ae392a3d1", size = 200404, upload-time = "2026-05-19T10:07:11.426Z" }, + { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + +[[package]] +name = "jq" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/ef/60ec5e3d8b6ae79c02af010030692e9e7e12a3a8134bc048728de16eb137/jq-1.11.0.tar.gz", hash = "sha256:67f1032e3a61b4e5dcdd4e390527b0000db521ac9872b64517c83c5f71ef8450", size = 2031555, upload-time = "2026-01-16T16:38:32.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/21/c90086530a9b7b444ef09fcb10df97e9b48d33e37c1c3809c1b92a994d17/jq-1.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:04376071111798aa007f74196eb251fd9e008080412d81ba0f5042fdf75a2685", size = 415261, upload-time = "2026-01-16T16:36:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/06/a1/dc97419a5e1aa4505496e32a9758e94f2c2b1d7e4fc74142b70e8e88c21e/jq-1.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f298e21cdaddae7de3ec67742535c3e30acd800016aaee2f9521f77b4918094", size = 422798, upload-time = "2026-01-16T16:36:08.357Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/5dc762429e7e4f4939b4cccdf4b666fd38ee4118489715861bff74f3800e/jq-1.11.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62561f673be573e17fb80ff95ad428d17f55d29546f6c44ffa04edaccd68212f", size = 746518, upload-time = "2026-01-16T16:36:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/acdd9d263e561c21c56b783e05c98056520e0376a6808aaa2d1c2f44f33a/jq-1.11.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e63630a51a01d8a8d587cbfa5a34544d7aa7a49d5d14bb8206e6e435d18af935", size = 757810, upload-time = "2026-01-16T16:36:15.586Z" }, + { url = "https://files.pythonhosted.org/packages/58/f0/6e20be344121af026653e90391ae715de26e9bac517a6eadd592f0584fa3/jq-1.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e674aff383d7969645b97ec77fa49b774e129dbc203be04f88b2dac1bb390cd", size = 739999, upload-time = "2026-01-16T16:36:18.179Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/41054871f47cf4a4e6e490e0b9752a041185699014620d043f07bea84a00/jq-1.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:669feef2326865a964e0b156f83e6608e8d7011462f773339198b4b1882fd05c", size = 755730, upload-time = "2026-01-16T16:36:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/04df19f64da9622e450b02d4502826f8f1a715eb8a17547c44eac6038f90/jq-1.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:8b56408bbe7d19e6ad3f1ba34fd70b3a2269b5acb322651e1262c9632e9e2a01", size = 407801, upload-time = "2026-01-16T16:36:22.067Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/d1175847d2812bb81a9feb56c16fac2e6c115db2ac40ee607ca15c8095ce/jq-1.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe1c5facefd0f1197fb95cda8f31195487f431e4c4699cb0cb207efc47553504", size = 414890, upload-time = "2026-01-16T16:36:23.656Z" }, + { url = "https://files.pythonhosted.org/packages/79/6c/a69b5b75defc1f2fd9102a807d9ae8f0d00df42f70b71543810a7a2f7f10/jq-1.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e8f4f1fd9fd85416d978e4e0d8e3fb7603bdb7da87f5cd6dc5e94047b75a4813", size = 422620, upload-time = "2026-01-16T16:36:25.106Z" }, + { url = "https://files.pythonhosted.org/packages/70/16/dc00b5d536aadaf95cdfa857ea0703aa52ec5a4a048f7cedb795433d04fe/jq-1.11.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bb8d244a6b11140c0908affbad621485788500d4236cf7c09b6f0087e991815", size = 762515, upload-time = "2026-01-16T16:36:29.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/a466a102e6681c244a03673226591c76013f4c925560d049beb417d8bd79/jq-1.11.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17755df4b65ad9f9021c43b90a04c02f771b32b8c429a0e7ff160a13229f787e", size = 772478, upload-time = "2026-01-16T16:36:32.395Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5f/4c0b4b5b2bce315697cb4816ecd76830b173ae9ce1442aadc37726035938/jq-1.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a8d080c00b5fc66bb9ee2876402b978b84a7108526b4c9affd704813a2add78", size = 752141, upload-time = "2026-01-16T16:36:35.715Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2f/b86d089d46b89380c179ba1ccc4cd4ab42b1cd966404581b0d64b4eb0716/jq-1.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:06819d14f18187379959f0ac5fdd1bcf7f452d84623a2945d7ed1d1ceeba8499", size = 773240, upload-time = "2026-01-16T16:36:37.992Z" }, + { url = "https://files.pythonhosted.org/packages/3a/e4/bc09a6b066bd82b60cbdb57e1b17c4e63c9a613639497807c120045bbb8e/jq-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:c463edd4f45ff3e1923766a0c582a8e955bd889dd756e0ac6392d28f5ca144db", size = 407170, upload-time = "2026-01-16T16:36:39.56Z" }, + { url = "https://files.pythonhosted.org/packages/da/59/0cc99dddf0df36818621a56c4834db73a244fb86c9a567eb42d8c3bf0eaf/jq-1.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d63c4437f256edb6c204481181d19e3e33f24781b1bfbab2db589af574567bed", size = 414638, upload-time = "2026-01-16T16:36:41.14Z" }, + { url = "https://files.pythonhosted.org/packages/d6/9e/731b5793e13066b229d6d17fb7a4cefc4c9a8cb93d1779a0473df15f2064/jq-1.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0db188d73f2b6ad4e4f62653b2a4ea06dd99b790cc9a8aba6b617d9d0806aee0", size = 422617, upload-time = "2026-01-16T16:36:42.663Z" }, + { url = "https://files.pythonhosted.org/packages/b1/13/ffce6a15730fae2da4a540607a3cfc5085dd6466c64fc3c93073870f09a7/jq-1.11.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6d0cde00e6da44f49772c5f77b0efa1d11d5866b2af923fd94e2ffbdfae89c", size = 754635, upload-time = "2026-01-16T16:36:45.684Z" }, + { url = "https://files.pythonhosted.org/packages/e9/0f/cb3ddaae16b56d0f3dca1bef6fe78b5828e4e4425b192b973c2c212ace47/jq-1.11.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40489d775f77d9d8b4ec1f3ab1e977415e003c5065ad3befbe61ae80810bc381", size = 773750, upload-time = "2026-01-16T16:36:48.738Z" }, + { url = "https://files.pythonhosted.org/packages/28/98/6e2127958546603aab57acefd2196ce788b4902a223a8e05f3a4493b6c95/jq-1.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d2ac5087d6d16929cf0f9918ed83b3d4c5d765bd160d3af125131bb124001d3a", size = 742704, upload-time = "2026-01-16T16:36:51.465Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/1c40851bfe4c56e727abe81e2000d7a01c944fc6b409a4916b73fe38bdf1/jq-1.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8a67a52445535edffe10733b9da493134bfbf354acdbeb29d9ef28fd39938ac", size = 768539, upload-time = "2026-01-16T16:36:53.948Z" }, + { url = "https://files.pythonhosted.org/packages/1c/de/2529c874fd6c192931fa1aeb9a9f0a7f9555a296a560d41958ebc0a546b3/jq-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:824a295e62802a67a21f13e4b2d32a24ff5849f7bd435b042e68a9c598c8e778", size = 409287, upload-time = "2026-01-16T16:36:55.659Z" }, + { url = "https://files.pythonhosted.org/packages/f7/9b/eeb893591371ed1d6badc8c3d6ca0033e764a90b221734d578049ea9898a/jq-1.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9aba6e4a4e66a8d55f45137c7039dd56a65ad95ef4c1c1c208190971966429b9", size = 414359, upload-time = "2026-01-16T16:36:58.003Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ff/f80f75b398052e9d09ae09c9bb175ee2679c9a13dea42e9a8ffcc03fbc20/jq-1.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5295d6d2a6c1de53f9ca1cd3211f5728e20c0be1e6456486a9e6b6016102e173", size = 422335, upload-time = "2026-01-16T16:36:59.684Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6f/a0d8353e03b6f2e1dec08eddb4d1accf2f37605730f9539412c4a1ae0a5c/jq-1.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04a8c3b0b4d78cab658ec8cd1b34f7c9a711355ff6d8aa01a3b24955b3eb531", size = 748178, upload-time = "2026-01-16T16:37:01.511Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/85de5a406c36133b71f80c1a4f3872baa2d5b598ea0179e25fb7e1fb385b/jq-1.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e08573ee9a5448166f1e8f068ce05fba21db15adcebbaf2472729d36ec92a1e", size = 767262, upload-time = "2026-01-16T16:37:03.31Z" }, + { url = "https://files.pythonhosted.org/packages/74/51/926c0dee6c5c0cf71a8d6d4665b44ad0dbd18eb5a40455c9b93fa327a188/jq-1.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd4376b62fc18e8c3f5334f388f92a48b148cf755610ad44d22958a41edcbc8d", size = 737316, upload-time = "2026-01-16T16:37:05.027Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/49c02ee479b29083047a323c3f4ca6a6c8ef2a9ae1bfe6d86d73d4f283b7/jq-1.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:680edbd5838fb04539469e39e3e147ae3890b90af3e727d6028e370f8a202b1c", size = 762343, upload-time = "2026-01-16T16:37:07.411Z" }, + { url = "https://files.pythonhosted.org/packages/cd/16/4697abf6c1d92e8297e07c3fba6d400b5a9c71780a24072480d9076451d7/jq-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:54f896e878c89cef4c05aff53f822de62a08e91d08bad7cbf4f7e91b7a06a460", size = 409686, upload-time = "2026-01-16T16:37:09.921Z" }, + { url = "https://files.pythonhosted.org/packages/9c/67/a677b54f7db47a629bd2d417d04ee9d3b02148c87e60e78eef5d1477c6dd/jq-1.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:62b8fd1b93ba3bad1f1051fa955ff675d076466c2e900c59afe2393bc09c49bc", size = 401389, upload-time = "2026-01-16T16:38:19.759Z" }, + { url = "https://files.pythonhosted.org/packages/68/9d/c513b229d2ed90b96f1402eb8890b0a5191ca8829aef40af9024d8278228/jq-1.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:77686477535191cdd2a01acfdcf3d67e71b4319edf33cab5bf5e383bbe147291", size = 410983, upload-time = "2026-01-16T16:38:21.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8d/dc6be3df591340f963351c002413cc5e418f290f1f6011eb5831aba4fa28/jq-1.11.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bfeb6627f8dace23e0eaf7818ba4c5227e60bc2e843c9eafe895d59c0d274d1", size = 410635, upload-time = "2026-01-16T16:38:23.962Z" }, + { url = "https://files.pythonhosted.org/packages/48/39/0d819962352f178492069ba2767b4983e302a9867e856cec624f06bd21ed/jq-1.11.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:594ebd007244e16b333bd2f35a5b766176be107ee99f9d92883a79d50439b93c", size = 425107, upload-time = "2026-01-16T16:38:26.969Z" }, +] + +[[package]] +name = "json-repair" +version = "0.44.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/6b/ed6e92efc5acfbc9c35ccae1676b70e4adb1552421e64f838c2a3f097d9a/json_repair-0.44.1.tar.gz", hash = "sha256:1130eb9733b868dac1340b43cb2effebb519ae6d52dd2d0728c6cca517f1e0b4", size = 32886, upload-time = "2025-04-30T16:09:38.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b4/3cbd27a3240b2962c3b87bbb1c20eb6c56e5b26cde61f141f86ca98e2f68/json_repair-0.44.1-py3-none-any.whl", hash = "sha256:51d82532c3b8263782a301eb7904c75dce5fee8c0d1aba490287fc0ab779ac50", size = 22478, upload-time = "2025-04-30T16:09:37.303Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "langchain" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/3f/034eb6cbef90bfccc89b7f8ed0c1d853dc9cb0bea17c7a269534c647ba3a/langchain-1.3.4.tar.gz", hash = "sha256:d6e0654c22848925534f5c0a706f9be481bb09a619ec60a738fbd1e5502e457a", size = 606617, upload-time = "2026-06-02T20:04:49.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/29/9ffe99c7dc4891a0215ec59c423bea320f943c08a231bc5bae392a438a83/langchain-1.3.4-py3-none-any.whl", hash = "sha256:e51b05ab23d056bc6bf2d97d8c694fb92d6d5765126fef74565d007c27581672", size = 125286, upload-time = "2026-06-02T20:04:48.13Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" }, +] + +[[package]] +name = "langchain-ollama" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ollama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/9b/6641afe8a5bf807e454fd464eddfc7eb2f2df53cb0b29744381171f9c609/langchain_ollama-1.1.0.tar.gz", hash = "sha256:f776f56f6782ae4da7692579b94a6575906118318d1023b455d7207f9d059811", size = 133075, upload-time = "2026-04-07T02:48:00.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/b2/c2acb076590a98bee2816ed5f285e00df162a34238f9e276e175e14ebc35/langchain_ollama-1.1.0-py3-none-any.whl", hash = "sha256:43ac83a6eacb0f43855810739794dd55019e0d9b17bdcf3ecb3b1991ac3b59dd", size = 31413, upload-time = "2026-04-07T02:47:59.642Z" }, +] + +[[package]] +name = "langchain-openai" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/1b/c506c7f41156d3a6b4582b4c487f480001b8741deecc6e2d4931fdf4cf2c/langchain_openai-1.2.2.tar.gz", hash = "sha256:8698ffcee9a086e91ab6d207f0026181a03effcbf86bf9aee1808ee35af69dcc", size = 1147539, upload-time = "2026-05-21T22:08:31.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/8e/7406c99afacafc8c2ce0fa4152f9f8b9598c93ceb291959821abd053b982/langchain_openai-1.2.2-py3-none-any.whl", hash = "sha256:7da39a3c70cbafa93853456199e39a264dc70651be79b12ac49b4f6a448bce2d", size = 99631, upload-time = "2026-05-21T22:08:29.527Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/e7/8300ba22d968653051fd06e3117d783872dddf3dcebdd6b1d386836eb43c/langchain_protocol-0.0.16.tar.gz", hash = "sha256:806c7cdd951b1c4f692fa40fce60821ff0f221d4360e27673ddf2c2b99c2b7ff", size = 5969, upload-time = "2026-05-28T23:05:11.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/9c/06dfcc88d02a6364e8d864c421ddd3736305cb0a6c853f75c302c80fe17c/langchain_protocol-0.0.16-py3-none-any.whl", hash = "sha256:3658c142c5d0fb3a023a4be442ce4c15c6d626aab6135eb79a76dc64ad19c3c3", size = 7037, upload-time = "2026-05-28T23:05:10.163Z" }, +] + +[[package]] +name = "langgraph" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/43/dac5a2621c1e57f8eb7f0703f6f6fe34a5caf62f8f0fb4d2bb395bb454ea/langgraph-1.2.4.tar.gz", hash = "sha256:5df076973a2d23efb13eceb279d1e5b46feebcbbeded0a86a2ef669abd9e4399", size = 720374, upload-time = "2026-06-02T17:07:37.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/9e/31ca236104966d7bb14ea9e93cfd73350aea8c41008ddf057b65794ed10d/langgraph-1.2.4-py3-none-any.whl", hash = "sha256:ffe3e1e31dce28907640f82525858470f293506d2b272d07ea3b3ce97974b067", size = 245402, upload-time = "2026-06-02T17:07:35.977Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, +] + +[[package]] +name = "langgraph-swarm" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/9c/b76ce5eb38584097e671464632fdb854ab28457dd9e8ec3912f7208a1d9a/langgraph_swarm-0.1.0.tar.gz", hash = "sha256:e0f189fc2471d06108fc293da05771d0106d72f755cd130ee37d579561942479", size = 12432, upload-time = "2025-12-04T19:04:05.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/1f/a2fd04e6ba6f34609ae37a290b198f37155516ed67e34c12948c5a742437/langgraph_swarm-0.1.0-py3-none-any.whl", hash = "sha256:7020b9a1ba6959b8ddd0168e49b236e8f9967ea40a8a2ccd14c28ec57fe6c227", size = 10227, upload-time = "2025-12-04T19:04:04.72Z" }, +] + +[[package]] +name = "langsmith" +version = "0.8.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/dd/f4c8a12987318e505b10760d30c3c2d45e8dc87ba8f47a004c753a9e7b35/langsmith-0.8.9.tar.gz", hash = "sha256:f16e37fcd5a8a2d4db30eae0e399a866a65ce5cc86218825c59409ed57a3bf53", size = 4428684, upload-time = "2026-06-03T17:56:09.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/2f/a701663c9fb4d9630448622a684bc372b4905b9a6dbe2297d55a70fde04e/langsmith-0.8.9-py3-none-any.whl", hash = "sha256:c9519cabc75568d088df045710d1b86eae9780c91054528b2aa7e6cb1fc80c52", size = 403165, upload-time = "2026-06-03T17:56:07.226Z" }, +] + +[[package]] +name = "litellm" +version = "1.87.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/e5/d0ac1c8f55e2c8d8799589e831bef0d450e69e02ecb511901ffc8de054d9/litellm-1.87.1.tar.gz", hash = "sha256:70ac9d6b25f56ad30de6ff95d26fac3b3fc697a95da582b6072d25d8dc73d493", size = 15455709, upload-time = "2026-06-04T16:23:23.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/18/8275c95ef09e81ab0c01a162c7b780ce3fbc49066b5d532c6b6ab3dc0118/litellm-1.87.1-py3-none-any.whl", hash = "sha256:dd4e00278cdb846d52e99a09d732575a897273540b54eb044247ecbc0d98f67c", size = 17105482, upload-time = "2026-06-04T16:23:20.769Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "ollama" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/72/5f12423b6b39ca8430fbe56f77fcf4ef60f63067c7c4a2e30e200ed9ec16/ollama-0.6.2.tar.gz", hash = "sha256:936d55daa684f474364c098611c933626f8d6c7d67065c5b7ae0c477b508b07f", size = 53145, upload-time = "2026-04-29T21:21:15.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/d6722beeb2d10f7a3b9ff49375708904fde18f82b5609a0bc4aeb5996a4d/ollama-0.6.2-py3-none-any.whl", hash = "sha256:3ad7daab28e5a973445c36a73882a3ef698c2ebb00e21e308652741577509f7d", size = 15115, upload-time = "2026-04-29T21:21:13.794Z" }, +] + +[[package]] +name = "openai" +version = "2.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload-time = "2026-06-03T22:39:40.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5d/b95ca542a001135cc250a49370f282f578c8f4e46cc8617d73775297eea8/orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce", size = 228986, upload-time = "2026-05-06T15:09:14.765Z" }, + { url = "https://files.pythonhosted.org/packages/80/01/be33fbff646e22f93398429ea645f20d2097aea1a6cdc1e6628e70125f83/orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd", size = 132558, upload-time = "2026-05-06T15:09:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/4e/61/73d49333bba660a075daccca10970dc6409ce1cf42ae4046646a19468aad/orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4", size = 128213, upload-time = "2026-05-06T15:09:18.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7d/30e844b3dac3f74aed66b1f984daf9db3c98c0328c03d965a9e8dc06449e/orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4", size = 135430, upload-time = "2026-05-06T15:09:20.257Z" }, + { url = "https://files.pythonhosted.org/packages/16/64/bd815f5c610b3facc204f26ba94e87a9eb49b0d83de3d5fc1eee2402d91b/orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e", size = 146178, upload-time = "2026-05-06T15:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/c7/35/e744fd36c79b339d27beb06068b5a08a8882ef5418804d0ce545a31f718d/orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb", size = 133068, upload-time = "2026-05-06T15:09:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/d54152b67b63a0b3e556cfc549d6ce84f74d7f425ddeadc6c8a74d913da7/orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47", size = 134217, upload-time = "2026-05-06T15:09:24.847Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ee/66154baf69f71c7164a268a5e888908aec5a0819d13c81d5e2755a257758/orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d", size = 141917, upload-time = "2026-05-06T15:09:26.647Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/c5824260ca8b9d7ba82648d042a3f8f4815d18c15bb98a1f30edd1bb2d83/orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13", size = 415356, upload-time = "2026-05-06T15:09:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/509c2e816fe4df641d93dc92f6a89adc8df3ada8ebdee2bd44aba3264c3c/orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92", size = 148112, upload-time = "2026-05-06T15:09:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/db/b5/3ceae56d2e4962979eedb023ba6a46a4bb65f333960379be0ca470686220/orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48", size = 137112, upload-time = "2026-05-06T15:09:31.432Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/81fa3f2c7bef79b04cf2ab7838e5ac74b1f12511ceab979759b0275d6bb4/orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94", size = 131706, upload-time = "2026-05-06T15:09:32.707Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/b64600f9083c7f151ad39717a5877fccbeb0ef6d7efcb55f971ce00b6bee/orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244", size = 127282, upload-time = "2026-05-06T15:09:33.955Z" }, + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "pyagentspec" +version = "26.2.0.dev6" +source = { git = "https://github.com/oracle/agent-spec.git?subdirectory=pyagentspec&rev=agent-spec-26.1.2#0799958f9087c02ac8c56df808b4adf0fb3ad539" } +dependencies = [ + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +langgraph = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpx" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langchain-ollama" }, + { name = "langchain-openai" }, + { name = "langgraph" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-swarm" }, + { name = "langsmith" }, + { name = "urllib3" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ed/0ad2c8edf634918eb4484365d3819fa7bd7f58daf807fe7fb21812c316e5/regex-2026.5.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a9e1328e17c84c1a5d22ec9f785ecef4a967fab9a42b6a8dc3bcbebd0a0c9e44", size = 489438, upload-time = "2026-05-09T23:11:29.374Z" }, + { url = "https://files.pythonhosted.org/packages/89/a9/4ed972ad263963b860b7c3e86e0e1bcc791def47b43b8c8efe57e710f139/regex-2026.5.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfe1ce50cbfb569d74e1e4337da6468961f31dbea55fd85aa5de59c0947a805a", size = 291270, upload-time = "2026-05-09T23:11:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/16/81/075930d9fa28c4ea1f53398dd015ee7c882f623539759113cda1257f4b82/regex-2026.5.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15ee42209947f4ca045412eae98416317238163618ace2a8e54f99586a466733", size = 289198, upload-time = "2026-05-09T23:11:35.769Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c8/5cdfbf0b5dc6599e1b6131eff43262e5275d4ec3469ce10216061659aadb/regex-2026.5.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb445ff3f725f59df8f6014edb547ee928ec7023a774f6a39a3f953038cbb2", size = 784765, upload-time = "2026-05-09T23:11:37.689Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ca/ae5fd6edc59b7f84b904b31d6ec39a860cbcecd10f64bd5a062ca83a4864/regex-2026.5.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446ddd671e43ab535810c4b21cff7104945c701d4a14d1e6d1cd6f4e445a8bea", size = 852115, upload-time = "2026-05-09T23:11:39.973Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ce/a91cf555afb51f3b74a182e24ba073b91ea7bb64592fc4b315c111bb19fd/regex-2026.5.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b92817338591505f282cf3864c145244b1edcf5381d237038df955001091538", size = 899503, upload-time = "2026-05-09T23:11:42.48Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/725a0a2b245a4cf0c4bab29d0e97c74285d94136a65d1b55a6459a583502/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b8a143aca6c39b446ea8092cde25cc8fe9304d4f5fecfbc1a9dbb0282703c2", size = 794093, upload-time = "2026-05-09T23:11:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2a/996efbd59ce6b5d4a09e3af6180ceb62af171f4a9a6fb557d2f0ae0d462b/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0f03aa6898aaaac4592479821df16e68e8d0e29e903e65d8f2dfb2f19028a989", size = 786234, upload-time = "2026-05-09T23:11:46.882Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/8731e8b8806174c9cdd5903f80a14990331c1f42fc4209b540952e9e010d/regex-2026.5.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed457d8e98ae812ed7732bef7bf78de78e834eae0372a74e23ca90ef21d910f9", size = 769895, upload-time = "2026-05-09T23:11:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0b/932473194bd563f342a412ae2ffbbd6da608306a2bc4e99249a41c2b0b92/regex-2026.5.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71b61c5bfe1c806332defc42ad6c780b3c55f661986d7f40283a3a88274b4c00", size = 774991, upload-time = "2026-05-09T23:11:51.261Z" }, + { url = "https://files.pythonhosted.org/packages/98/80/9523d196010031df25f7177ee0a467efbee436324038e5d99def17a57515/regex-2026.5.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3b1e39888c5e0c7d92cea4fc777396c4a90363b05de75d02eb459a4752200808", size = 848790, upload-time = "2026-05-09T23:11:53.232Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/56987b35e89edf47e4a38cf2845aeee476bfa688a6bdbd3e820cda461dc1/regex-2026.5.9-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6ba42b2e7e7f46cf68cc6a5ca36fa07959f9bbd9c6bdcc47b6ee76549a590248", size = 757679, upload-time = "2026-05-09T23:11:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/04/2a/ff713fff0c566507c06a4ce2dc0ae8e7eeebc88811a95fc81cf1e7d534dd/regex-2026.5.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c010eb8caca74bdb40c07498d7ece26b4428fd3f04aa8a72c9ac6f79e8faaac6", size = 837116, upload-time = "2026-05-09T23:11:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/77/90/df6d982b03e3614785c6937ba51b57f6733d97d2ee1c9bc7531dbfab3a54/regex-2026.5.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a6a563446a41adc451393dc6b8e6ad87979efaee3c8738690a8d1b08ebead1b4", size = 782081, upload-time = "2026-05-09T23:11:59.607Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/4e88a5f7c3e98489aac4dd23142723d907b2a595b4a6abcbacabefeded09/regex-2026.5.9-cp310-cp310-win32.whl", hash = "sha256:954cc214c04663ee6d266fc61739cad83054683048de65c5bd1d640ad28098ac", size = 266247, upload-time = "2026-05-09T23:12:01.116Z" }, + { url = "https://files.pythonhosted.org/packages/6a/40/4b224cb0582b2dca1786726e6cdabe26abbf757d7f6718332f186da155d2/regex-2026.5.9-cp310-cp310-win_amd64.whl", hash = "sha256:b310768746dd314ea6e2ff4cc89ef215426813396ff4e94ee8e6f7096c8b6e03", size = 278416, upload-time = "2026-05-09T23:12:03.2Z" }, + { url = "https://files.pythonhosted.org/packages/12/4d/014fbe803204cab0947ee428f09f658a29632053dde1d3c6176bb4f0fd4c/regex-2026.5.9-cp310-cp310-win_arm64.whl", hash = "sha256:19c16ceb4a267a8789e25733e583983eeab9f0f8664e66b0bd1c5d21f14c2d4b", size = 270413, upload-time = "2026-05-09T23:12:04.649Z" }, + { url = "https://files.pythonhosted.org/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" }, + { url = "https://files.pythonhosted.org/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" }, + { url = "https://files.pythonhosted.org/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" }, + { url = "https://files.pythonhosted.org/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" }, + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/e3/03c90dadcf5b3f82b83cee9adee60ef666b329c654f58c066af44eae0287/tiktoken-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:47b1df8d73390a24f94980c75158cdd5c56d256f16d55f30cb49c230caba9ba4", size = 1036627, upload-time = "2026-05-15T04:50:11.229Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/760463e5b2e8ad2bc229ae0a17ecb06727b6cbc094f08d8f65844315632e/tiktoken-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7d40c6c5aab171dcd6eb8455bc567bde404bb9def60cdb8c1299cc782b242bb9", size = 984699, upload-time = "2026-05-15T04:50:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/de/8a/8895f342a6b6aabd1a358e672f6f077b3ae51d0c63ca605d142db3bcd8ab/tiktoken-0.13.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:9b842981fa91accdffd48ff6408a977b7a91c3fbda55d353c3c68114d5c9d69e", size = 1118690, upload-time = "2026-05-15T04:50:14.234Z" }, + { url = "https://files.pythonhosted.org/packages/51/e0/92557768fb0801f0d9dd9243cb9b6d342900b05e4b1006d4771f49ce233e/tiktoken-0.13.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ed5a30027cb4d8c7ca8b273d4766f3db3cf58fad9e9f3b1a68a351ffb54873d5", size = 1138423, upload-time = "2026-05-15T04:50:15.668Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b9/a3d99feeedb032ffd09cd6652077f86bdee9a70dd0b990b2b272b445d4c3/tiktoken-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7ab10f4a21c2999846940113f6dbd72e0fa06a24119feddd74cc47e85818e06d", size = 1185077, upload-time = "2026-05-15T04:50:17.19Z" }, + { url = "https://files.pythonhosted.org/packages/cc/93/bab868277d475dc6d2aaacd34cdd239c282f4908dcc8702e0a3311a8e032/tiktoken-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a2937ad042d49d50eac6e1ba07c5661d4bd3942a5b1e0c0d08475c4df83676e1", size = 1241702, upload-time = "2026-05-15T04:50:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/c3/16/27e9f7e0ed76e501cfefc9fb2112df4c7bf70ca96945b15ecb7615aac860/tiktoken-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:44733b99bfd72b590cd0936b1c01b3b4dd73122db2d544bc1ceeb18a7678c910", size = 876565, upload-time = "2026-05-15T04:50:20.268Z" }, + { url = "https://files.pythonhosted.org/packages/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" }, + { url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/fe42ad00de01a8c4a49ad8649a2c8a316835a9cad5961b11d21eac0020a5/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173", size = 1138253, upload-time = "2026-05-15T04:50:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/ccee1ecccca107e9a16efcecdeeb964c325305038554d466ece65b42338f/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff", size = 1185747, upload-time = "2026-05-15T04:50:27.02Z" }, + { url = "https://files.pythonhosted.org/packages/9d/03/cd0cba295522b91eb55c6b2704f1df895f8226cfe60ab10d4d51d0cc9e69/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed", size = 1241265, upload-time = "2026-05-15T04:50:28.815Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/a10efd564402d82c2ff50d12057353ace447aa8007deceaa48641f63d35c/tiktoken-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94", size = 876509, upload-time = "2026-05-15T04:50:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/a1/822ceef22d1c139cffebe4b1b660cfaa10253d5c770aa2598dc8e9497593/uuid_utils-0.16.0.tar.gz", hash = "sha256:d6902d4375dfba4c9902c736bb82d3c040417b67f7d0fa48910ddfdb1ac95de7", size = 42596, upload-time = "2026-05-19T07:44:23.28Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/78/fc830a25597001586770f0436a4917aac21fcdaf7ac2824bbe168ccdc724/uuid_utils-0.16.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a632fead2a6505a8df3318d5e95503739b9aa1c518521cd93d83ce00699b78f8", size = 566691, upload-time = "2026-05-19T07:45:14.2Z" }, + { url = "https://files.pythonhosted.org/packages/10/39/3f1eee6d3c3c33d6dd75441bdb49ac246de57f97f67faa7ff04cdb5e4ffe/uuid_utils-0.16.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:d716e5b35266400d2a2cd349697868179825f113c543e55c9d2ac304991f8d4f", size = 291039, upload-time = "2026-05-19T07:45:52.28Z" }, + { url = "https://files.pythonhosted.org/packages/c6/85/f7fb16eed216fd8085d62d4ce7179e2a81ac7649e043f34168e7700b6df4/uuid_utils-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:207c2a98ca8b065cc93378a3a59744efb88a68e9ecc2c3afefe43d59c864280a", size = 327880, upload-time = "2026-05-19T07:44:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/b2b629d29c8234677850e1ae47add9c8866dfb3864af257542989a13ba1b/uuid_utils-0.16.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79824850330e450c7b2fa933572e32192240060937426052fa3fc05134ed3faa", size = 334090, upload-time = "2026-05-19T07:44:57.354Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/a6871c6231244bb80be06a2babf3ca34396b29d893103d84ddfd3654e6e4/uuid_utils-0.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d89927c47e1a55509e90b7f2fd3e7ff89908c77b61f8f0deda97a89d8854e0f8", size = 448558, upload-time = "2026-05-19T07:45:03.986Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d0/b606a2857f98c20c149044e80f276ff7966c9f679fc7b25f6d608bd8d48b/uuid_utils-0.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ae4168e1ca0ae69d24207645a8b3cd2b641a0ad15058eda17d2c9898aa89d3", size = 327733, upload-time = "2026-05-19T07:43:40.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/7951dd47b6717b6ebb340e673d31d539be928d280a697fab4dd233bcc7fa/uuid_utils-0.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d363017a3223de3a57eb6fca135df6ffcef7c534836bff2e71354dce7d10987c", size = 353659, upload-time = "2026-05-19T07:44:03.551Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5d/f46e91fad5f049c7bd12701293c1ac31b4460ec83606c4bdd37c05abef52/uuid_utils-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4a87a7433b355eadaa200f150da6bb5b87bb6de0adf260883b26cb637aba0410", size = 504509, upload-time = "2026-05-19T07:44:34.147Z" }, + { url = "https://files.pythonhosted.org/packages/f4/94/ea4f559e5e87da5847ecf78ba68a78e8bb4e537e1169093ea543cab94886/uuid_utils-0.16.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6da070e75b0e2424728e6f8547647cce36c83f9a6101a08da4849a8ab2b58105", size = 609358, upload-time = "2026-05-19T07:44:39.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/60dbac2459426a925b77e08cb8ec492d4bc82caa0f124f498d2e24409cb8/uuid_utils-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1baab8966f9e0097cbaf9cc01ad448b38e616e7b4968ca5e49cb53a74ad91a2f", size = 569428, upload-time = "2026-05-19T07:44:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/ae39c1e1bff65dfe9c7c70cbd64b8d529a3d1cc836aeaa7accdc44e5c308/uuid_utils-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b42014536943c1a654ff107538c0f7dc39809d8d774ec8dafd19bec05006e568", size = 532465, upload-time = "2026-05-19T07:44:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/03/5c/4dc93017a095c9c314525a9abc4f9983e520d88d7eff9bd52398d81c374e/uuid_utils-0.16.0-cp310-cp310-win32.whl", hash = "sha256:228701ab6f188b6def24f2add6db64f0794adb1f06d0abacdcec40b0cda13cdf", size = 171162, upload-time = "2026-05-19T07:44:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/43/df/1398f5b117d5daa4d757b156728db7aa092a3eff1271c40ec39dbe945327/uuid_utils-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:10d3c5983f770b1b2847ad811c87a1c9e28f8155d1a27cc581abcd5abb386b64", size = 176927, upload-time = "2026-05-19T07:44:54.93Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/0e18177e2fbb0b9f54f90fd48fe3302dfda731e22ad650d6e6f8f4b3d3d3/uuid_utils-0.16.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:04af9966ecd82b78eeba5725e29aa1e86fb8eb84b5443dd6a9935f9fadb6678e", size = 565929, upload-time = "2026-05-19T07:44:06.496Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7e/bb91b04b2c8a081a4df2d50f1a50dd85502e2391c6eaed71b339ec9f2524/uuid_utils-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3d86ca394e0ea21bdb53784eb99276d263b93d1586f56678cab1414b7ae1d0f3", size = 290556, upload-time = "2026-05-19T07:43:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/69/2a/47ee18b294af59754ef5acfa96eb027137c98cef7521199b6f70be705de4/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9f504efeb20ffd9571621658f7c8093c646d33150406d5742e49ff7cd861615", size = 328059, upload-time = "2026-05-19T07:45:30.533Z" }, + { url = "https://files.pythonhosted.org/packages/89/7c/ed6d8bb48eeecaed6722af1187d722c5243334be750419d10d5f05dffeb2/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57d85f48535dc541060f6b82f277cbcd12b78c04008ccc1039546cfcec027327", size = 334759, upload-time = "2026-05-19T07:45:07.715Z" }, + { url = "https://files.pythonhosted.org/packages/ff/33/371bddf9fd47e045c375df9668eea0d96ce9201ab6a03985b0155498e376/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39453f1ebf4398fbeb71607f3437e2ac469c9e38b5921755c1e17ad0158a8907", size = 448927, upload-time = "2026-05-19T07:45:11.464Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f1/b201d5ee005d4987fc072714fcb9f6e75303520cf19d4deec0b4df44bf40/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50361aca5c2a770728a6343df85109fe57f89ac026827f34fe0153563cdc9ce7", size = 327178, upload-time = "2026-05-19T07:44:02.255Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/04b4c02ce5c24a3602baa12e59bd3ec853ae73c3e9319b706c4620f47a05/uuid_utils-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:948485c47d8569a8bf6e86f522a2599fa9134674bee9f483898e601e68c3caca", size = 352981, upload-time = "2026-05-19T07:44:25.578Z" }, + { url = "https://files.pythonhosted.org/packages/2c/19/25db019727d14630c75c2a75a8ea66dd712bb468adcf410bac8d01ff19fd/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ceef237cf8467fddbf6d8466cc1f6e2c04605ec919046ef5eba10a895b559fcf", size = 504686, upload-time = "2026-05-19T07:43:46.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/93/c000cd42ebfdd37cc74981ed31c979a1270156572bdebab8b5d61460e750/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:24e6fa0d0ade7a9ad60a3c296022474983243df5b4e863babb4828a85ef2e52c", size = 610102, upload-time = "2026-05-19T07:45:53.765Z" }, + { url = "https://files.pythonhosted.org/packages/15/1d/7dd239909c82616722b9ee53fa1b4657c6244fb4fd026890300ebf6db22b/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1c2df42314b014c9d23330f92887e21d2fc72fde0beb170c7833cd2d22d845a1", size = 569048, upload-time = "2026-05-19T07:45:41.596Z" }, + { url = "https://files.pythonhosted.org/packages/f1/49/b6a688648368a9cc0137e183657956853a91dc06ef73deda27290d586155/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e2f369dd734050fe96ae4905c58779b09276d47d5e9a0e5cd33ec7982784341", size = 532255, upload-time = "2026-05-19T07:45:16.936Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fb/34f221ae93d5ea249a0d7056bdf45313b8d267d6aa9c5d0673ac1a4746c7/uuid_utils-0.16.0-cp311-cp311-win32.whl", hash = "sha256:733da81d51ea578862d8b9b754e8968b6da2be2b7840aee868917c23cae84015", size = 171081, upload-time = "2026-05-19T07:45:26.578Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/c2a608a813f655834ee6df4ce53ea46edad4d54f774eac1890be5c7e4e1c/uuid_utils-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:10d21fddb086e69245c4f0f77c7b442471f3a242aa85f62954bff157baa1c5f2", size = 176770, upload-time = "2026-05-19T07:43:49.102Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/8ab4eff328a833c065f280b2e0d9ac873505b5e5282f2bc5133a9843d4dd/uuid_utils-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:98e2404713677070cee9a99a1f1e24afd496c18e833ee1b31a0587659452ff80", size = 175274, upload-time = "2026-05-19T07:44:27.216Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4c/b4cf43a5d22bcdb91727acdf54be0d78e83e595b73c5a9a8a4291875f059/uuid_utils-0.16.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:727fae3f0682191ec9c8ce1cd0f71e81b471a2e26b7c5fd66712fc0f11640aa0", size = 562183, upload-time = "2026-05-19T07:45:02.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fb/4b0d1c4b5e9f8679ca41b9cdbce5749e1d5db3d3d42a07060d6ce61ac583/uuid_utils-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66a9c8cedf7695c28e700f6a66bde0809c3b2e0d8a70968be7bfd47c908952e5", size = 289018, upload-time = "2026-05-19T07:44:07.726Z" }, + { url = "https://files.pythonhosted.org/packages/de/43/2dc6c7401c8fab86e46b0b33ada6dcfde949b2fd48877ba6f880862be80e/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9152bff801ec2ccf630df06d67389090a2c612dea87fbf9a887ab4b222929f6f", size = 326171, upload-time = "2026-05-19T07:45:25.186Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/48f11fb91f36453611ca148bc441436f279870b1ec6b576dc5167fb6e680/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06fc7db470c37e5c1ab3fd2cd159697d6f8b279d7d23b5b96bd418b115f8caa9", size = 332222, upload-time = "2026-05-19T07:45:09.036Z" }, + { url = "https://files.pythonhosted.org/packages/30/cb/b2b49528521e4a097f129e8bf7850a26f00af46afba778832cf3458a5c00/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e1a1f57fe3631e164dad27b24aa81267810e20575f705af3b0fa734f3a21247", size = 444801, upload-time = "2026-05-19T07:45:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b3/a28d9c6f7c701dfe01c8020b30e33899a28eb9e4d056b07e7388f50ebf67/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ee392fe59808a731b7b6bf4d453fb6e833774921331cceae5f254d1e9c5b97d", size = 325594, upload-time = "2026-05-19T07:44:44.682Z" }, + { url = "https://files.pythonhosted.org/packages/cf/65/e1ff41dc44966e396ead86e104ba21b35ddb07ff7a64bb55013074ee77fe/uuid_utils-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b2e981b1258db444df4cf4bf4c79673570d081d48d35f22d0f86471e0ad795c5", size = 349312, upload-time = "2026-05-19T07:45:15.582Z" }, + { url = "https://files.pythonhosted.org/packages/ed/57/fb19b7951f66a46e03bd1943a61ee9d59c83e994e56e8c97d79aff1f0e47/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbb92feb4db08cd76e27b4d3b1a82bfde708447317150c614eb9f761a43b387e", size = 502115, upload-time = "2026-05-19T07:43:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8e/9a129c469b7b77afb62da5c6b7e92591073b845bd0c3108c0d0aa65389fb/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c3c5afaaa68b1d6393d653e9fc93a2fde9da1681da01f74b4593f41d31fb5f1", size = 607433, upload-time = "2026-05-19T07:44:11.675Z" }, + { url = "https://files.pythonhosted.org/packages/4a/56/2ef71fad168cc3d894f7094fa458086c093635d7835381c91470b19c9ad3/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:38126b353527c5f001e4b24db9e62351eb768d0367febcd68100a4b39a035109", size = 566076, upload-time = "2026-05-19T07:44:35.453Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/68e60ea053ca30f35df877b96001331398140d5c4983561affa1350331b1/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41a67e546d9adf11c4e4cb5c8e81f000f8b1f000c17912ced089b499855719a5", size = 530645, upload-time = "2026-05-19T07:45:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/42/19/b521f7d73094fca4c0c44002f4a42bfcbcf0b770fdc3c4b9a596dda25734/uuid_utils-0.16.0-cp312-cp312-win32.whl", hash = "sha256:52d2cc8c12a3466cd1727883e0746d8bad5dddd670369eb553ba17fdc3b565ca", size = 168887, upload-time = "2026-05-19T07:45:45.502Z" }, + { url = "https://files.pythonhosted.org/packages/87/1f/4126c3ccbc2d98a613664e55f6ab6d7bd4b98424a04486e4fcc76549af15/uuid_utils-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97625e5edfda8b118160ce1e88756f92b1635775f836c168be7bf10928d97fa", size = 174607, upload-time = "2026-05-19T07:43:52.938Z" }, + { url = "https://files.pythonhosted.org/packages/74/62/b83ccc8446ae39dcc0bda2cb3b525b6af6a2036383afe1d1d5fe7b234c2c/uuid_utils-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:baf79c8050eb784b252dd34807df73f61130fe8676b61231baccab62530f20ec", size = 173021, upload-time = "2026-05-19T07:45:10.204Z" }, + { url = "https://files.pythonhosted.org/packages/60/9b/74c1f47a9b4f138a254e51528e5ffaeba6bf99ecead9f0c4b6fccccfbfcb/uuid_utils-0.16.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d34cf9681e8892fad2a63e393068e544505408748cd8bf0c3517d753a01528d4", size = 563166, upload-time = "2026-05-19T07:44:10.494Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1c/009e37b70f1f0ff17e7103a36bafde33d503d9ea7fe739761aa3e3c9fde6/uuid_utils-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0681d1bdb7956e0c6d581e7601dabcfb2b08c25d2a65189f4e9b102c94f5ff46", size = 289529, upload-time = "2026-05-19T07:43:54.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5e/e0323d54321166639eb2be5e8a464f5cb0fc04d72d91f3e78944bb6a1da8/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed45fb8732d216426227096b55accbb87cba57febc86a044d90780b090eb99d0", size = 326328, upload-time = "2026-05-19T07:45:31.901Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a3/046f6cb958467c3bf4a163a8a53b178b64a62e21ed8ad5b2c1dacb3a2cfc/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b617a334bb01ef2ff8c22900f5a14125eb9063f602131494cc9dc59519beaa5b", size = 332322, upload-time = "2026-05-19T07:43:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/67/80/01914e3949744db7acd0006885e5542fbebb6e39114857d007d29b3265c2/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a750d8aeb8ae880aa9a2529606bde0e994bcc7448730c953107f357a28e6102e", size = 445787, upload-time = "2026-05-19T07:45:36.102Z" }, + { url = "https://files.pythonhosted.org/packages/14/ef/f6908f41279f205d70c8a0d5dcb25dd6802741d7f88e3f0123453c3584d3/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a250e111903c4368745fce5ac2aa607bd477c62d3307e45347338fdb64b38e0", size = 324678, upload-time = "2026-05-19T07:45:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/11/4a/bf841ba90f829c7779d82155e0f4b88ef6726ccc25507d064d50ac2cd329/uuid_utils-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:95b7f480010ea98a29ee809857a98aa923008c68129af1b39244adccff7377fb", size = 349704, upload-time = "2026-05-19T07:44:47.172Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3b5c60172b8c57bf4ca485484b8e4edef550ca324f9287f1183be97422e2/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:420aa3ca403cedb73490b6ea3aeefeea7e0455f5ce60bbf856390ee872ae3306", size = 502456, upload-time = "2026-05-19T07:45:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/88/bf/3da8d497af80fd51d8bf85551c77ede67f07825924ec5987bf9b6031014a/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b8a9a7b1065a12d40f2cc25b7d705ab34954cc57095034367bca39ebcf4a876b", size = 607727, upload-time = "2026-05-19T07:44:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/7c8cf03ec15cd6f40e4cbab81b2b4a625461327f68c7971e54723280ec3e/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f235ac5827d74ac630cc87f29278cdaa5d2f273613a6e05bbd96df7aa4170776", size = 566204, upload-time = "2026-05-19T07:44:51.225Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5f/af955feae69cce7fd2121ca3f790ff4b85ad2e17b2149546f50753e1a047/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c8083284488b84ad178e74add64cfd1e74e8be5e30821e5acbc5019281c658b0", size = 529986, upload-time = "2026-05-19T07:45:57.85Z" }, + { url = "https://files.pythonhosted.org/packages/10/cf/3fec757e51bef10eb41ae8075f5442c60e85ff456b42d16a3063f5dc6c80/uuid_utils-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:27a071a899ba46a551d6524dbbc5a98b88be176d0f55ddf72cf71c005326ac10", size = 98683, upload-time = "2026-05-19T07:44:16.369Z" }, + { url = "https://files.pythonhosted.org/packages/40/a7/cd1adbea7ef882a70db064c00cd93b12e11027b4cdd7ffd79e95c35fc3e3/uuid_utils-0.16.0-cp313-cp313-win32.whl", hash = "sha256:924a8de04460e4cf65998ad0b6568084f7c51740ebd3254d07a0bcde35a84af6", size = 168822, upload-time = "2026-05-19T07:44:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/74/99/617ceb9e3a95b23837012740979baf71afad723b70daf34862da3f7c17a1/uuid_utils-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:5279bc7ab3c6683f1c67314695bee14d869015acbbc677bdb0015190fe753d16", size = 174967, upload-time = "2026-05-19T07:44:56.022Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d8/148ae707bfc36d482e39db679c86b81bdce264d4feb9df5d40a03b7687e3/uuid_utils-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:61a9c4c26ad12ac66fa4bfd0fdb8494724fe7a5b98a9fcd43e78e2b388663dbb", size = 173142, upload-time = "2026-05-19T07:43:50.171Z" }, + { url = "https://files.pythonhosted.org/packages/21/05/ca6d60705e71fdeaa3431dad94e279a8213c5573cb2925e1aabf3dc0330a/uuid_utils-0.16.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73486b6aa3f755a6c97000f5ea67e7ac78d6df89bf22980789a1e943e24b74f0", size = 564408, upload-time = "2026-05-19T07:44:38.351Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/b9a0462c38535c1662acb1025768e2d626bee5ce9e1790bad6b5381162ea/uuid_utils-0.16.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f1614572fd9345cdc3dde3f40c237345719fabca1aa87d2d87b321d523cfa34d", size = 289923, upload-time = "2026-05-19T07:45:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/a53afeef1a56051551a0f5a801e4bce411dd73c6a8c99bad16902651256d/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9346ce6eb1fbd8b03a6b331d66016afcb4edcdff6eac708e21391600529a016a", size = 325762, upload-time = "2026-05-19T07:45:18.261Z" }, + { url = "https://files.pythonhosted.org/packages/72/ca/4462a4f36365d7ee72d41e05e6bcfe127e861b073ab37c25b2c8a518317c/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a0fc6eb3fd821466fbab69cf356c6ec2b7327266bbbc740a2eb57c77c4bef965", size = 332359, upload-time = "2026-05-19T07:45:34.886Z" }, + { url = "https://files.pythonhosted.org/packages/c5/67/9d3373fa7c5a746fdecc64e30caf915c29eb632203508d87676f9243ed03/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13a797e5e8f0dadc18351a5aa013815ddac25dce6864072a539d510910c95f71", size = 445483, upload-time = "2026-05-19T07:44:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/57/08/ce01aa6d897fc7f875844fe58cad0a542c8ebf089d9242b654b56260ecb8/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57c3583b1f1c00a94f59726a5e2b988fa209221143919a1af5c2fc24e318fc98", size = 326281, upload-time = "2026-05-19T07:44:59.677Z" }, + { url = "https://files.pythonhosted.org/packages/76/ef/2c719b2c26bb5b5e5061a1435c11ad2bd33ac3cd6d4cd0c7c3ac1d3396ed/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:caac9c8b1d50e8fbddc76e93bfefbef472978eb45adbfdb6289d578816992953", size = 350809, upload-time = "2026-05-19T07:45:28.076Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9b/c1ed447328b32229cca38ac4c62d309eab006e5e9c4020e2056a175bc607/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:91db59bad97ed2b9d2c6ed25082fe9762b2c422e694fe06786b28cf4e776ac4c", size = 502088, upload-time = "2026-05-19T07:44:09.208Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e0/8442f4efe7bde72f0b4ae5f675d0c7fbe209ad0b54718b8ddf43c46c6fae/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:41985e342a30e76366a8becc60bbdb07d72cd1b86ec657b1f31654e9fb1baada", size = 607631, upload-time = "2026-05-19T07:44:19.384Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/9a9fa261edf4c972f28ae83421377e3ab8dbd0bd7db58fd316e782d09a3b/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1b0dcedf9266bf34a54d5cbe78648eaa627e02352f2a6923ed647530aea2f661", size = 567618, upload-time = "2026-05-19T07:43:58.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/1bcfdb9d539bd42736dd6076470a42fbb5db23f79712c0a06aa0a3752f7b/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:26fe23ab60f05de4ad70aaa5b6a4c2a7bbd43055e3dd6f6b31efba0532ac9c71", size = 530971, upload-time = "2026-05-19T07:45:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/24/0c/18945f417d6bb4d0dd2b7652fe36c58c4e83bcf593b9b326b83aa40b853a/uuid_utils-0.16.0-cp313-cp313t-win32.whl", hash = "sha256:7f8cf49c05d58523a0f977cb7f11afc05791a0fa164d7303b8365a34750638e7", size = 169369, upload-time = "2026-05-19T07:44:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/c0eb0c3fab2ed80d706369b750029143b53126809b77b36bcbb77da66bab/uuid_utils-0.16.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e99f9a8b2420b228faba23a637e96efaf5c6a678b2e225870f24431c82707f50", size = 175384, upload-time = "2026-05-19T07:45:56.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/89/655408a5485c56bf2c4561eb85f5bca119b1f4020370b4daaeb8d13e46fb/uuid_utils-0.16.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4e35e9a986e86806a61288fac3afbb51317f2580929feefd1661891ffd7b8c24", size = 569295, upload-time = "2026-05-19T07:45:22.325Z" }, + { url = "https://files.pythonhosted.org/packages/24/1c/a7c5506a4e2cf95ac98fec0996c56daa14e41f2ab1858f569b3556a202f9/uuid_utils-0.16.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b35706350cf9bd4813f1811bebe03cac09795a5a379f90cb3616171f4e9ffc9e", size = 292316, upload-time = "2026-05-19T07:43:57.044Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/4267ab8baa1e6a8ad7c262e204484b44df0fde0920025ea9b43c2b869726/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4fd5c7936a876ba2606ba124603b559a5c2cea458c59b9c31677e6acc3c53cc", size = 329619, upload-time = "2026-05-19T07:44:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/15/77/c794102831e331564f651099cac55006694677938d70f1033b35da451a89/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:130f7452c1b87b7c16d0bdc1f32a1de531ae4cc4220ed4e691402bbcfc39e0a9", size = 335121, upload-time = "2026-05-19T07:45:47.974Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3e/458a0a2da75c596b151182a6c7550c6c3d30f479e14e40f69c0336579e59/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5ee0bbbd4ca3968422cd8308f0072520bc73dc760cb26c6fa75ca1aca14d210", size = 449631, upload-time = "2026-05-19T07:45:50.645Z" }, + { url = "https://files.pythonhosted.org/packages/ed/15/dd1fab6f7fcd15f2c331d0c1f0f516bb1113a640216460f82be53db3dcf8/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc0824a31898ef46a9d84d748c3abe27cdb615ac3773c53cc1f84fc8e66dc7c4", size = 328418, upload-time = "2026-05-19T07:44:52.38Z" }, + { url = "https://files.pythonhosted.org/packages/96/56/62dcd551b140cbeb0f87522da2015b4b9e5818327b920506ad88d28562b0/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abfbf5e0c47fb31b37164a99515104e449a0bee36a071dc8b105457a2b35a5e6", size = 356177, upload-time = "2026-05-19T07:45:42.856Z" }, + { url = "https://files.pythonhosted.org/packages/44/e7/3937b9a9d6745b94dbe7b86531e098db8c53b77c8d07df7daa9577a47b8e/uuid_utils-0.16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:680799a9ade01d69c53cb9d41392ced24919d4f600bfab5060b61fca37510097", size = 178508, upload-time = "2026-05-19T07:43:43.774Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[[package]] +name = "wayflowcore" +version = "26.2.0.dev3" +source = { git = "https://github.com/oracle/wayflow.git?subdirectory=wayflowcore&rev=wayflow-26.1.2#d55dae2906449dd126aeecaa8cb7a138b9816e97" } +dependencies = [ + { name = "annotated-types" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "deprecated" }, + { name = "exceptiongroup" }, + { name = "fastapi" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "idna" }, + { name = "jinja2" }, + { name = "jq" }, + { name = "json-repair" }, + { name = "litellm" }, + { name = "mcp" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "pandas" }, + { name = "pyagentspec" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "pyjwt" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/8b/84bc1ea68b620fe0e2696a8cff07e82f4b962d952ab14efee8955997bb70/wrapt-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0f68f478004475d97906686e702ddbddeaf717c0b68ad2794384308f2dc713ae", size = 80093, upload-time = "2026-05-22T14:47:27.074Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/64ec81194a0bc708d9720174c998c8a32116e82b5b32c04e20a7fe01176c/wrapt-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e422b2d647a65d6b080cad5accd09055d3809bdff00c76fba8dca00ca935572a", size = 81183, upload-time = "2026-05-22T14:47:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/3d186944aae923631d1def58f4c4ff8f0b6309906afc0b6978de3e69b3e0/wrapt-2.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:036dfb40128819a751c6f451c6b9c10172c49e4c401aebcdb8ecf2aec1683598", size = 152494, upload-time = "2026-05-22T14:47:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/01/d1/6b3d0ea995b867d2862aad5619bd5e17de09a9d64a821f46832dcd272d40/wrapt-2.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ac16c081bebfd15d8e4dfa5bdc805990bbd52249ecff22530da7a129d6120b", size = 154310, upload-time = "2026-05-22T14:47:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4b/37ecb90a8c3753e580327fb40731a984b754e3df65d2ef932bf359fe4adc/wrapt-2.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07be671fa8875971222b0ba9059ed8b4dc738631122feba17c93aa36b4213e9a", size = 149002, upload-time = "2026-05-22T14:47:34.021Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/918884d9dfa84d0d135b42a51c00910f5c5447fe7a5e211a8e16ac324dd4/wrapt-2.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93fc2bf40cd7f4a0256010dce073d44eeb4a351b9bca94d0477ce2b6e62532b3", size = 153185, upload-time = "2026-05-22T14:47:35.722Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/382299d8ced610b29b59b099a89eda821e8c489aa152b7183748ac83f32a/wrapt-2.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba519b2d765df9871a25879e6f7fa78948ea59a2a31f9c1a257e34b651994afc", size = 148040, upload-time = "2026-05-22T14:47:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/62a79b79e35bbebb1207ca5d15b81192f37f20cc5659cf4e3ce955b7fcc8/wrapt-2.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9011395be8db1827d106c6449b4bb6dd17e331ff6ec521f227e4588f1c78e46f", size = 151773, upload-time = "2026-05-22T14:47:38.713Z" }, + { url = "https://files.pythonhosted.org/packages/a1/db/95c152151d206d4b430516c89725306e92484072f38e65492afde63f6d19/wrapt-2.2.1-cp310-cp310-win32.whl", hash = "sha256:a8f7176b83664af44567e9cc06e0d3827823fcc1a5e52307ebb8ac3aa95860b9", size = 77393, upload-time = "2026-05-22T14:47:40.061Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/882d50452c6fbd13f24fe5d2644b97cdad2565a7e1522cbb6312de8a52cf/wrapt-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:d7f513d3185e6fec82d0c3518f2e6365d8b4e49f5f45f29640d5162d56a23b54", size = 80350, upload-time = "2026-05-22T14:47:41.194Z" }, + { url = "https://files.pythonhosted.org/packages/58/0f/148376523b4e370692286a9ba14d5715cf3c5b86da3bd3630926367b6b73/wrapt-2.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:44255c84bc57554fed822e83e70036b51afa9edb56fc7ca56c54410ece7898c9", size = 79149, upload-time = "2026-05-22T14:47:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + +[[package]] +name = "xxhash" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/49/e4b575b4ed170a7f640c8bd69cfadfa81c7b700191fde5e72228762b9f73/xxhash-3.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cd8ab85c916a58d5c8656ea15e3ce9df836fe2f120a74c296e01d69fab2614b4", size = 33426, upload-time = "2026-04-25T11:05:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/07/61/40f0155b0b09988eb6cdbfc52652f2f371810b0c58163208cb05667757bd/xxhash-3.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:85f5c0e26d945b5bb475e0a3d95193117498130baa7619357bdc7869c2391b5a", size = 30859, upload-time = "2026-04-25T11:05:17.708Z" }, + { url = "https://files.pythonhosted.org/packages/12/bd/2902b7aad574e43cd85fd84849cfbce48c52cb02c7d6902b8a2b3f6e668e/xxhash-3.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7ffeaada9f8699be63d639536b0b60dff73b7d3325b7475c5bc8fdbf4eed47f", size = 193839, upload-time = "2026-04-25T11:05:19.364Z" }, + { url = "https://files.pythonhosted.org/packages/48/df/343ce8fd09e47ba8fba43b3bad3283ddf0deca799d5a27b084c3aa2ce502/xxhash-3.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee88dfaa6b1b2bfadd3c031fa5f05584870e62fb05dc500942e9900c44fcfda", size = 212896, upload-time = "2026-04-25T11:05:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/79/cf/703e8422a8b52407864281fb4eb52c605e9f33180413b4458f05de110eba/xxhash-3.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7426ff0dfa76eb47efc2cc59d4a717bfa9dc9938bff5e49e748bca749f6aa616", size = 235896, upload-time = "2026-04-25T11:05:22.988Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bc/d4b039edbd426575add5f217abeeb2bf870e2c510d35445df81b4f457901/xxhash-3.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8ff6ec73110f610425caef3ea875afbfc34caa542f01df3a80f45aadeb9f906", size = 211665, upload-time = "2026-04-25T11:05:24.799Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/c6f81361796814b92399a88bf079d3b65e617f531819128fcf1bd6ef0571/xxhash-3.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d23fd49fdc5c8af61fb7104f1ad247954499140f6cb6045b3aa5c99dadbbf28", size = 444929, upload-time = "2026-04-25T11:05:26.245Z" }, + { url = "https://files.pythonhosted.org/packages/a4/db/268012153eb7f6bf2c8a0491fdcde11e093f166990821a2ab754fe95537d/xxhash-3.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c249621af6d50a05d9f10af894b404157b15819878e18f75fcbb0213a77d07", size = 193271, upload-time = "2026-04-25T11:05:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/0a/86/1d0d905d659850dad7f59c807c130249fdb204dc6f71f1fb36268f3f3e61/xxhash-3.7.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6741564a923f082f3c2941c8bb920462ed5b25eaebdd1e161f162233c9a10bc5", size = 284580, upload-time = "2026-04-25T11:05:30.116Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/fc01ca7ff425a9bdb38d9e3a17f2630447ce3b45d45a929a6cd94d469334/xxhash-3.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4fd8acc6e32596350619896feb372033c0920975992d29837c32853bb1feacd", size = 210193, upload-time = "2026-04-25T11:05:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/122e0c6a3537a54b30752031dca557182576bae1a4171c0be8c532c84496/xxhash-3.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:646a69b56d8145d85f7fd2289d14fba07880c8a5bda406aa256b407481a61f35", size = 241094, upload-time = "2026-04-25T11:05:33.651Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/92e33338db8c18add33a46b56c2b7d5dcc6cc2ac076c45389f6017b1bf37/xxhash-3.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:11dd69b1a34b7b9af29012f390825b0cdb0617c0966560e227ca74daa7478ba9", size = 197721, upload-time = "2026-04-25T11:05:35.387Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/fd4114a0820913f336bef5c82ef851bde8d06270982ebd7b2a859961bbf2/xxhash-3.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:01cf5c5333aed26cc8d5eea33b8d6398e085e365a704b7372fabdf7ab06441a9", size = 210073, upload-time = "2026-04-25T11:05:37.405Z" }, + { url = "https://files.pythonhosted.org/packages/dd/eb/a2472b8b81cd576a9af3a4889ad8ba5784e8c5a04592587056cdaededd6c/xxhash-3.7.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f1e65d52c2d526734abecb98372c256b7eacce8fdc42e0df8570417fb39e2772", size = 274960, upload-time = "2026-04-25T11:05:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d3/493afc544aae50b5fb2844ceaeb3697283bb59695db1a7cb40448636de05/xxhash-3.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8ff00fcc3eb436617ed8556cf15daf76c2b501248361a065625a588af78a0a02", size = 413113, upload-time = "2026-04-25T11:05:40.669Z" }, + { url = "https://files.pythonhosted.org/packages/50/6a/002800845a22bff32bcf5fd09caceb4d3f5c3da6b754c46edb9743ce908b/xxhash-3.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b5cd29840505631c6f7dbb8a5d34b742b5e6bbda38fe0b9f54e825f3ea6b61dc", size = 190677, upload-time = "2026-04-25T11:05:42.403Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0f/86ee514622a381c0dc49167c8d431a22aa93518a4063559c3e36e4b82bc8/xxhash-3.7.0-cp310-cp310-win32.whl", hash = "sha256:5bf2f1940499839b39fef1561b5ecb6ede9ac34ef4457474e1337fc7ef07c2f3", size = 30627, upload-time = "2026-04-25T11:05:44.022Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/2ef2310803efb4a2d07844e8098d797e25702024793aa2e85858623a43b5/xxhash-3.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:d41fcda2fa8ca682ebca134a2f2dc02575ba549267585597e73061565795f475", size = 31463, upload-time = "2026-04-25T11:05:45.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/75/40dbf8f142baf8993c38cd988c8d8f51fe0c51e6c84c5769a3c0280a651d/xxhash-3.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:a845a59664d5c531525a467470220f8edc37959e0a6f8e734ffb6654da5c4bee", size = 27747, upload-time = "2026-04-25T11:05:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f4/7bd35089ff1f8e2c96baa2dce05775a122aacd2e3830a73165e27a4d0848/xxhash-3.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fdc7d06929ae28dda98297a18eef7b0fd38991a3b405d8d7b55c9ef24c296958", size = 33423, upload-time = "2026-04-25T11:05:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/26/4e00c88a6a2c8a759cfb77d2a9a405f901e8aa66e60ef1fd0aeb35edda48/xxhash-3.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea6daa712f4e094a30830cf01e9b47d03b24d05cc9dab8609f0d9a9db8454712", size = 30857, upload-time = "2026-04-25T11:05:49.189Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/eeb942c17a5a761a8f01cb9180a0b76bfb62a2c39e6f46b1f9001899027a/xxhash-3.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9e6c0d843f1daf85ea23aeb053579135552bde575b7b98af20bfc667b6e4548d", size = 194702, upload-time = "2026-04-25T11:05:50.457Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/96f132c08b1e5951c68691d3b9ec351ec2edc028f6a01fcd294f46b9d9f0/xxhash-3.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:363c139bf15e1ac5f136b981d3c077eb551299b1effede7f12faa010b8590a60", size = 213613, upload-time = "2026-04-25T11:05:52.571Z" }, + { url = "https://files.pythonhosted.org/packages/82/89/d4e92b796c5ed052d29ed324dbfc1dc1188e0c4bf64bebbf0f8fc20698df/xxhash-3.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a778b25874cb0f862eaab5986bff4ca49ffb0def7c0a34c237b948b3c6c775b2", size = 236726, upload-time = "2026-04-25T11:05:54.395Z" }, + { url = "https://files.pythonhosted.org/packages/40/f1/81fc4361921dc6e557a9c60cb3712f36d244d06eeeb71cd2f4252ac42678/xxhash-3.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e1860f1e43d40e9d904cf22d93e587ea42e010ebce4160877e46bcab4bc232a", size = 212443, upload-time = "2026-04-25T11:05:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d0/afeddd4cff50a332f50d4b8a2e8857673153ab0564ef472fcdeb0b5430df/xxhash-3.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9122ad6f867c4a0f5e655f5c3bdf89103852009dbb442a3d23e688b9e699e800", size = 445793, upload-time = "2026-04-25T11:05:58.953Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/3c91e4e6a05ca4d7df8e39ec3a75b713609258ec84705ab34be6430826a1/xxhash-3.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d9110d0c3fb02679972837a033251fd186c529aa62f19c132fc909c74052b8", size = 193937, upload-time = "2026-04-25T11:06:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3a/a6b0772d9801dd4bea4ca4fd34734d6e9b51a711c8a611a24a79de26a878/xxhash-3.7.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:347a93f2b4ce67ce61959665e32a7447c380f8347e55e100daa23766baacf0e5", size = 285188, upload-time = "2026-04-25T11:06:01.96Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f8/cf8e31fd7282230fe7367cd501a2e75b4b67b222bfc7eacccfc20d2652cb/xxhash-3.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:acbb48679ddf3852c45280c10ff10d52ca2cd1da2e552fb81db1ff786c75d0e4", size = 210966, upload-time = "2026-04-25T11:06:03.453Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/fd36cc4a81bf52ee5633275daae2b93dd958aace67fd4f5d466ec83b5f35/xxhash-3.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fe14c356f8b23ad811dc026077a6d4abccdaa7bce5ca98579605550657b6fcfb", size = 241994, upload-time = "2026-04-25T11:06:05.264Z" }, + { url = "https://files.pythonhosted.org/packages/08/e1/67f5d9c9369be42eaf99ba02c01bf14c5ecd67087b02567960bfcee43b63/xxhash-3.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f420ad3d41e38194353a498bbc9561fd5a9973a27b536ce46d8583479cf44335", size = 198707, upload-time = "2026-04-25T11:06:07.044Z" }, + { url = "https://files.pythonhosted.org/packages/50/17/a4c865ca22d2da6b1bc7d739bf88cab209533cf52ba06ca9da27c3039bee/xxhash-3.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:693d02c6dc7d1aa0a45921d54cd8c1ff629e09dfdc2238471507af1f7a1c6f04", size = 210917, upload-time = "2026-04-25T11:06:08.853Z" }, + { url = "https://files.pythonhosted.org/packages/49/8b/453b35810d697abac3c96bde3528bece685869227da274eb80a4a4d4a119/xxhash-3.7.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:14bf7a54e43825ec131ee7fe3c60e142e7c2c1e676ad0f93fc893432d15414af", size = 275772, upload-time = "2026-04-25T11:06:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ad/4eed7eab07fd3ee6678f416190f0413d097ab5d7c1278906bf1e9549d789/xxhash-3.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ae3a39a4d96bdb6f8d154fd7f490c4ad06f0532fcd2bb656052a9a7762cf5d31", size = 414068, upload-time = "2026-04-25T11:06:12.511Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4e/fd6f8a680ba248fdb83054fa71a8bfa3891225200de1708b888ef2c49829/xxhash-3.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1cc07c639e3a77ef1d32987464d3e408565b8a3be57b545d3542b191054d9923", size = 191459, upload-time = "2026-04-25T11:06:14.07Z" }, + { url = "https://files.pythonhosted.org/packages/50/7c/8cb34b3bed4f44ca6827a534d50833f9bc6c006e83b0eb410ac9fa0793bd/xxhash-3.7.0-cp311-cp311-win32.whl", hash = "sha256:3281ba1d1e60ee7a382a7b958513ba03c2c0d5fcbd9a6f7517c0a81251a23422", size = 30628, upload-time = "2026-04-25T11:06:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/0b/47/a49767bd7b40782bedae9ff0721bfe1d7e4dd9dc1585dea684e57ba67c20/xxhash-3.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:a7f25baec4c5d851d40718d6fae52285b31683093d4ff5207e63ab306ccf14a5", size = 31461, upload-time = "2026-04-25T11:06:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c6/3957bfacfb706bd687be246dfa8dd60f8df97c44186d229f7fd6e26c4b7e/xxhash-3.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:4c2454448ce847c72635827bb75c15c5a3434b03ee1afd28cb6dc6fb2597d830", size = 27746, upload-time = "2026-04-25T11:06:18.716Z" }, + { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" }, + { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" }, + { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" }, + { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" }, + { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" }, + { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" }, + { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" }, + { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" }, + { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" }, + { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" }, + { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" }, + { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" }, + { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" }, + { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" }, + { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" }, + { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" }, + { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" }, + { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" }, + { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" }, + { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" }, + { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" }, + { url = "https://files.pythonhosted.org/packages/54/c1/e57ac7317b1f58a92bab692da6d497e2a7ce44735b224e296347a7ecc754/xxhash-3.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ad3aa71e12ee634f22b39a0ff439357583706e50765f17f05550f92dbf128a23", size = 31232, upload-time = "2026-04-25T11:10:21.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4e/075559bd712bc62e84915ea46bbee859f935d285659082c129bdbff679dd/xxhash-3.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5de686e73690cdaf72b96d4fa083c230ec9020bcc2627ce6316138e2cf2fe2d1", size = 28553, upload-time = "2026-04-25T11:10:23.1Z" }, + { url = "https://files.pythonhosted.org/packages/92/ca/a9c78cb384d4b033b0c58196bd5c8509873cabe76389e195127b0302a741/xxhash-3.7.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7fbec49f5341bbdea0c471f7d1e2fb41ae8925af9b6f28025c28defd8eb94274", size = 41109, upload-time = "2026-04-25T11:10:25.022Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b1/dfe2629f7c77eb2fa234c72ff537cdd64939763df704e256446ed364a16d/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b542c347c2089f43dc5a6db31d2a6f3cdb04ee33505ec6e9f653834dbb0bde", size = 36307, upload-time = "2026-04-25T11:10:26.949Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f7/5a484afce0f48dd8083208b42e4911f290a82c7b52458ef2927e4d421a45/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a169a036bed0995e090d1493b283cc2cc8a6f5046821086b843abefff80643bc", size = 32534, upload-time = "2026-04-25T11:10:29.01Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5f/4acfcd490db9780cf36c58534d828003c564cde5350220a1c783c4d10776/xxhash-3.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ec101643395d7f21405b640f728f6f627e6986557027d740f2f9b220955edafe", size = 31552, upload-time = "2026-04-25T11:10:30.727Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/df/f1c7a3de0831cd83194f1a85c5bb431b13f81e6b45079314c86d1c4ef3f2/yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12", size = 129057, upload-time = "2026-05-19T21:27:47.564Z" }, + { url = "https://files.pythonhosted.org/packages/48/41/7daafb32dd7562bf45b1ce56562e7e1a9146f6479b6456873eb8a3413c40/yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0", size = 91545, upload-time = "2026-05-19T21:27:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8f/7b3ec212f1ea0683f55f978e3246bc313c38818664edfc97a9f349a4901e/yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75", size = 91380, upload-time = "2026-05-19T21:27:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1b/8bafab7db23b0567ae9db749099b329d91e3b82bc6028b2050ba583e116c/yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727", size = 105957, upload-time = "2026-05-19T21:27:53.98Z" }, + { url = "https://files.pythonhosted.org/packages/7f/77/21030c2f8d21d21559719beafc772ada2014be933418ed1eaed9cc800e42/yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413", size = 97242, upload-time = "2026-05-19T21:27:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/f9ea63d1b6aa910a866e089d871fff6cbd49caab29b86b35221a62dfa0d5/yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9", size = 114719, upload-time = "2026-05-19T21:27:58.037Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/04e0ee98ac58a249ea7ed75223f5f901ba81a834f0b4921b58e5cec11757/yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2", size = 112140, upload-time = "2026-05-19T21:27:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/ad/0b9cc9f38a7324a7eb1d80f834eaa5283d17e9271bbda3186e598dddaeac/yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90", size = 106721, upload-time = "2026-05-19T21:28:02.586Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/a52478ebfc66ec989e085c6ae038b9f1bfa4190baa193b133b669c709e2f/yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643", size = 106478, upload-time = "2026-05-19T21:28:04.523Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/5508530fea8472542de00013ae280765fc938ee196fc4030c43a498afb36/yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac", size = 105423, upload-time = "2026-05-19T21:28:06.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/f1/ece28505e9628e8b756e11bb4f28864a17cc33b6b44db4d2aaf0622bf630/yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f", size = 99878, upload-time = "2026-05-19T21:28:08.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/52/fb5d34529b46dd84013afcfb30b8d2bc2832ed03d412736f577d604fa393/yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36", size = 114025, upload-time = "2026-05-19T21:28:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/ff9d31aaab024f7a251c0ed308a98ae29bf9f7dc344e78f28b1322431ca2/yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a", size = 105613, upload-time = "2026-05-19T21:28:12.784Z" }, + { url = "https://files.pythonhosted.org/packages/31/7d/3296fb3f3ecd52bf9ae6c16b0895c1cda7e9170a2083861552b683f70264/yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53", size = 111665, upload-time = "2026-05-19T21:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/77aa6ddaca4fbf42e45e675a465c43956dd40702281049975a2aa04eae59/yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342", size = 106914, upload-time = "2026-05-19T21:28:15.893Z" }, + { url = "https://files.pythonhosted.org/packages/d8/02/7611f22cd1d4ed7373eb7f9ee21fde1046edba2e7c0e514880d760352f48/yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4", size = 92658, upload-time = "2026-05-19T21:28:17.471Z" }, + { url = "https://files.pythonhosted.org/packages/91/00/671d0add79938127292839ae44506ce2f7fe8909c72d5a931864f128fd0b/yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39", size = 87887, upload-time = "2026-05-19T21:28:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, +] diff --git a/integrations/agno/python/examples/server/api/backend_tool_rendering.py b/integrations/agno/python/examples/server/api/backend_tool_rendering.py index b1d96058fc..30d6035b67 100644 --- a/integrations/agno/python/examples/server/api/backend_tool_rendering.py +++ b/integrations/agno/python/examples/server/api/backend_tool_rendering.py @@ -4,6 +4,7 @@ """ import json +import os import httpx from agno.agent.agent import Agent @@ -56,6 +57,25 @@ def get_weather_condition(code: int) -> str: return conditions.get(code, "Unknown") +def _mock_weather(location: str) -> str: + """Return deterministic canned weather data for tests. + + Used when ``AG_UI_MOCK_WEATHER`` is set so e2e runs don't depend on the + live open-meteo API (which rate-limits CI's shared egress IPs). + """ + return json.dumps( + { + "temperature": 21.0, + "feels_like": 20.0, + "humidity": 65.0, + "wind_speed": 12.0, + "windGust": 18.0, + "conditions": get_weather_condition(1), + "location": location, + } + ) + + @tool(external_execution=False) async def get_weather(location: str) -> str: """Get current weather for a location. @@ -67,6 +87,9 @@ async def get_weather(location: str) -> str: A json string with weather information including temperature, feels like, humidity, wind speed, wind gust, conditions, and location name. """ + if os.getenv("AG_UI_MOCK_WEATHER"): + return _mock_weather(location) + async with httpx.AsyncClient() as client: # Geocode the location geocoding_url = ( diff --git a/integrations/agno/typescript/LICENSE b/integrations/agno/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/agno/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/agno/typescript/package.json b/integrations/agno/typescript/package.json index ea36961c31..931ed84a7c 100644 --- a/integrations/agno/typescript/package.json +++ b/integrations/agno/typescript/package.json @@ -2,6 +2,7 @@ "name": "@ag-ui/agno", "author": "Manu Hortet ", "version": "0.0.5", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/integrations/aws-strands/python/LICENSE b/integrations/aws-strands/python/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/aws-strands/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/aws-strands/python/examples/server/__init__.py b/integrations/aws-strands/python/examples/server/__init__.py index bbbea7a673..1f7d68aeed 100644 --- a/integrations/aws-strands/python/examples/server/__init__.py +++ b/integrations/aws-strands/python/examples/server/__init__.py @@ -25,6 +25,8 @@ # Import agent apps from .api import ( + a2ui_dynamic_schema_app, + a2ui_recovery_app, agentic_chat_app, agentic_chat_reasoning_app, agentic_chat_multimodal_app, @@ -47,6 +49,8 @@ ) # Mount agents +app.mount('/a2ui-dynamic-schema', a2ui_dynamic_schema_app, 'A2UI Dynamic Schema') +app.mount('/a2ui-recovery', a2ui_recovery_app, 'A2UI Recovery') app.mount('/agentic-chat', agentic_chat_app, 'Agentic Chat') app.mount('/agentic-chat-reasoning', agentic_chat_reasoning_app, 'Agentic Chat Reasoning') app.mount('/agentic-chat-multimodal', agentic_chat_multimodal_app, 'Agentic Chat Multimodal') @@ -60,10 +64,15 @@ def root(): return { "message": "AWS Strands Integration 2 - AG-UI Dojo", "endpoints": { + "a2ui_dynamic_schema": "/a2ui-dynamic-schema", + "a2ui_recovery": "/a2ui-recovery", "agentic_chat": "/agentic-chat", + "agentic_chat_reasoning": "/agentic-chat-reasoning", + "agentic_chat_multimodal": "/agentic-chat-multimodal", "backend_tool_rendering": "/backend-tool-rendering", "agentic_generative_ui": "/agentic-generative-ui", - "shared_state": "/shared-state" + "shared_state": "/shared-state", + "human_in_the_loop": "/human-in-the-loop" } } diff --git a/integrations/aws-strands/python/examples/server/api/__init__.py b/integrations/aws-strands/python/examples/server/api/__init__.py index 8e134f5741..94d171fba7 100644 --- a/integrations/aws-strands/python/examples/server/api/__init__.py +++ b/integrations/aws-strands/python/examples/server/api/__init__.py @@ -1,5 +1,7 @@ """API modules for AWS Strands integration examples.""" +from .a2ui_dynamic_schema import app as a2ui_dynamic_schema_app +from .a2ui_recovery import app as a2ui_recovery_app from .agentic_chat import app as agentic_chat_app from .agentic_chat_reasoning import app as agentic_chat_reasoning_app from .agentic_chat_multimodal import app as agentic_chat_multimodal_app @@ -9,6 +11,8 @@ from .shared_state import app as shared_state_app __all__ = [ + "a2ui_dynamic_schema_app", + "a2ui_recovery_app", "agentic_chat_app", "agentic_chat_reasoning_app", "agentic_chat_multimodal_app", diff --git a/integrations/aws-strands/python/examples/server/api/a2ui_dynamic_schema.py b/integrations/aws-strands/python/examples/server/api/a2ui_dynamic_schema.py new file mode 100644 index 0000000000..1710a1da67 --- /dev/null +++ b/integrations/aws-strands/python/examples/server/api/a2ui_dynamic_schema.py @@ -0,0 +1,87 @@ +"""Dynamic A2UI example for AWS Strands. + +A plain agent with no a2ui wiring. When the runtime enables A2UI tool +injection, the adapter auto-injects ``generate_a2ui`` and renders surfaces +generated from the conversation. +""" +import os +from pathlib import Path +from dotenv import load_dotenv + +# Suppress OpenTelemetry context warnings from Strands SDK +os.environ["OTEL_SDK_DISABLED"] = "true" +os.environ["OTEL_PYTHON_DISABLED_INSTRUMENTATIONS"] = "all" + +from strands import Agent +from ag_ui_strands import StrandsAgent, StrandsAgentConfig, create_strands_app +from server.model_factory import create_model + +# Load environment variables from .env file +env_path = Path(__file__).parent.parent.parent / '.env' +load_dotenv(dotenv_path=env_path) + +# The dojo registers its dynamic component catalog (HotelCard, ProductCard, +# TeamMemberCard) under this id; auto-injected surfaces must reference it so +# the renderer can resolve their components. +DOJO_CATALOG_ID = "https://a2ui.org/demos/dojo/dynamic_catalog.json" + +# Teaches the sub-agent how to compose the dojo catalog's components. Mirrors +# the LangGraph dynamic-schema demo's COMPOSITION_GUIDE so a real model (not +# just the e2e mock) can produce valid surfaces. +COMPOSITION_GUIDE = """ +## Available Pre-made Components + +You have 3 card components. Use Row as the root with structural children to +repeat a card per item. + +### Row +Layout container. Repeat a card template via structural children: + {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}} + +### HotelCard +Props: name, location, rating (number 0-5), pricePerNight, action + +### ProductCard +Props: name, price, rating (number 0-5), description (optional), action + +### TeamMemberCard +Props: name, role, department (optional), email (optional), action + +## RULES +- Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"} +- ALWAYS include the referenced card component in the components array. +- Inside templates use RELATIVE paths (no leading slash): {"path":"name"}. +- Always provide data in the "data" argument as {"items":[...]}. +- Pick the card type that best matches the request; generate 3-4 realistic items. +""" + +SYSTEM_PROMPT = """You are a helpful assistant that creates rich visual UI on the fly. + +When the user asks for visual content (product comparisons, dashboards, team +rosters, lists, cards, etc.), use the generate_a2ui tool to create a dynamic +A2UI surface. +IMPORTANT: After calling the tool, do NOT repeat the data in your text response. +The tool renders UI automatically. Just confirm what was rendered.""" + +strands_agent = Agent( + # Chat Completions API (OpenAI provider only; other providers ignore the + # kwarg): the Responses model buffers tool-call argument deltas, which + # would defeat A2UI's progressive surface streaming. + model=create_model(openai_api="chat"), + system_prompt=SYSTEM_PROMPT, + # generate_a2ui is auto-injected by the adapter; nothing wired here. +) + +agui_agent = StrandsAgent( + agent=strands_agent, + name="a2ui_dynamic_schema", + description="Dynamic A2UI surfaces generated on the fly (auto-injected tool)", + config=StrandsAgentConfig( + a2ui={ + "default_catalog_id": DOJO_CATALOG_ID, + "guidelines": {"composition_guide": COMPOSITION_GUIDE}, + } + ), +) + +app = create_strands_app(agui_agent, "/") diff --git a/integrations/aws-strands/python/examples/server/api/a2ui_recovery.py b/integrations/aws-strands/python/examples/server/api/a2ui_recovery.py new file mode 100644 index 0000000000..6911dd0e8f --- /dev/null +++ b/integrations/aws-strands/python/examples/server/api/a2ui_recovery.py @@ -0,0 +1,77 @@ +"""A2UI Error Recovery example for AWS Strands. + +A plain agent with no a2ui wiring. The adapter auto-injects ``generate_a2ui``, +which validates each generated surface and retries on failure (up to 3 +total attempts) before falling back to a tasteful hard-failure. +""" +import os +from pathlib import Path +from dotenv import load_dotenv + +# Suppress OpenTelemetry context warnings from Strands SDK +os.environ["OTEL_SDK_DISABLED"] = "true" +os.environ["OTEL_PYTHON_DISABLED_INSTRUMENTATIONS"] = "all" + +from strands import Agent +from ag_ui_strands import StrandsAgent, StrandsAgentConfig, create_strands_app +from server.model_factory import create_model + +# Load environment variables from .env file +env_path = Path(__file__).parent.parent.parent / '.env' +load_dotenv(dotenv_path=env_path) + +# The dojo registers its dynamic component catalog under this id; auto-injected +# surfaces must reference it so the renderer can resolve their components. +DOJO_CATALOG_ID = "https://a2ui.org/demos/dojo/dynamic_catalog.json" + +# Teaches the sub-agent how to compose the dojo catalog's components. Mirrors +# the LangGraph recovery demo's COMPOSITION_GUIDE. +COMPOSITION_GUIDE = """ +## Available Pre-made Components + +Use Row as the root with structural children to repeat a card per item. + +### Row +Repeat a card template via structural children: + {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}} + +### HotelCard / ProductCard / TeamMemberCard +Card components bound to per-item data (relative paths inside the template). + +## RULES +- Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"} +- ALWAYS include the referenced card component in the components array. +- Inside templates use RELATIVE paths (no leading slash): {"path":"name"}. +- Always provide data in the "data" argument as {"items":[...]}. +- Generate 3-4 realistic items with diverse data. +""" + +SYSTEM_PROMPT = """You are a helpful assistant that creates rich visual UI on the fly. + +When the user asks for visual content (hotel/product comparisons, team rosters, +lists, cards, etc.), use the generate_a2ui tool to create a dynamic A2UI surface. +IMPORTANT: After calling the tool, do NOT repeat the data in your text response. +The tool renders UI automatically. Just confirm what was rendered.""" + +strands_agent = Agent( + # Chat Completions API (OpenAI provider only; other providers ignore the + # kwarg): the Responses model buffers tool-call argument deltas, which + # would defeat A2UI's progressive surface streaming. + model=create_model(openai_api="chat"), + system_prompt=SYSTEM_PROMPT, + # generate_a2ui is auto-injected by the adapter; nothing wired here. +) + +agui_agent = StrandsAgent( + agent=strands_agent, + name="a2ui_recovery", + description="Dynamic A2UI with automatic error recovery (auto-injected tool)", + config=StrandsAgentConfig( + a2ui={ + "default_catalog_id": DOJO_CATALOG_ID, + "guidelines": {"composition_guide": COMPOSITION_GUIDE}, + } + ), +) + +app = create_strands_app(agui_agent, "/") diff --git a/integrations/aws-strands/python/examples/server/model_factory.py b/integrations/aws-strands/python/examples/server/model_factory.py index b5ad0d42ca..e3d63cffce 100644 --- a/integrations/aws-strands/python/examples/server/model_factory.py +++ b/integrations/aws-strands/python/examples/server/model_factory.py @@ -9,13 +9,26 @@ logger = logging.getLogger(__name__) -def create_model(): +def create_model(openai_api: str = "responses"): """Create a Strands model based on MODEL_PROVIDER env var. Supported providers: openai (default), anthropic, gemini + + ``openai_api`` selects the OpenAI API mode. The default Responses API + surfaces reasoning summaries but buffers tool-call argument deltas until + the call completes; pass ``"chat"`` for demos that need tool-call ARGUMENTS + to stream incrementally (e.g. A2UI progressive surface painting). """ provider = os.getenv("MODEL_PROVIDER", "openai").lower() + if openai_api not in ("chat", "responses"): + # A typo here would silently select the Responses API, whose buffered + # tool-call deltas defeat progressive A2UI painting — the exact + # regression the streaming e2e guards. Fail loud instead. + raise ValueError( + f"Unknown openai_api: {openai_api!r}. Supported: chat, responses" + ) + if provider == "openai": api_key = os.getenv("OPENAI_API_KEY") if not api_key: @@ -23,6 +36,14 @@ def create_model(): "OPENAI_API_KEY environment variable is required when MODEL_PROVIDER=openai. " "Set it in your .env file or environment." ) + if openai_api == "chat": + from strands.models.openai import OpenAIModel + return OpenAIModel( + client_args={ + "api_key": api_key, + }, + model_id=os.getenv("MODEL_ID", "gpt-5.4"), + ) from strands.models.openai_responses import OpenAIResponsesModel return OpenAIResponsesModel( client_args={ @@ -44,11 +65,18 @@ def create_model(): return AnthropicModel( client_args={ "api_key": api_key, + # Without this beta, Anthropic buffers tool-input JSON into a + # few coarse validated chunks (seconds apart), which defeats + # progressive A2UI painting. Fine-grained tool streaming emits + # token-level input_json_delta events. + "default_headers": { + "anthropic-beta": "fine-grained-tool-streaming-2025-05-14" + }, }, model_id=os.getenv("MODEL_ID", "claude-sonnet-4-6"), - params={ - "budget_tokens": 5000, - } + # Top-level required config for strands' AnthropicModel (its + # format_request reads self.config["max_tokens"] unconditionally). + max_tokens=8192, ) elif provider == "gemini": api_key = os.getenv("GOOGLE_API_KEY") diff --git a/integrations/aws-strands/python/pyproject.toml b/integrations/aws-strands/python/pyproject.toml index db84af55a0..aa659e175a 100644 --- a/integrations/aws-strands/python/pyproject.toml +++ b/integrations/aws-strands/python/pyproject.toml @@ -1,11 +1,14 @@ [project] name = "ag_ui_strands" -version = "0.1.9" +license = "MIT" +version = "0.2.1" +license-files = ["LICENSE"] authors = [ { name = "AG-UI Contributors" } ] requires-python = ">=3.12, <3.14" dependencies = [ + "ag-ui-a2ui-toolkit>=0.0.4", "ag-ui-protocol>=0.1.18", "fastapi>=0.115.12", "strands-agents>=1.15.0", diff --git a/integrations/aws-strands/python/src/ag_ui_strands/__init__.py b/integrations/aws-strands/python/src/ag_ui_strands/__init__.py index 85a04bc0e6..5212688cc2 100644 --- a/integrations/aws-strands/python/src/ag_ui_strands/__init__.py +++ b/integrations/aws-strands/python/src/ag_ui_strands/__init__.py @@ -1,9 +1,21 @@ """ AWS Strands Integration for AG-UI. -Simple adapter following the Agno pattern. +Wraps a Strands ``Agent`` as an AG-UI agent: event-stream translation, +frontend proxy-tool sync, per-thread session management, and the two-tier +A2UI surface generation (``get_a2ui_tools`` / ``plan_a2ui_injection``). """ from .agent import StrandsAgent +from .a2ui_tool import ( + A2UI_OPERATIONS_KEY, + A2UI_STREAM_KEY, + A2UIGuidelines, + A2UIToolParams, + BASIC_CATALOG_ID, + get_a2ui_tools, + is_auto_injected_a2ui_tool, + plan_a2ui_injection, +) from .client_proxy_tool import create_proxy_tool, sync_proxy_tools from .utils import create_strands_app from .endpoint import add_strands_fastapi_endpoint, add_ping @@ -18,6 +30,14 @@ __all__ = [ "StrandsAgent", + "A2UI_STREAM_KEY", + "A2UI_OPERATIONS_KEY", + "A2UIToolParams", + "A2UIGuidelines", + "BASIC_CATALOG_ID", + "get_a2ui_tools", + "is_auto_injected_a2ui_tool", + "plan_a2ui_injection", "create_proxy_tool", "sync_proxy_tools", "create_strands_app", diff --git a/integrations/aws-strands/python/src/ag_ui_strands/a2ui_tool.py b/integrations/aws-strands/python/src/ag_ui_strands/a2ui_tool.py new file mode 100644 index 0000000000..d18d014f06 --- /dev/null +++ b/integrations/aws-strands/python/src/ag_ui_strands/a2ui_tool.py @@ -0,0 +1,816 @@ +"""A2UI subagent tool for AWS Strands agents — Python. + +Thin adapter over ``ag-ui-a2ui-toolkit`` — the recovery loop, validation, op +builders, prompt assembly and output envelope all live in the toolkit. This +module owns only the Strands-specific glue (mirrors the TypeScript adapter's +``a2ui-tool.ts``): + + - ``get_a2ui_tools(params, glue=None)`` — explicit wiring: builds a Strands + tool the dev adds to their agent's ``tools``. The tool runs the toolkit's + validate->retry recovery loop, driving a sub-agent that calls + ``render_a2ui``. + - ``plan_a2ui_injection(...)`` — auto-injection: the pure per-run + decision. Reads the runtime ``injectA2UITool`` flag, infers the model, + resolves the catalog, threads the run's AG-UI messages + state, and returns + the tool to register (+ the injected render tool to drop) — or ``None``. + +Streaming: the sub-agent's ``render_a2ui`` call must STREAM to the AG-UI wire — +the a2ui middleware's "building" skeleton and progressive paint key off the +inner tool-call's arg deltas, not the final result. The toolkit recovery loop +is synchronous, so it runs in a worker thread; sub-agent stream events are +pushed onto an asyncio queue and re-yielded from the tool's ``stream()`` as +``ToolStreamEvent`` payloads under ``A2UI_STREAM_KEY``, which the adapter +translates into synthetic inner TOOL_CALL_START/ARGS/END events. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import threading +import uuid +from typing import Any, Callable, Optional + +from strands import Agent +from strands.tools.tools import PythonAgentTool +from strands.types._events import ToolResultEvent, ToolStreamEvent +from strands.types.tools import AgentTool, ToolSpec, ToolUse + +from ag_ui.core import RunAgentInput +from ag_ui_a2ui_toolkit import ( + A2UI_OPERATIONS_KEY, + A2UIGuidelines, + A2UIToolParams, + BASIC_CATALOG_ID, + GENERATE_A2UI_ARG_DESCRIPTIONS, + GENERATE_A2UI_TOOL_NAME, + RENDER_A2UI_TOOL_DEF, + build_a2ui_envelope, + prepare_a2ui_request, + resolve_a2ui_catalog, + resolve_a2ui_tool_params, + run_a2ui_generation_with_recovery, + wrap_error_envelope, +) + +# Re-export the toolkit constants/types for callers that import them from this +# package — keeps the public surface aligned with the LangGraph adapter so +# consumers can type their params bag without depending on the toolkit directly. +# ``plan_a2ui_injection`` / ``is_auto_injected_a2ui_tool`` / ``A2UI_STREAM_KEY`` +# are Strands-specific additions (the auto-injection machinery LG handles in its +# graph state merge instead). +__all__ = [ + "get_a2ui_tools", + "plan_a2ui_injection", + "is_auto_injected_a2ui_tool", + "A2UI_STREAM_KEY", + "A2UI_OPERATIONS_KEY", + "A2UIToolParams", + "A2UIGuidelines", + "BASIC_CATALOG_ID", +] + +logger = logging.getLogger("ag_ui_strands") + +#: Default name of the render tool the A2UI middleware injects (and we drop). +RENDER_A2UI_TOOL_NAME: str = RENDER_A2UI_TOOL_DEF["function"]["name"] + +#: Marker key on ``ToolStreamEvent`` data payloads carrying the sub-agent's +#: render_a2ui streaming progress out of the ``generate_a2ui`` tool. The +#: adapter translates these into synthetic inner TOOL_CALL_START/ARGS/END +#: events on the AG-UI wire. The marker key must match the TS adapter's +#: ``A2UI_STREAM_KEY``; payload field casing is adapter-local (snake_case +#: here, camelCase in TS — each adapter consumes only its own payloads). +A2UI_STREAM_KEY = "__a2uiRenderStream" + +#: Attribute marking a ``generate_a2ui`` tool this adapter auto-injected +#: so the per-run hook can tell its OWN prior-turn injection (safe to +#: refresh) apart from a dev-wired tool (which always wins, never touched). +_A2UI_AUTOINJECT_ATTR = "_a2ui_auto_injected" + +def _log_abandoned_recovery_result(future: "asyncio.Future") -> None: + """Consume the recovery future's outcome after generator abandonment so a + rethrown sub-agent error isn't silently dropped by asyncio.""" + try: + exc = future.exception() + except asyncio.CancelledError: + return + # The adapter's own between-attempt disconnect abort raises CancelledError + # INSIDE the executor fn, so the future finishes with it as a stored + # exception (FINISHED state, not CANCELLED) — intentional, don't warn. + if exc is None or isinstance(exc, asyncio.CancelledError): + return + logger.warning( + "A2UI recovery loop failed after the consumer disconnected: %s", + exc, + exc_info=exc, + ) + + +# --------------------------------------------------------------------------- +# Sub-agent error classification +# --------------------------------------------------------------------------- + + +def classify_a2ui_subagent_error(err: BaseException, aborted: bool) -> str: + """Classify a sub-agent invoke error. ``"rethrow"`` must unwind the tool + call — no recovery retries; Strands' tool executor surfaces it as a tool + error (only BaseExceptions escape the run itself): + + - cancellation — retrying would defeat the cancel and burn MORE tokens; + - programmer errors (TypeError/NameError = adapter bugs) — must surface + loudly, not masquerade as a recoverable "failed attempt". + + ``"recoverable"`` is a genuine model/network error the recovery loop should + record as a failed attempt (retry or tasteful hard-failure). + """ + if aborted or isinstance(err, asyncio.CancelledError): + return "rethrow" + if isinstance(err, (TypeError, NameError)): + # (TS asymmetry note: the TS twin exempts undici's exact + # `TypeError: fetch failed` — Python transports never surface network + # failures as TypeError, so no exemption is needed here.) + return "rethrow" + # Non-Exception BaseExceptions (SystemExit, KeyboardInterrupt, ...) signal + # shutdown — retrying through them would fire more model calls during + # interpreter teardown. + if not isinstance(err, Exception): + return "rethrow" + return "recoverable" + + +# --------------------------------------------------------------------------- +# Message-shape helpers (Strands python message dicts) +# --------------------------------------------------------------------------- + + +def _has_tool_use_for(message: dict, tool_name: str) -> bool: + content = message.get("content") + if not isinstance(content, list): + return False + for block in content: + if isinstance(block, dict): + tool_use = block.get("toolUse") + if isinstance(tool_use, dict) and tool_use.get("name") == tool_name: + return True + return False + + +def strip_in_flight_tool_call(messages: list, tool_name: str) -> list: + """Drop the trailing in-flight ``tool_name`` call. When the model invokes + the generate tool, the assistant turn carrying that toolUse is the last + message with no matching toolResult yet — passing it to the sub-agent + (which lacks the tool) is malformed. Only strips when the LAST message is + that call, so a normal user turn at the tail is preserved. The WHOLE + trailing message is dropped — any sibling text block in that assistant + turn goes with it (the sub-agent prompt carries the request context).""" + if messages: + last = messages[-1] + if ( + isinstance(last, dict) + and last.get("role") == "assistant" + and _has_tool_use_for(last, tool_name) + ): + return list(messages[:-1]) + return list(messages) + + +def _tool_result_text(content: Any) -> str: + """Extract text from a Strands ``toolResult.content`` for A2UI detection. + Handles raw strings, ``{"text": ...}`` and ``{"json": ...}`` blocks.""" + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + parts: list[str] = [] + for block in content: + if not isinstance(block, dict): + continue + if isinstance(block.get("text"), str): + parts.append(block["text"]) + elif "json" in block: + parts.append(json.dumps(block["json"])) + return "".join(parts) + + +def strands_tool_results_to_agui(messages: list) -> list: + """Reconstruct the AG-UI ``role:"tool"`` messages the toolkit's + ``find_prior_surface`` needs (used only for ``intent:"update"``) from + Strands history. Strands carries tool results as ``toolResult`` blocks + nested in user turns; emit one AG-UI tool message per result whose content + contains a prior ``a2ui_operations`` envelope.""" + out: list = [] + fallback_seq = 0 + for message in messages: + if not isinstance(message, dict): + continue + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + result = block.get("toolResult") + if not isinstance(result, dict): + continue + text = _tool_result_text(result.get("content")) + if not text or A2UI_OPERATIONS_KEY not in text: + continue + tool_call_id = result.get("toolUseId") + if not tool_call_id: + tool_call_id = f"a2ui-prior-{fallback_seq}" + fallback_seq += 1 + out.append( + { + "id": tool_call_id, + "role": "tool", + "tool_call_id": tool_call_id, + "content": text, + } + ) + return out + + +# --------------------------------------------------------------------------- +# Sub-agent invocation (streaming) +# --------------------------------------------------------------------------- + + +async def _stream_render_subagent( + model: Any, + prompt: str, + messages: list, + push: Callable[[dict], None], + catalog_id: Optional[str] = None, +) -> Optional[dict]: + """Run the structured-output sub-agent once: bind a ``render_a2ui`` tool, + stream the model, push per-event render progress (start / args deltas / + end) via ``push``, and return the captured ``render_a2ui`` args — or + ``None`` if the model produced no call. + + ``catalog_id`` (the host-resolved ``default_catalog_id``) is stamped into the + streamed args. The model never emits ``catalogId`` — the render schema omits + it and the host owns the catalog — so without this the progressive paint in + ``@ag-ui/a2ui-middleware`` (which reads ``catalogId`` off the streamed args) + falls back to the basic catalog and the renderer throws "Catalog not found". + The id matches what ``build_a2ui_envelope`` stamps on the final surface, so + the progressive and committed surfaces agree.""" + captured: dict | None = None + + def _capture(tool_use: ToolUse, **_kwargs: Any): + nonlocal captured + raw = tool_use.get("input") + captured = raw if isinstance(raw, dict) else {} + return { + "toolUseId": tool_use["toolUseId"], + "status": "success", + "content": [{"text": "ok"}], + } + + render_tool = PythonAgentTool( + tool_name=RENDER_A2UI_TOOL_NAME, + tool_spec={ + "name": RENDER_A2UI_TOOL_NAME, + "description": RENDER_A2UI_TOOL_DEF["function"]["description"], + "inputSchema": {"json": RENDER_A2UI_TOOL_DEF["function"]["parameters"]}, + }, + tool_func=_capture, + ) + + subagent = Agent( + model=model, + system_prompt=prompt, + messages=list(messages), + tools=[render_tool], + ) + + live_call_id: Optional[str] = None + emitted_len = 0 + # Whether the host ``catalog_id`` has been spliced into the streamed args + # for the current call yet (reset per call below). + catalog_prefixed = False + # Per-invocation fallback id: providers that never stamp toolUseId must + # not reuse one literal id across recovery attempts (two full lifecycles + # under one toolCallId would mis-merge in id-keyed consumers). + fallback_call_id = f"a2ui-render-{uuid.uuid4().hex[:8]}" + try: + async for event in subagent.stream_async(None): + if not isinstance(event, dict): + continue + current = event.get("current_tool_use") + if not isinstance(current, dict) or current.get("name") != RENDER_A2UI_TOOL_NAME: + continue + raw_call_id = current.get("toolUseId") + call_id = raw_call_id or live_call_id or fallback_call_id + if live_call_id == fallback_call_id and raw_call_id: + # The provider delivered the real toolUseId only after id-less + # frames: same logical call — keep the latched fallback id so the + # synthetic stream stays continuous (no spurious end/start and no + # duplicate prefix re-push under the new id). Residual: a DISTINCT + # second call after an entirely id-less first would merge into it + # — accepted; real providers stamp ids, and the envelope rides the + # captured args, not these deltas. + call_id = live_call_id + if call_id != live_call_id: + # New render call (normally the only one). Close any previous call + # first so streamed args DELTAS never mis-attribute across call ids + # (mirrors the TS adapter's per-toolUseStart reset). NOTE: the + # dict-input fallback below still emits the single shared + # `captured` under the LAST call id — exact per-call capture isn't + # worth the bookkeeping for a path models shouldn't take. + if live_call_id is not None: + push({"kind": "end", "tool_call_id": live_call_id}) + live_call_id = call_id + emitted_len = 0 + catalog_prefixed = False + push( + { + "kind": "start", + "tool_call_id": call_id, + "tool_call_name": RENDER_A2UI_TOOL_NAME, + } + ) + raw = current.get("input") + if isinstance(raw, str) and len(raw) > emitted_len: + delta = raw[emitted_len:] + # Stamp the host catalog id into the FIRST chunk by splicing it + # right after the opening brace, so the accumulated args become + # ``{"catalogId": "", ...}`` — valid JSON the middleware's + # progressive paint reads the id from. The model never emits it. + if catalog_id and not catalog_prefixed: + brace = delta.find("{") + if brace != -1: + delta = ( + delta[: brace + 1] + + f'"catalogId": {json.dumps(catalog_id)}, ' + + delta[brace + 1 :] + ) + catalog_prefixed = True + push( + { + "kind": "args", + "tool_call_id": live_call_id, + "delta": delta, + } + ) + emitted_len = len(raw) + except BaseException: + # The provider stream died mid-call (model 429, network drop, ...): + # close the live synthetic call before unwinding — an unclosed inner + # TOOL_CALL_START is a wire-protocol violation, and the next recovery + # attempt would open a fresh call on top of it. + if live_call_id is not None: + try: + push({"kind": "end", "tool_call_id": live_call_id}) + except RuntimeError: + # call_soon_threadsafe on a closing loop must not REPLACE the + # original exception (e.g. a CancelledError) mid-unwind. + pass + raise + if live_call_id is None and captured is not None: + # The provider invoked the bound render tool without emitting any + # current_tool_use stream frames: synthesize the full triplet so the + # middleware still sees components before the result (no bulk paint). + live_call_id = fallback_call_id + push( + { + "kind": "start", + "tool_call_id": live_call_id, + "tool_call_name": RENDER_A2UI_TOOL_NAME, + } + ) + push( + { + "kind": "args", + "tool_call_id": live_call_id, + "delta": json.dumps( + {**captured, "catalogId": catalog_id} if catalog_id else captured + ), + } + ) + push({"kind": "end", "tool_call_id": live_call_id}) + elif live_call_id is not None: + # Some providers deliver the input as a parsed dict (no raw growth); if + # nothing streamed, emit the captured args as one delta so the + # middleware still sees the components before the result. (Providers + # are assumed not to MIX shapes within one call — a str-then-dict + # switch would leave the streamed deltas truncated; paint still + # completes from the captured args in the result envelope.) + if emitted_len == 0 and captured is not None: + push( + { + "kind": "args", + "tool_call_id": live_call_id, + "delta": json.dumps( + {**captured, "catalogId": catalog_id} if catalog_id else captured + ), + } + ) + push({"kind": "end", "tool_call_id": live_call_id}) + return captured + + +# --------------------------------------------------------------------------- +# The generate_a2ui tool +# --------------------------------------------------------------------------- + + +class _GenerateA2UITool(AgentTool): + """Strands tool that delegates A2UI surface generation to a sub-agent + running the toolkit recovery loop, streaming render progress as it goes.""" + + def __init__(self, params: A2UIToolParams, glue: Optional[dict] = None) -> None: + super().__init__() + cfg = resolve_a2ui_tool_params(params) + self._cfg = cfg + self._glue = glue or {} + self._spec: ToolSpec = { + "name": cfg["tool_name"], + "description": cfg["tool_description"], + "inputSchema": { + "json": { + "type": "object", + "properties": { + "intent": { + "type": "string", + "enum": ["create", "update"], + "description": GENERATE_A2UI_ARG_DESCRIPTIONS["intent"], + }, + "target_surface_id": { + "type": "string", + "description": GENERATE_A2UI_ARG_DESCRIPTIONS["target_surface_id"], + }, + "changes": { + "type": "string", + "description": GENERATE_A2UI_ARG_DESCRIPTIONS["changes"], + }, + }, + } + }, + } + + @property + def tool_name(self) -> str: + return self._spec["name"] + + @property + def tool_spec(self) -> ToolSpec: + return self._spec + + @property + def tool_type(self) -> str: + return "python" + + async def stream(self, tool_use: ToolUse, invocation_state: dict, **kwargs: Any): + cfg = self._cfg + glue = self._glue + raw_input = tool_use.get("input") + args = raw_input if isinstance(raw_input, dict) else {} + intent = args.get("intent") + target_surface_id = args.get("target_surface_id") + changes = args.get("changes") + + # Strands history for the sub-agent, minus the in-flight generate_a2ui + # call. Prefer the LIVE calling agent (execution-time history); fall + # back to the per-thread agent captured at injection time. + calling_agent = invocation_state.get("agent") or glue.get("strands_agent") + strands_messages = strip_in_flight_tool_call( + list(getattr(calling_agent, "messages", None) or []), + self.tool_name, + ) + + # AG-UI history for the toolkit's find_prior_surface (update intent + # only). MERGE the adapter-supplied glue snapshot (run-start history) + # with the + # live Strands-derived results: the snapshot alone misses a surface + # created EARLIER IN THIS SAME RUN, so a same-run create-then-update + # would error for a surface visibly on screen. Derived results go + # last — find_prior_surface walks backwards, so same-run state wins. + agui_messages = list(glue.get("agui_messages") or []) + ( + strands_tool_results_to_agui(strands_messages) + ) + + prep = prepare_a2ui_request( + intent=intent, + target_surface_id=target_surface_id, + changes=changes, + messages=agui_messages, + # `RunAgentInput.state` is Any on the wire; a truthy non-dict must + # degrade to empty state (generation proceeds without it) rather + # than crash the tool before the recovery loop engages. + state=( + glue.get("state") if isinstance(glue.get("state"), dict) else {} + ), + guidelines=cfg["guidelines"], + ) + + if prep.get("error"): + # The model still reads the envelope (it can self-correct), but + # leave a server-side breadcrumb so these are countable. + logger.warning("A2UI request prep failed: %s", prep["error"]) + envelope = wrap_error_envelope(prep["error"]) + else: + # The sync recovery loop runs in a worker thread; sub-agent stream + # progress is pushed onto this queue and re-yielded live. + loop = asyncio.get_running_loop() + queue: asyncio.Queue = asyncio.Queue() + + def _push(payload: dict) -> None: + loop.call_soon_threadsafe(queue.put_nowait, payload) + + # Disconnect channel (the TS adapter's cancelSignal analog, scoped + # to attempt boundaries): set when the consumer abandons this + # generator so the recovery loop stops before firing further + # sub-agent model calls nobody will drain. The in-flight attempt + # still runs to completion (asyncio.run can't be aborted mid-call). + disconnected = threading.Event() + + def _invoke_subagent(prompt: str, attempt: int) -> Optional[dict]: + if disconnected.is_set() or loop.is_closed(): + # Loop closure (process shutdown) would otherwise surface + # as a "recoverable" RuntimeError from _push and burn the + # remaining attempts against a dead consumer. + raise asyncio.CancelledError( + "consumer disconnected; abandoning A2UI recovery" + ) + # Worker thread: run the async sub-agent on its own loop. + try: + return asyncio.run( + _stream_render_subagent( + cfg["model"], + prompt, + strands_messages, + _push, + catalog_id=cfg["default_catalog_id"], + ) + ) + except BaseException as err: # noqa: BLE001 — classified below + # `aborted=False`: mid-attempt cancellation still rethrows + # via asyncio.CancelledError; between-attempt disconnects + # are handled by the `disconnected` check above. + if classify_a2ui_subagent_error(err, False) == "rethrow": + raise + logger.warning( + "A2UI sub-agent invoke failed on attempt %d; treating as " + "a failed attempt: %s", + attempt, + err, + exc_info=True, + ) + return None + + def _build_envelope(render_args: dict) -> str: + return build_a2ui_envelope( + args=render_args, + is_update=prep["is_update"], + target_surface_id=target_surface_id, + prior=prep.get("prior"), + default_surface_id=cfg["default_surface_id"], + default_catalog_id=cfg["default_catalog_id"], + ) + + future = loop.run_in_executor( + None, + lambda: run_a2ui_generation_with_recovery( + base_prompt=prep["prompt"], + catalog=cfg["catalog"], + config=cfg["recovery"], + on_attempt=cfg["on_a2ui_attempt"], + invoke_subagent=_invoke_subagent, + build_envelope=_build_envelope, + ), + ) + + # Drain until the recovery future is done AND the queue is empty — + # the same structural guarantee as the TS adapter's + # `while (!settled || queue.length > 0)`. Relying on call_soon FIFO + # ordering alone could drop pushes scheduled concurrently with the + # future's completion callback. + get_task: Optional[asyncio.Task] = None + try: + while not (future.done() and queue.empty()): + while not queue.empty(): + yield ToolStreamEvent( + tool_use, {A2UI_STREAM_KEY: queue.get_nowait()} + ) + if future.done(): + continue # re-check: a push may have landed during drain + get_task = asyncio.ensure_future(queue.get()) + done, _ = await asyncio.wait( + {get_task, future}, return_when=asyncio.FIRST_COMPLETED + ) + if get_task in done: + item = get_task.result() + get_task = None + yield ToolStreamEvent(tool_use, {A2UI_STREAM_KEY: item}) + else: + get_task.cancel() + # asyncio sharp edge: a cancelled Queue.get() can have + # already consumed an item. Recover it instead of losing it. + try: + item = await get_task + get_task = None + yield ToolStreamEvent(tool_use, {A2UI_STREAM_KEY: item}) + except asyncio.CancelledError: + get_task = None + # An OUTER task cancellation landing while we were + # suspended here is indistinguishable from our own + # get_task.cancel() — swallowing it would lose the + # cancel (it injects once). cancelling() is raised + # only for the enclosing task's cancellation. + task = asyncio.current_task() + if task is not None and task.cancelling(): + raise + except BaseException: + # Unwinding abnormally (GeneratorExit on disconnect, + # cancellation, or a bug above): stop the recovery loop before + # its next attempt, and consume the future's eventual outcome + # so a rethrown error isn't dropped as "exception was never + # retrieved" — even when the future completed just before we + # unwound. + disconnected.set() + future.add_done_callback(_log_abandoned_recovery_result) + raise + finally: + # Generator abandonment (client disconnect -> GeneratorExit at + # a suspension point) must not strand a pending Queue.get() + # ("Task was destroyed but it is pending"). + if get_task is not None and not get_task.done(): + get_task.cancel() + # One final settle + drain: let any just-scheduled threadsafe + # callbacks run, then flush. Same abandonment guard as the main + # drain — a disconnect at THESE yields must still consume the + # future's outcome (it can hold a rethrow-class exception). + try: + await asyncio.sleep(0) + while not queue.empty(): + yield ToolStreamEvent( + tool_use, {A2UI_STREAM_KEY: queue.get_nowait()} + ) + except BaseException: + disconnected.set() + future.add_done_callback(_log_abandoned_recovery_result) + raise + envelope = future.result()["envelope"] + + yield ToolResultEvent( + { + "toolUseId": tool_use["toolUseId"], + "status": "success", + "content": [{"text": envelope}], + } + ) + + +def get_a2ui_tools(params: A2UIToolParams, glue: Optional[dict] = None) -> AgentTool: + """Build a Strands tool that delegates A2UI surface generation to a + sub-agent running the toolkit recovery loop. Add the returned tool to a + Strands ``Agent``'s ``tools`` list yourself, or let ``plan_a2ui_injection`` + build it (auto-injection).""" + if params.get("model") is None: + # The TS factory enforces this at the type level; without it the + # sub-agent would silently bind Strands' default Bedrock model. + raise ValueError( + "get_a2ui_tools requires a 'model' (the Strands model instance " + "the render sub-agent runs on)." + ) + recovery = params.get("recovery") + if isinstance(recovery, dict): + # The toolkit contract is camelCase; snake_case keys are otherwise + # silently ignored (e.g. ``max_attempts`` vs ``maxAttempts``). + for key in recovery: + if isinstance(key, str) and "_" in key: + logger.warning( + "a2ui recovery config key %r is ignored — the shared " + "toolkit reads camelCase keys (e.g. 'maxAttempts').", + key, + ) + return _GenerateA2UITool(params, glue) + + +def is_auto_injected_a2ui_tool(tool: Any) -> bool: + """True if ``tool`` is a ``generate_a2ui`` this adapter auto-injected.""" + return getattr(tool, _A2UI_AUTOINJECT_ATTR, False) is True + + +# --------------------------------------------------------------------------- +# Auto-inject decision +# --------------------------------------------------------------------------- + + +def plan_a2ui_injection( + *, + model: Any, + input: RunAgentInput, + existing_tool_names: list, + config: Optional[dict] = None, + log: Optional[logging.Logger] = None, + strands_agent: Any = None, + agui_state: Optional[dict] = None, +) -> Optional[dict]: + """Decide whether to auto-inject ``generate_a2ui`` for this run, mirroring + the LangGraph contract ("no injectA2UITool, no injection"): + + 1. Off unless the runtime forwarded ``injectA2UITool`` (``True``, or a + string naming the injected RENDER tool to drop) OR a backend + ``config["inject_a2ui_tool"]`` override. + 2. USER PREVAILS — a dev-wired ``generate_a2ui`` is never + double-injected. (The per-run hook removes our OWN marked tool before + computing ``existing_tool_names``.) Deliberately, NOTHING else is + touched in this branch: the dev opted out of adapter management, so any + runtime-injected render tool stays too. Limitation: the check is + name-based — a dev-wired tool under a custom ``tool_name`` is not + recognized and auto-injection proceeds alongside it. + 3. No inferable model (Graph/Swarm orchestrators) -> warn + skip. + 4. Otherwise build the tool (threading the run's AG-UI messages + state + + guidelines), using only an explicit ``config["catalog"]`` (mirrors the + LangGraph adapter — no auto-resolution from context), and drop the + injected render tool. + + ``agui_state`` is the run state the caller (``agent.py``) assembles with the + A2UI component schema + remaining context lifted under ``state["ag-ui"]`` + (via the toolkit's ``split_a2ui_schema_context``), mirroring how the + LangGraph adapter routes context into graph state. When provided it is + threaded to the sub-agent so ``build_context_prompt`` emits the + ``## Available Components`` block + context; absent it, the raw wire + ``input.state`` is used and the sub-agent prompt carries neither. + + Returns ``{"tool", "tool_name", "drop_tool_names", "catalog"}`` or ``None``. + """ + log = log or logger + config = config or {} + + # `forwarded_props` is Any on the wire; tolerate non-dict shapes the same + # way the context-entry handling does (exported API). + forwarded = ( + input.forwarded_props if isinstance(input.forwarded_props, dict) else {} + ) + flag = forwarded.get("injectA2UITool") + if flag is None: + # Nullish fallback, mirroring the TS adapter's `??`: an explicit + # runtime `injectA2UITool: false` disables injection even when the + # backend config opts in. + flag = config.get("inject_a2ui_tool") + if not flag: + return None + + tool_name = GENERATE_A2UI_TOOL_NAME + # USER PREVAILS: explicit dev wiring wins — never double-inject. + if tool_name in existing_tool_names: + return None + + if model is None: + log.warning( + "A2UI tool injection requested but no model could be inferred from " + "the agent (multi-agent orchestrators have no model). Skipping " + "auto-injection — wire get_a2ui_tools() explicitly." + ) + return None + + render_tool_name = flag if isinstance(flag, str) else RENDER_A2UI_TOOL_NAME + + # Resolve the frontend-registered catalog from run state (the ``ag-ui`` + # ``a2ui_schema`` entry or an ``ag-ui.context`` "A2UI catalog" entry) so + # surfaces bind to the host's catalog without the host hardcoding it — + # mirrors the LangGraph adapter's auto-resolution. Backend config WINS when + # set, so an explicit ``default_catalog_id`` / ``guidelines`` override still + # applies. + resolved = resolve_a2ui_catalog(agui_state) if agui_state is not None else None + runtime_schema, runtime_catalog_id = resolved if resolved else (None, None) + + # Explicit ``config["catalog"]`` still feeds the semantic-validation catalog + # (recovery stays structural-only when absent — catalog is never + # auto-resolved from context for VALIDATION, only the id/guide below). + catalog = config.get("catalog") + default_catalog_id = config.get("default_catalog_id") or runtime_catalog_id + guidelines = config.get("guidelines") + if guidelines is None and runtime_schema: + guidelines = {"composition_guide": runtime_schema} + + tool = get_a2ui_tools( + { + "model": model, + "tool_name": tool_name, + "tool_description": config.get("tool_description"), + "catalog": catalog, + "default_catalog_id": default_catalog_id, + "default_surface_id": config.get("default_surface_id"), + "guidelines": guidelines, + "recovery": config.get("recovery"), + "on_a2ui_attempt": config.get("on_a2ui_attempt"), + }, + glue={ + "agui_messages": list(input.messages or []), + "state": agui_state if agui_state is not None else input.state, + "strands_agent": strands_agent, + }, + ) + setattr(tool, _A2UI_AUTOINJECT_ATTR, True) + + return { + "tool": tool, + "tool_name": tool_name, + "drop_tool_names": [render_tool_name], + "catalog": catalog, + } diff --git a/integrations/aws-strands/python/src/ag_ui_strands/agent.py b/integrations/aws-strands/python/src/ag_ui_strands/agent.py index 531e055b7c..bf680a84e7 100644 --- a/integrations/aws-strands/python/src/ag_ui_strands/agent.py +++ b/integrations/aws-strands/python/src/ag_ui_strands/agent.py @@ -60,6 +60,13 @@ def _extract_agent_kwargs(agent: StrandsAgentCore) -> dict: return kwargs +def _has_strands_session_manager(agent: Any) -> bool: + return ( + getattr(agent, "session_manager", None) is not None + or getattr(agent, "_session_manager", None) is not None + ) + + logger = logging.getLogger(__name__) from ag_ui.core import ( AssistantMessage, @@ -92,6 +99,13 @@ def _extract_agent_kwargs(agent: StrandsAgentCore) -> dict: UserMessage, ) +from ag_ui_a2ui_toolkit import split_a2ui_schema_context + +from .a2ui_tool import ( + A2UI_STREAM_KEY, + is_auto_injected_a2ui_tool, + plan_a2ui_injection, +) from .client_proxy_tool import sync_proxy_tools from .config import ( StrandsAgentConfig, @@ -320,6 +334,14 @@ def __init__( # would clobber the other. self._thread_init_lock = asyncio.Lock() + def _will_emit_tool_snapshot(self, behavior: Any, emit_snapshots: bool) -> bool: + # ``emit_snapshots`` is the per-run gate (config flag AND not a + # delta-only payload); callers pass it so snapshot emission stays + # suppressed on delta payloads that would otherwise wipe prior turns. + return emit_snapshots and not ( + behavior and behavior.skip_messages_snapshot + ) + async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: """Run the Strands agent and yield AG-UI events.""" @@ -452,6 +474,73 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: ) self._proxy_tool_names_by_thread[thread_id] = set() + # A2UI auto-injection. When the runtime forwards + # ``injectA2UITool`` (or the host opts in via ``config.a2ui``), register + # a ``generate_a2ui`` recovery tool bound to this agent's model and drop + # the injected ``render_a2ui`` proxy so the model calls generate_a2ui + # directly. Best-effort: a failure here logs and runs without A2UI + # rather than crashing the turn. + try: + registry = strands_agent.tool_registry + # Remove our OWN prior-turn auto-injected tool first, so (a) the + # refreshed tool carries THIS turn's messages/state, and (b) the + # USER-PREVAILS check only ever sees a dev-wired + # generate_a2ui — not our own from a previous turn on this cached + # agent. Without this, turn 2+ leaks the re-synced render_a2ui back + # to the model. + for name in [ + n for n, t in list(registry.registry.items()) + if is_auto_injected_a2ui_tool(t) + ]: + registry.registry.pop(name, None) + getattr(registry, "dynamic_tools", {}).pop(name, None) + # Lift the A2UI component schema + remaining context under + # state["ag-ui"] so the generate_a2ui sub-agent prompt carries the + # "## Available Components" block + context — same routing the + # LangGraph adapter does in its state merge. Uses the shared toolkit + # split so both adapters agree on the schema-context description. + a2ui_schema_value, a2ui_regular_ctx = split_a2ui_schema_context( + input_data.context + ) + a2ui_state = ( + dict(input_data.state) + if isinstance(input_data.state, dict) + else {} + ) + a2ui_ag_ui: dict = {"context": a2ui_regular_ctx} + if a2ui_schema_value is not None: + a2ui_ag_ui["a2ui_schema"] = a2ui_schema_value + a2ui_state["ag-ui"] = a2ui_ag_ui + + a2ui_plan = plan_a2ui_injection( + model=getattr(strands_agent, "model", None), + input=input_data, + existing_tool_names=list(registry.registry.keys()), + config=self.config.a2ui, + log=logger, + strands_agent=strands_agent, + agui_state=a2ui_state, + ) + if a2ui_plan: + # Register FIRST: if this raises, the except below degrades to + # "render proxy leaks through" (middleware still paints, + # unvalidated) instead of a turn with no A2UI path at all. + registry.register_tool(a2ui_plan["tool"]) + for name in a2ui_plan["drop_tool_names"]: + registry.registry.pop(name, None) + getattr(registry, "dynamic_tools", {}).pop(name, None) + # Keep the proxy bookkeeping honest — the dropped render + # tool is no longer registered. + self._proxy_tool_names_by_thread.get(thread_id, set()).discard(name) + except Exception as e: # noqa: BLE001 — never crash the turn here + # ERROR, not warning: the runtime explicitly requested injection + # (injectA2UITool) and this turn runs without it. + logger.error( + "A2UI auto-injection failed; running without A2UI for this turn: %s", + e, + exc_info=True, + ) + # Start run yield RunStartedEvent( type=EventType.RUN_STARTED, @@ -460,12 +549,32 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: ) try: + # Detect delta-only payloads (where the client sent fewer + # messages than the session has — e.g. only the trailing + # tool result, or only the new user message in a continued + # chat). CopilotKit V2's MESSAGES_SNAPSHOT handler treats + # the snapshot as authoritative: any existing client message + # whose id is not in the snapshot gets dropped. Emitting a + # partial snapshot on a delta payload would wipe prior turns + # from the UI. The frontend already has the full history with + # the original ids, so we suppress snapshot emission for this + # run and let TEXT_MESSAGE_*/TOOL_CALL_* streaming events + # reconcile naturally. + session_msgs = getattr(strands_agent, "messages", None) or [] + is_delta_payload = ( + bool(session_msgs) + and len(session_msgs) > len(input_data.messages or []) + ) + emit_snapshots = ( + self.config.emit_messages_snapshot and not is_delta_payload + ) + # Seed the running ``MessagesSnapshotEvent`` payload from the # full conversation history sent by the client. Each emitted # snapshot then carries prior turns + whatever this turn adds. snapshot_messages: List[Any] = ( _build_snapshot_messages(input_data.messages) - if self.config.emit_messages_snapshot + if emit_snapshots else [] ) @@ -484,7 +593,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: # after ``RunStartedEvent`` / ``StateSnapshotEvent`` so the # frontend can render the seeded thread before any new content # streams in. - if self.config.emit_messages_snapshot and snapshot_messages: + if emit_snapshots and snapshot_messages: yield MessagesSnapshotEvent( type=EventType.MESSAGES_SNAPSHOT, messages=list(snapshot_messages), @@ -572,11 +681,17 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: # Handle tool messages (must follow assistant message with tool_calls) elif msg.role == "tool": - # Skip tool messages that don't have a preceding assistant message with tool_calls + # Skip tool messages that don't have a preceding assistant message + # with tool_calls — UNLESS this is a pending frontend tool result + # (delta-only payloads only contain the tool result, so the + # assistant message is absent but the result is still valid). + is_pending_frontend_result = ( + msg.tool_call_id in pending_tool_result_ids + ) if ( not last_msg_had_tool_calls or msg.tool_call_id not in expected_tool_call_ids - ): + ) and not is_pending_frontend_result: logger.debug( f"Skipping orphaned tool message: tool_call_id={msg.tool_call_id}, last_msg_had_tool_calls={last_msg_had_tool_calls}, valid_ids={expected_tool_call_ids}, thread_id={input_data.thread_id}" ) @@ -589,7 +704,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: else: strands_msg["content"] = msg.content - expected_tool_call_ids.remove(msg.tool_call_id) + expected_tool_call_ids.discard(msg.tool_call_id) if not expected_tool_call_ids: last_msg_had_tool_calls = False strands_messages.append(strands_msg) @@ -615,17 +730,64 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: if tc.id and tc_name: _tool_call_id_to_name[tc.id] = tc_name + # On delta-only continuation payloads, the assistant message that + # carries the tool_call is absent from input_data.messages, so the + # lookup above misses. The session manager still holds the full + # native history — scan its ``toolUse`` blocks so we resolve the + # tool that actually executed rather than guessing. + for _smsg in session_msgs: + if not isinstance(_smsg, dict) or _smsg.get("role") != "assistant": + continue + for _block in (_smsg.get("content") or []): + tool_use = _block.get("toolUse") if isinstance(_block, dict) else None + if tool_use: + tu_id = tool_use.get("toolUseId") + tu_name = tool_use.get("name") + if tu_id and tu_name and tu_id not in _tool_call_id_to_name: + _tool_call_id_to_name[tu_id] = tu_name + # Get the latest user message for state context builder. # For continuation runs (has_pending_tool_result), derive a meaningful # message from the frontend tool that was just executed so the agent # understands the context and can generate a proper conclusion. - user_message = "Hello" + user_message = "" if pending_tool_result_ids and input_data.messages: for msg in reversed(input_data.messages): if msg.role == "tool" and hasattr(msg, "tool_call_id"): tool_name = _tool_call_id_to_name.get(msg.tool_call_id) if tool_name and tool_name in frontend_tool_names: - user_message = f"{tool_name} executed successfully with no return value." + # Forward the ACTUAL frontend tool result so the model + # can act on the human's decision (e.g. an approval + # resolving to {"approved": false}). Previously this + # discarded ``msg.content`` and hardcoded a success + # string, silently breaking HITL — the model was told + # the tool "executed successfully with no return value" + # regardless of what the human actually returned. + # Only fall back to that synthetic acknowledgement when + # the result is genuinely empty. + result_text = ( + msg.content + if isinstance(msg.content, str) + else flatten_content_to_text(msg.content) + ) + if result_text and result_text.strip(): + user_message = f"{tool_name} returned: {result_text}" + else: + user_message = f"{tool_name} executed successfully with no return value." + else: + # Could not resolve the executed tool's name from + # input messages or session history. Leave the + # continuation message empty rather than guessing: + # picking an arbitrary frontend tool would feed false + # context to the LLM when several frontend tools exist. + # Strands still has the real tool result in session + # history to conclude the round-trip from. + logger.warning( + f"Could not resolve tool name for tool_call_id={msg.tool_call_id} " + f"from input messages or session history (assistant message with " + f"tool_calls may be missing — delta-only payload). Leaving the " + f"continuation message empty." + ) break elif input_data.messages: for msg in reversed(input_data.messages): @@ -639,7 +801,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: user_message = convert_agui_content_to_strands(msg.content) if not user_message: # All content blocks failed conversion — fall back to text - user_message = flatten_content_to_text(msg.content) or "Hello" + user_message = flatten_content_to_text(msg.content) or "" logger.warning("All media content blocks failed conversion, falling back to text") else: user_message = flatten_content_to_text(msg.content) @@ -669,6 +831,10 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: message_id = str(uuid.uuid4()) message_started = False accumulated_text = "" + # Tracks the latest assistant text id that was actually emitted on + # the wire. Tool calls use it only when no snapshot will expose the + # tool-call AssistantMessage id. + last_emitted_text_message_id: str | None = None tool_calls_seen = {} current_state = dict(input_data.state or {}) # Track state for final snapshot stop_text_streaming = False @@ -692,7 +858,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: # Strands manages history itself, so we leave it alone. replay_history = ( self.config.replay_history_into_strands - and getattr(strands_agent, "session_manager", None) is None + and not _has_strands_session_manager(strands_agent) ) if replay_history: native_history = _build_strands_history(input_data.messages) @@ -761,6 +927,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: role="assistant", ) message_started = True + last_emitted_text_message_id = message_id text_chunk = str(event["data"]) accumulated_text += text_chunk @@ -881,6 +1048,38 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: type=EventType.STATE_SNAPSHOT, snapshot=stream_data["state"], ) + # A2UI sub-agent streaming: re-emit the + # generate_a2ui tool's inner render_a2ui progress as + # synthetic TOOL_CALL events. The a2ui middleware's + # streaming path keys its "building" skeleton + + # progressive paint off these — without them the + # surface only paints in bulk from the final result. + elif ( + isinstance(stream_data, dict) + and isinstance(stream_data.get(A2UI_STREAM_KEY), dict) + ): + a2ui_ev = stream_data[A2UI_STREAM_KEY] + kind = a2ui_ev.get("kind") + a2ui_call_id = a2ui_ev.get("tool_call_id", "") + if kind == "start": + yield ToolCallStartEvent( + type=EventType.TOOL_CALL_START, + tool_call_id=a2ui_call_id, + tool_call_name=a2ui_ev.get( + "tool_call_name", "render_a2ui" + ), + ) + elif kind == "args" and a2ui_ev.get("delta"): + yield ToolCallArgsEvent( + type=EventType.TOOL_CALL_ARGS, + tool_call_id=a2ui_call_id, + delta=a2ui_ev["delta"], + ) + elif kind == "end": + yield ToolCallEndEvent( + type=EventType.TOOL_CALL_END, + tool_call_id=a2ui_call_id, + ) # Handle tool results from Strands for backend tool rendering elif "message" in event and event["message"].get("role") == "user": @@ -968,7 +1167,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: # running snapshot so the frontend can pair # call + result in the message tree. if ( - self.config.emit_messages_snapshot + emit_snapshots and not ( behavior and behavior.skip_messages_snapshot @@ -1039,7 +1238,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: # variant): commit any accumulated # assistant text into the snapshot. if ( - self.config.emit_messages_snapshot + emit_snapshots and accumulated_text ): snapshot_messages.append( @@ -1163,7 +1362,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: message_id=message_id, ) if ( - self.config.emit_messages_snapshot + emit_snapshots and accumulated_text ): snapshot_messages.append( @@ -1199,11 +1398,17 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: value=predict_state_payload, ) + # Must mirror the later tool snapshot emission condition. + tool_parent_message_id = ( + message_id + if self._will_emit_tool_snapshot(behavior_now, emit_snapshots) + else last_emitted_text_message_id + ) yield ToolCallStartEvent( type=EventType.TOOL_CALL_START, tool_call_id=tool_use_id, tool_call_name=tool_name, - parent_message_id=message_id, + parent_message_id=tool_parent_message_id, ) tool_calls_seen[tool_use_id]["start_emitted"] = True elif tool_name and tool_use_id in tool_calls_seen: @@ -1335,13 +1540,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: tool_call_id=tool_use_id, ) - if ( - self.config.emit_messages_snapshot - and not ( - behavior - and behavior.skip_messages_snapshot - ) - ): + if self._will_emit_tool_snapshot(behavior, emit_snapshots): snapshot_messages.append( AssistantMessage( id=message_id, @@ -1440,7 +1639,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: type=EventType.TEXT_MESSAGE_END, message_id=message_id ) if ( - self.config.emit_messages_snapshot + emit_snapshots and accumulated_text ): snapshot_messages.append( @@ -1458,11 +1657,17 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: message_started = False message_id = str(uuid.uuid4()) + # Must mirror the later tool snapshot emission condition. + tool_parent_message_id = ( + message_id + if self._will_emit_tool_snapshot(behavior, emit_snapshots) + else last_emitted_text_message_id + ) yield ToolCallStartEvent( type=EventType.TOOL_CALL_START, tool_call_id=tool_use_id, tool_call_name=tool_name, - parent_message_id=message_id, + parent_message_id=tool_parent_message_id, ) try: @@ -1491,13 +1696,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: tool_call_id=tool_use_id, ) - if ( - self.config.emit_messages_snapshot - and not ( - behavior - and behavior.skip_messages_snapshot - ) - ): + if self._will_emit_tool_snapshot(behavior, emit_snapshots): snapshot_messages.append( AssistantMessage( id=message_id, @@ -1586,7 +1785,7 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: # Splice point 4 of 4 (terminal): commit the final # assistant text turn into the snapshot so the frontend # has the closing message in canonical history. - if self.config.emit_messages_snapshot and accumulated_text: + if emit_snapshots and accumulated_text: snapshot_messages.append( AssistantMessage( id=message_id, diff --git a/integrations/aws-strands/python/src/ag_ui_strands/config.py b/integrations/aws-strands/python/src/ag_ui_strands/config.py index 3836d589b9..4ea933a264 100644 --- a/integrations/aws-strands/python/src/ag_ui_strands/config.py +++ b/integrations/aws-strands/python/src/ag_ui_strands/config.py @@ -93,6 +93,22 @@ class StrandsAgentConfig: tool_behaviors: Dict[str, ToolBehavior] = field(default_factory=dict) state_context_builder: Optional[StateContextBuilder] = None session_manager_provider: Optional[SessionManagerProvider] = None + """Optional factory for creating per-thread SessionManager instances. + + Called exactly once per thread_id the first time that thread is seen. + Subsequent requests on the same thread reuse the cached agent (and its + SessionManager). If the provider depends on per-request data (e.g. auth + tokens in ``forwarded_props``), be aware that only the first request's + data is used to initialise the session manager. + + If the provider raises an exception the run yields a ``RUN_ERROR`` event + and returns early; the thread is NOT cached so the provider will be + retried on the next request. + + If the provider returns ``None`` a warning is logged and the agent runs + without session persistence; the thread IS cached in this state, so the + provider will not be called again for the same thread. + """ emit_messages_snapshot: bool = True """Emit ``MessagesSnapshotEvent`` at lifecycle boundaries (after the initial state snapshot, after each ``TOOL_CALL_END`` / @@ -113,21 +129,23 @@ class StrandsAgentConfig: the frontend produced. Disable only if you manage Strands history yourself (e.g. via a custom ``session_manager``). """ - """Optional factory for creating per-thread SessionManager instances. - - Called exactly once per thread_id the first time that thread is seen. - Subsequent requests on the same thread reuse the cached agent (and its - SessionManager). If the provider depends on per-request data (e.g. auth - tokens in ``forwarded_props``), be aware that only the first request's - data is used to initialise the session manager. - - If the provider raises an exception the run yields a ``RUN_ERROR`` event - and returns early; the thread is NOT cached so the provider will be - retried on the next request. - - If the provider returns ``None`` a warning is logged and the agent runs - without session persistence; the thread IS cached in this state, so the - provider will not be called again for the same thread. + a2ui: Optional[Dict[str, Any]] = None + """A2UI auto-injection config — everything A2UI-related in one + place. When the CopilotKit runtime forwards ``injectA2UITool`` (or + ``a2ui["inject_a2ui_tool"]`` opts in on a host that doesn't), the adapter + injects a ``generate_a2ui`` recovery tool and infers the model from the + wrapped agent — no manual ``get_a2ui_tools()`` needed. Keys: + + - ``inject_a2ui_tool`` — opt in without the runtime flag; a string also + names the injected render tool to drop. + - ``default_catalog_id`` — catalog id stamped into auto-injected surfaces + (must match the host renderer's catalog). + - ``guidelines`` — ``{"composition_guide": ...}`` teaches the sub-agent the + catalog's components; required for a real model to compose them. + - ``catalog`` — inline catalog for catalog-aware (semantic) recovery. + - ``recovery`` — recovery loop config. NOTE: keys are camelCase per the + shared toolkit contract — e.g. ``{"maxAttempts": 5}`` (a snake_case + ``max_attempts`` is silently ignored). """ diff --git a/integrations/aws-strands/python/tests/test_a2ui_tool.py b/integrations/aws-strands/python/tests/test_a2ui_tool.py new file mode 100644 index 0000000000..d4b9a09175 --- /dev/null +++ b/integrations/aws-strands/python/tests/test_a2ui_tool.py @@ -0,0 +1,1278 @@ +"""Unit tests for the AWS Strands A2UI subagent tool — Python. + +Mirrors the TypeScript suite +(integrations/aws-strands/typescript/src/__tests__/a2ui-tool.test.ts), covering +both wiring modes (explicit + auto-injected), message-shape helpers, error +classification, and +the sub-agent streaming translation: + + Explicit wiring: ``get_a2ui_tools(params)`` returns a Strands + ``AgentTool`` subclass named ``generate_a2ui`` that runs the toolkit recovery + loop. + + Auto-injection: ``plan_a2ui_injection(...)`` is the pure per-run + decision — read the runtime ``injectA2UITool`` flag off ``forwarded_props``, + infer the model from the wrapped agent, resolve the catalog from + ``input.context``, and decide whether to inject ``generate_a2ui`` (and which + injected render tool to drop). Returns ``None`` when it must NOT inject. + +String literals mirror the shared constants (``GENERATE_A2UI_TOOL_NAME`` from +ag-ui-a2ui-toolkit, ``RENDER_A2UI_TOOL_NAME`` + ``A2UI_SCHEMA_CONTEXT_DESCRIPTION`` +from @ag-ui/a2ui-middleware), hardcoded ON PURPOSE: these are cross-package +wire contracts, and a hardcoded copy makes the suite fail if an upstream +constant drifts (importing the constant would hide the drift). +""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import MagicMock + +import pytest +from ag_ui.core import Context, EventType, RunAgentInput, Tool, UserMessage +from strands.tools.registry import ToolRegistry + +from ag_ui_strands.a2ui_tool import ( + A2UI_STREAM_KEY, + classify_a2ui_subagent_error, + get_a2ui_tools, + is_auto_injected_a2ui_tool, + plan_a2ui_injection, + strands_tool_results_to_agui, + strip_in_flight_tool_call, +) +from ag_ui_strands.agent import StrandsAgent +from ag_ui_strands.config import StrandsAgentConfig + +GENERATE_A2UI_TOOL_NAME = "generate_a2ui" +RENDER_A2UI_TOOL_NAME = "render_a2ui" +A2UI_SCHEMA_CONTEXT_DESCRIPTION = ( + "A2UI Component Schema — available components for generating UI surfaces. " + "Use these component names and properties when creating A2UI operations." +) +A2UI_OPS_KEY = "a2ui_operations" + +STUB_MODEL = MagicMock(name="stub-model") +CATALOG = { + "components": { + "Row": {"required": ["children"]}, + "HotelCard": {"required": ["name", "rating"]}, + } +} + + +def _input(forwarded_props=None, context=None, tools=None) -> RunAgentInput: + return RunAgentInput( + thread_id="thread-1", + run_id="run-1", + state={}, + messages=[], + tools=tools or [], + context=context or [], + forwarded_props=forwarded_props or {}, + ) + + +# --------------------------------------------------------------------------- +# Explicit factory +# --------------------------------------------------------------------------- + + +def test_get_a2ui_tools_default_name(): + tool = get_a2ui_tools({"model": STUB_MODEL}) + assert tool.tool_name == GENERATE_A2UI_TOOL_NAME + + +def test_get_a2ui_tools_custom_name(): + tool = get_a2ui_tools({"model": STUB_MODEL, "tool_name": "make_ui"}) + assert tool.tool_name == "make_ui" + + +# --------------------------------------------------------------------------- +# Auto-inject decision +# --------------------------------------------------------------------------- + + +def test_injects_when_flag_true_and_model_present(): + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + ) + assert plan is not None + assert plan["tool_name"] == GENERATE_A2UI_TOOL_NAME + assert RENDER_A2UI_TOOL_NAME in plan["drop_tool_names"] + + +def test_drops_custom_named_render_tool_when_flag_is_string(): + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": "render_ui_custom"}), + existing_tool_names=[], + ) + assert plan is not None + assert plan["tool_name"] == GENERATE_A2UI_TOOL_NAME + assert "render_ui_custom" in plan["drop_tool_names"] + + +def test_skips_and_warns_when_no_model_inferable_orchestrator(): + log = MagicMock() + plan = plan_a2ui_injection( + model=None, + input=_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + log=log, + ) + assert plan is None + log.warning.assert_called_once() + + +def test_no_inject_without_flag_or_override(): + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(), + existing_tool_names=[], + ) + assert plan is None + + +def test_backend_override_injects_without_runtime_flag(): + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(), + existing_tool_names=[], + config={"inject_a2ui_tool": True}, + ) + assert plan is not None + assert plan["tool_name"] == GENERATE_A2UI_TOOL_NAME + + +def test_user_prevails_no_double_inject(): + # THE "USER PREVAILS" REQUIREMENT: explicit dev wiring wins. + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[GENERATE_A2UI_TOOL_NAME], + ) + assert plan is None + + +def test_ignores_catalog_in_schema_context_entry(): + """Mirrors the LangGraph adapter: a catalog carried in RunAgentInput.context + is NOT auto-resolved. Only an explicit ``config["catalog"]`` enables + catalog-aware recovery; otherwise recovery stays structural-only.""" + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input( + forwarded_props={"injectA2UITool": True}, + context=[ + Context( + description=A2UI_SCHEMA_CONTEXT_DESCRIPTION, + value=json.dumps(CATALOG), + ) + ], + ), + existing_tool_names=[], + ) + assert plan is not None + assert plan["catalog"] is None + + +def test_uses_explicit_config_catalog(): + """Explicit backend config catalog is threaded through unchanged.""" + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + config={"catalog": CATALOG}, + ) + assert plan is not None + assert plan["catalog"] == CATALOG + + +def test_resolves_catalog_id_from_runtime_state(): + """When the host does NOT configure default_catalog_id, the catalog id is + auto-resolved from run state (native ag-ui.a2ui_schema) and bound — parity + with the LangGraph adapter, so the host wires nothing.""" + agui_state = { + "ag-ui": { + "a2ui_schema": json.dumps({"catalogId": "runtime-cat", "components": []}) + } + } + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + agui_state=agui_state, + ) + assert plan is not None + assert plan["tool"]._cfg["default_catalog_id"] == "runtime-cat" + + +def test_config_default_catalog_id_overrides_runtime(): + """Explicit backend config wins over the runtime-resolved catalog id.""" + agui_state = { + "ag-ui": {"a2ui_schema": json.dumps({"catalogId": "runtime-cat"})} + } + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + config={"default_catalog_id": "config-cat"}, + agui_state=agui_state, + ) + assert plan is not None + assert plan["tool"]._cfg["default_catalog_id"] == "config-cat" + + +def test_runtime_schema_becomes_composition_guide(): + """The proxy-path component schema is bound as the sub-agent + composition_guide when the host did not supply guidelines.""" + agui_state = { + "ag-ui": { + "context": [ + {"description": "A2UI catalog", "value": "- custom-cat\nSchema text"} + ] + } + } + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + agui_state=agui_state, + ) + assert plan is not None + assert plan["tool"]._cfg["default_catalog_id"] == "custom-cat" + assert "custom-cat" in plan["tool"]._cfg["guidelines"]["composition_guide"] + + +def test_auto_inject_threads_all_config_knobs(): + """plan_a2ui_injection must forward every backend ``config.a2ui`` knob the + toolkit honors (tool_description / default_surface_id / on_a2ui_attempt), + not just the model/catalog subset — parity with the dev-wired path.""" + def sentinel(*_a, **_k): + return None + + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + config={ + "tool_description": "custom desc", + "default_surface_id": "surf-9", + "default_catalog_id": "cat-9", + "on_a2ui_attempt": sentinel, + }, + ) + assert plan is not None + cfg = plan["tool"]._cfg + assert cfg["tool_description"] == "custom desc" + assert cfg["default_surface_id"] == "surf-9" + assert cfg["default_catalog_id"] == "cat-9" + assert cfg["on_a2ui_attempt"] is sentinel + + +def test_plan_threads_agui_state_into_glue(): + """The caller-assembled ``agui_state`` (schema + context under + state["ag-ui"]) is threaded into the built tool's glue, so the sub-agent + prompt can carry it — parity with the LangGraph adapter.""" + state = {"ag-ui": {"context": [], "a2ui_schema": "SCHEMA"}} + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + agui_state=state, + ) + assert plan is not None + assert plan["tool"]._glue["state"] is state + + +@pytest.mark.asyncio +async def test_subagent_prompt_carries_ag_ui_schema_and_context(monkeypatch): + """state["ag-ui"] schema + context reach the sub-agent prompt as the + '## Available Components' block and context lines — the LangGraph-parity + fix: without it the sub-agent gets no component list and guesses.""" + import ag_ui_strands.a2ui_tool as mod + + seen = {} + + async def fake_subagent(model, prompt, messages, push, **kwargs): + seen["prompt"] = prompt + return {"surfaceId": "s1", "components": [{"id": "root", "component": "Row"}]} + + monkeypatch.setattr(mod, "_stream_render_subagent", fake_subagent) + tool = get_a2ui_tools( + {"model": STUB_MODEL}, + glue={ + "state": { + "ag-ui": { + "context": [ + {"description": "App context", "value": "user on dashboard"} + ], + "a2ui_schema": json.dumps(CATALOG), + } + } + }, + ) + await _drive_stream(tool) + + prompt = seen["prompt"] + assert "## Available Components" in prompt + assert "HotelCard" in prompt # from CATALOG schema + assert "## App context" in prompt + assert "user on dashboard" in prompt + + +def test_marker_distinguishes_auto_injected_from_dev_wired(): + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": True}), + existing_tool_names=[], + ) + assert plan is not None + assert is_auto_injected_a2ui_tool(plan["tool"]) is True + # A dev-wired tool carries no marker. + assert is_auto_injected_a2ui_tool(get_a2ui_tools({"model": STUB_MODEL})) is False + + +# --------------------------------------------------------------------------- +# Message-shape helpers (Strands python message dicts) +# --------------------------------------------------------------------------- + + +def test_strip_in_flight_tool_call_drops_trailing_call(): + messages = [ + {"role": "user", "content": [{"text": "compare hotels"}]}, + { + "role": "assistant", + "content": [ + {"toolUse": {"name": GENERATE_A2UI_TOOL_NAME, "toolUseId": "t1", "input": {}}} + ], + }, + ] + stripped = strip_in_flight_tool_call(messages, GENERATE_A2UI_TOOL_NAME) + assert len(stripped) == 1 + assert stripped[0]["role"] == "user" + + +def test_strip_in_flight_tool_call_keeps_trailing_user_turn(): + messages = [{"role": "user", "content": [{"text": "compare hotels"}]}] + assert len(strip_in_flight_tool_call(messages, GENERATE_A2UI_TOOL_NAME)) == 1 + + +def test_strands_tool_results_to_agui_reconstructs_a2ui_results(): + envelope = json.dumps({A2UI_OPS_KEY: [{"version": "v0.9"}]}) + messages = [ + { + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": "tc1", + "status": "success", + "content": [{"text": envelope}], + } + } + ], + } + ] + agui = strands_tool_results_to_agui(messages) + assert len(agui) == 1 + assert agui[0]["role"] == "tool" + assert agui[0]["tool_call_id"] == "tc1" + assert A2UI_OPS_KEY in agui[0]["content"] + + +def test_strands_tool_results_to_agui_handles_json_blocks_and_ignores_non_a2ui(): + # {json} content block form. + from_json = strands_tool_results_to_agui( + [ + { + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": "tc2", + "status": "success", + "content": [{"json": {A2UI_OPS_KEY: [{"version": "v0.9"}]}}], + } + } + ], + } + ] + ) + assert len(from_json) == 1 + assert A2UI_OPS_KEY in from_json[0]["content"] + # Non-A2UI tool results are ignored. + ignored = strands_tool_results_to_agui( + [ + { + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": "tc3", + "status": "success", + "content": [{"text": "just a weather result"}], + } + } + ], + } + ] + ) + assert ignored == [] + + +# --------------------------------------------------------------------------- +# Sub-agent error classification +# --------------------------------------------------------------------------- + + +def test_classify_rethrows_cancellation_and_programmer_errors(): + assert classify_a2ui_subagent_error(asyncio.CancelledError(), False) == "rethrow" + assert classify_a2ui_subagent_error(Exception("x"), True) == "rethrow" + assert classify_a2ui_subagent_error(TypeError("x"), False) == "rethrow" + assert classify_a2ui_subagent_error(NameError("x"), False) == "rethrow" + + +def test_classify_treats_model_errors_as_recoverable(): + assert classify_a2ui_subagent_error(Exception("Bedrock 429"), False) == "recoverable" + + +# --------------------------------------------------------------------------- +# Adapter integration — scripted runs (conventions from +# tests/test_streaming_predict_state.py) +# --------------------------------------------------------------------------- + + +def _template_agent() -> MagicMock: + mock = MagicMock() + mock.model = MagicMock() + mock.system_prompt = "You are helpful" + mock.tool_registry.registry = {} + mock.record_direct_tool_call = True + # A bare MagicMock auto-creates a truthy `_session_manager`, which would + # fire the "session_manager will be ignored" warning in every test. + mock._session_manager = None + return mock + + +def _build_agent(thread_id: str, stream_events: list, config=None) -> StrandsAgent: + agent = StrandsAgent( + _template_agent(), name="test-agent", config=config or StrandsAgentConfig() + ) + mock_inner = MagicMock() + mock_inner.model = MagicMock() + mock_inner.tool_registry = ToolRegistry() + mock_inner.session_manager = None + # Without this a bare MagicMock auto-creates a truthy `_session_manager`, + # flipping `_has_strands_session_manager` True and silently routing every + # test through the legacy (non-replay) path instead of the default + # `replay_history_into_strands` one. + mock_inner._session_manager = None + mock_inner.messages = [] + + async def _stream(_msg): + for event in stream_events: + yield event + + mock_inner.stream_async = _stream + agent._agents_by_thread[thread_id] = mock_inner + return agent + + +async def _collect(agent: StrandsAgent, inp: RunAgentInput) -> list: + return [e async for e in agent.run(inp)] + + +RENDER_TOOL_INPUT = Tool( + name=RENDER_A2UI_TOOL_NAME, + description="render a2ui", + parameters={"type": "object", "properties": {}}, +) + + +def _msg_input(**overrides) -> RunAgentInput: + base = dict( + thread_id="thread-1", + run_id="run-1", + state={}, + messages=[UserMessage(id="u1", role="user", content="hi")], + tools=[], + context=[], + forwarded_props={}, + ) + base.update(overrides) + return RunAgentInput(**base) + + +@pytest.mark.asyncio +async def test_auto_inject_registers_generate_and_drops_render_across_turns(): + """F1 regression: turn 2 on a cached thread must re-drop the re-synced + render_a2ui and keep exactly one generate_a2ui (our own marked tool is + refreshed, never treated as dev-wired).""" + agent = _build_agent("thread-1", []) + registry = agent._agents_by_thread["thread-1"].tool_registry + + inp = _msg_input( + forwarded_props={"injectA2UITool": True}, tools=[RENDER_TOOL_INPUT] + ) + await _collect(agent, inp) + names = set(registry.registry.keys()) + assert GENERATE_A2UI_TOOL_NAME in names + assert RENDER_A2UI_TOOL_NAME not in names + tool_turn1 = registry.registry[GENERATE_A2UI_TOOL_NAME] + # The dropped render tool must also leave the proxy bookkeeping. + assert RENDER_A2UI_TOOL_NAME not in agent._proxy_tool_names_by_thread["thread-1"] + + # Turn 2: syncProxyTools re-adds render_a2ui from input.tools; the hook + # must drop it again and refresh (not duplicate) generate_a2ui. + await _collect(agent, inp) + names = set(registry.registry.keys()) + assert GENERATE_A2UI_TOOL_NAME in names + assert RENDER_A2UI_TOOL_NAME not in names + # "Refresh" means a REBUILT tool carrying turn-2 glue — reusing the turn-1 + # object would resolve `intent:"update"` priors against stale history. + assert registry.registry[GENERATE_A2UI_TOOL_NAME] is not tool_turn1 + + +@pytest.mark.asyncio +async def test_tool_stream_a2ui_payloads_become_inner_tool_call_events(): + """The generate_a2ui tool yields A2UI_STREAM_KEY payloads; the adapter must + re-emit them as synthetic inner TOOL_CALL_START/ARGS/END so the middleware + can drive the building skeleton + progressive paint.""" + events = [ + { + "tool_stream_event": { + "data": { + A2UI_STREAM_KEY: { + "kind": "start", + "tool_call_id": "r1", + "tool_call_name": RENDER_A2UI_TOOL_NAME, + } + } + } + }, + { + "tool_stream_event": { + "data": {A2UI_STREAM_KEY: {"kind": "args", "tool_call_id": "r1", "delta": '{"surfaceId":'}} + } + }, + { + "tool_stream_event": { + "data": {A2UI_STREAM_KEY: {"kind": "args", "tool_call_id": "r1", "delta": '"s1"}'}} + } + }, + { + "tool_stream_event": { + "data": {A2UI_STREAM_KEY: {"kind": "end", "tool_call_id": "r1"}} + } + }, + ] + agent = _build_agent("thread-1", events) + out = await _collect(agent, _msg_input()) + + starts = [ + e + for e in out + if e.type == EventType.TOOL_CALL_START + and getattr(e, "tool_call_name", None) == RENDER_A2UI_TOOL_NAME + ] + assert len(starts) == 1 + assert starts[0].tool_call_id == "r1" + + deltas = [ + getattr(e, "delta", "") + for e in out + if e.type == EventType.TOOL_CALL_ARGS and getattr(e, "tool_call_id", None) == "r1" + ] + assert "".join(deltas) == '{"surfaceId":"s1"}' + + assert any( + e.type == EventType.TOOL_CALL_END and getattr(e, "tool_call_id", None) == "r1" + for e in out + ) + + +# --------------------------------------------------------------------------- +# _GenerateA2UITool.stream() — the REAL executor + queue drain path +# --------------------------------------------------------------------------- + + +def _tool_use(args=None): + return {"name": GENERATE_A2UI_TOOL_NAME, "toolUseId": "tu-1", "input": args or {}} + + +async def _drive_stream(tool, invocation_state=None): + events = [] + async for ev in tool.stream(_tool_use(), invocation_state or {}): + events.append(ev) + return events + + +@pytest.mark.asyncio +async def test_stream_drains_all_pushed_events_through_executor(monkeypatch): + """Drives the real worker-thread + queue drain path (not the mocked + adapter loop): every pushed payload — including the terminal `end` pushed + just before the recovery future resolves — must reach the wire, and the + final ToolResultEvent must carry the envelope.""" + import ag_ui_strands.a2ui_tool as mod + + async def fake_subagent(model, prompt, messages, push, **kwargs): + push({"kind": "start", "tool_call_id": "r1", "tool_call_name": "render_a2ui"}) + for i in range(5): + push({"kind": "args", "tool_call_id": "r1", "delta": f"chunk{i}"}) + push({"kind": "end", "tool_call_id": "r1"}) + return {"surfaceId": "s1", "components": [{"id": "root", "component": "Row"}]} + + monkeypatch.setattr(mod, "_stream_render_subagent", fake_subagent) + tool = get_a2ui_tools({"model": STUB_MODEL}) + events = await _drive_stream(tool) + + payloads = [ + ev["tool_stream_event"]["data"][A2UI_STREAM_KEY] + for ev in events + if isinstance(ev, dict) and "tool_stream_event" in ev + ] + kinds = [p["kind"] for p in payloads] + assert kinds[0] == "start" + assert kinds.count("args") == 5 + assert kinds[-1] == "end", "terminal end push must not be dropped" + + # Final event is the ToolResultEvent wrapper; its text carries the envelope. + text = str(events[-1]) + assert A2UI_OPS_KEY in text + + +@pytest.mark.asyncio +async def test_stream_update_intent_without_prior_returns_error_envelope(monkeypatch): + """intent='update' with an unknown surface short-circuits to an error + envelope (no recovery loop, no sub-agent call).""" + import ag_ui_strands.a2ui_tool as mod + + async def fail_subagent(*a, **k): # pragma: no cover — must not be called + raise AssertionError("sub-agent must not run on prep error") + + monkeypatch.setattr(mod, "_stream_render_subagent", fail_subagent) + tool = get_a2ui_tools({"model": STUB_MODEL}) + events = [] + async for ev in tool.stream( + { + "name": GENERATE_A2UI_TOOL_NAME, + "toolUseId": "tu-2", + "input": {"intent": "update", "target_surface_id": "nope"}, + }, + {}, + ): + events.append(ev) + text = str(events[-1]) + assert "error" in text + assert A2UI_OPS_KEY not in text + + +@pytest.mark.asyncio +async def test_stream_recoverable_subagent_error_yields_hard_failure(monkeypatch): + """A recoverable sub-agent error per attempt exhausts the recovery loop and + yields the structured hard-failure envelope — never a crash.""" + import ag_ui_strands.a2ui_tool as mod + + async def boom(model, prompt, messages, push, **kwargs): + raise RuntimeError("model 429") + + monkeypatch.setattr(mod, "_stream_render_subagent", boom) + tool = get_a2ui_tools({"model": STUB_MODEL}) + events = await _drive_stream(tool) + text = str(events[-1]) + assert "a2ui_recovery_exhausted" in text + + +@pytest.mark.asyncio +async def test_stream_programmer_error_propagates(monkeypatch): + """TypeError from the sub-agent path is an adapter bug — it must unwind, + not masquerade as a failed attempt.""" + import ag_ui_strands.a2ui_tool as mod + + async def bug(model, prompt, messages, push, **kwargs): + raise TypeError("adapter bug") + + monkeypatch.setattr(mod, "_stream_render_subagent", bug) + tool = get_a2ui_tools({"model": STUB_MODEL}) + with pytest.raises(TypeError): + await _drive_stream(tool) + + +# --------------------------------------------------------------------------- +# _stream_render_subagent — the REAL streaming translation (faked Agent) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_render_subagent_streams_raw_arg_growth_as_deltas(monkeypatch): + """Direct coverage of ``_stream_render_subagent`` (OpenAI-chat provider + shape): the growing ``current_tool_use.input`` string must become start + + incremental args deltas + end, all under the live toolUseId.""" + import ag_ui_strands.a2ui_tool as mod + + class FakeAgent: + def __init__(self, **kwargs): + self._tools = kwargs.get("tools") or [] + + async def stream_async(self, _msg): + yield { + "current_tool_use": { + "name": RENDER_A2UI_TOOL_NAME, + "toolUseId": "r1", + "input": '{"surf', + } + } + yield {"unrelated_event": True} + yield { + "current_tool_use": { + "name": RENDER_A2UI_TOOL_NAME, + "toolUseId": "r1", + "input": '{"surfaceId": "s1"}', + } + } + + monkeypatch.setattr(mod, "Agent", FakeAgent) + pushed = [] + captured = await mod._stream_render_subagent(STUB_MODEL, "prompt", [], pushed.append) + + kinds = [p["kind"] for p in pushed] + assert kinds == ["start", "args", "args", "end"] + assert ( + "".join(p["delta"] for p in pushed if p["kind"] == "args") + == '{"surfaceId": "s1"}' + ) + assert all(p["tool_call_id"] == "r1" for p in pushed) + # The fake never invoked the render tool: no captured args -> the recovery + # loop records a no-call attempt. + assert captured is None + + +@pytest.mark.asyncio +async def test_render_subagent_dict_input_falls_back_to_single_delta(monkeypatch): + """Direct coverage of the parsed-dict provider shape (Anthropic/Gemini + deliver ``input`` as a dict with no raw string growth): the captured args + must be emitted as ONE args delta before ``end`` so the middleware still + sees the components before the result.""" + import ag_ui_strands.a2ui_tool as mod + + args = {"surfaceId": "s1", "components": [{"id": "root", "component": "Row"}]} + + class FakeAgent: + def __init__(self, **kwargs): + self._tools = kwargs.get("tools") or [] + + async def stream_async(self, _msg): + yield { + "current_tool_use": { + "name": RENDER_A2UI_TOOL_NAME, + "toolUseId": "r1", + "input": dict(args), + } + } + # The model "invokes" the bound render tool, which captures args. + async for _ in self._tools[0].stream( + {"name": RENDER_A2UI_TOOL_NAME, "toolUseId": "r1", "input": dict(args)}, + {}, + ): + pass + + monkeypatch.setattr(mod, "Agent", FakeAgent) + pushed = [] + captured = await mod._stream_render_subagent(STUB_MODEL, "prompt", [], pushed.append) + + kinds = [p["kind"] for p in pushed] + assert kinds == ["start", "args", "end"] + assert json.loads(pushed[1]["delta"]) == args + assert captured == args + + +@pytest.mark.asyncio +async def test_render_subagent_stamps_catalog_id_into_streamed_args(monkeypatch): + """The host catalog id is spliced into the FIRST streamed chunk (after the + opening brace) so the middleware's progressive paint binds to the real + catalog instead of falling back to basic. The model never emits catalogId.""" + import ag_ui_strands.a2ui_tool as mod + + class FakeAgent: + def __init__(self, **kwargs): + pass + + async def stream_async(self, _msg): + yield { + "current_tool_use": { + "name": RENDER_A2UI_TOOL_NAME, + "toolUseId": "r1", + "input": '{"surf', + } + } + yield { + "current_tool_use": { + "name": RENDER_A2UI_TOOL_NAME, + "toolUseId": "r1", + "input": '{"surfaceId": "s1"}', + } + } + + monkeypatch.setattr(mod, "Agent", FakeAgent) + pushed = [] + await mod._stream_render_subagent( + STUB_MODEL, "prompt", [], pushed.append, catalog_id="my-cat" + ) + args_str = "".join(p["delta"] for p in pushed if p["kind"] == "args") + # Accumulated args are valid JSON carrying the stamped id. + assert json.loads(args_str) == {"catalogId": "my-cat", "surfaceId": "s1"} + + +@pytest.mark.asyncio +async def test_render_subagent_stamps_catalog_id_in_dict_fallback(monkeypatch): + """The parsed-dict provider shape also gets the catalog id merged into the + single emitted delta (host id wins over anything the model put there).""" + import ag_ui_strands.a2ui_tool as mod + + args = {"surfaceId": "s1", "components": [{"id": "root", "component": "Row"}]} + + class FakeAgent: + def __init__(self, **kwargs): + self._tools = kwargs.get("tools") or [] + + async def stream_async(self, _msg): + yield { + "current_tool_use": { + "name": RENDER_A2UI_TOOL_NAME, + "toolUseId": "r1", + "input": dict(args), + } + } + async for _ in self._tools[0].stream( + {"name": RENDER_A2UI_TOOL_NAME, "toolUseId": "r1", "input": dict(args)}, + {}, + ): + pass + + monkeypatch.setattr(mod, "Agent", FakeAgent) + pushed = [] + await mod._stream_render_subagent( + STUB_MODEL, "prompt", [], pushed.append, catalog_id="my-cat" + ) + delta = json.loads(pushed[1]["delta"]) + assert delta["catalogId"] == "my-cat" + assert delta["surfaceId"] == "s1" + + +@pytest.mark.asyncio +async def test_auto_inject_failure_never_crashes_run(monkeypatch): + """The auto-inject hook is best-effort by contract: a planner bug must log and + leave the turn running without A2UI — never escape after RUN_STARTED.""" + import ag_ui_strands.agent as agent_mod + + def boom(**_kwargs): + raise RuntimeError("planner exploded") + + monkeypatch.setattr(agent_mod, "plan_a2ui_injection", boom) + agent = _build_agent("thread-1", []) + out = await _collect( + agent, + _msg_input(forwarded_props={"injectA2UITool": True}, tools=[RENDER_TOOL_INPUT]), + ) + types = [e.type for e in out] + assert EventType.RUN_STARTED in types + assert EventType.RUN_FINISHED in types + assert EventType.RUN_ERROR not in types + + +def test_classify_rethrows_non_exception_base_exceptions(): + """SystemExit/KeyboardInterrupt signal shutdown — the recovery loop must + not retry through them.""" + assert classify_a2ui_subagent_error(SystemExit(), False) == "rethrow" + assert classify_a2ui_subagent_error(KeyboardInterrupt(), False) == "rethrow" + # Genuine model/network errors remain recoverable. + assert classify_a2ui_subagent_error(RuntimeError("429"), False) == "recoverable" + + +def test_explicit_runtime_false_disables_backend_override(): + """Nullish (not falsy) fallback, mirroring the TS adapter's `??`: a runtime + that explicitly forwards injectA2UITool=False wins over a backend opt-in.""" + plan = plan_a2ui_injection( + model=STUB_MODEL, + input=_input(forwarded_props={"injectA2UITool": False}), + existing_tool_names=[], + config={"inject_a2ui_tool": True}, + ) + assert plan is None + + +@pytest.mark.asyncio +async def test_stream_update_intent_reuses_prior_surface(monkeypatch): + """The auto-inject glue's purpose: `intent:"update"` resolves the prior surface + from glue agui_messages and the envelope reconciles in place — no + createSurface op (v0.9 forbids re-creating an existing surface id).""" + import ag_ui_strands.a2ui_tool as mod + + prior_envelope = json.dumps( + { + A2UI_OPS_KEY: [ + { + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://example.com/cat.json", + } + }, + { + "updateComponents": { + "surfaceId": "s1", + "components": [{"id": "root", "component": "Row"}], + } + }, + ] + } + ) + + async def fake_subagent(model, prompt, messages, push, **kwargs): + return {"components": [{"id": "root", "component": "Column"}], "data": {}} + + monkeypatch.setattr(mod, "_stream_render_subagent", fake_subagent) + tool = get_a2ui_tools( + {"model": STUB_MODEL}, + glue={"agui_messages": [{"role": "tool", "content": prior_envelope}]}, + ) + events = [] + async for ev in tool.stream( + { + "name": GENERATE_A2UI_TOOL_NAME, + "toolUseId": "tu-up", + "input": {"intent": "update", "target_surface_id": "s1"}, + }, + {}, + ): + events.append(ev) + + text = str(events[-1]) + assert A2UI_OPS_KEY in text + assert "updateComponents" in text + assert "createSurface" not in text + assert '\\"surfaceId\\": \\"s1\\"' in text or '"surfaceId": "s1"' in text + + +@pytest.mark.asyncio +async def test_stream_abandonment_stops_further_recovery_attempts( + monkeypatch, caplog +): + """Closing the stream mid-run (client disconnect) sets the disconnect + flag: the recovery loop must not fire further sub-agent attempts for a + consumer that's gone — and the intentional abort must not be logged as a + recovery failure.""" + import threading as _threading + + import ag_ui_strands.a2ui_tool as mod + + attempts: list[int] = [] + gate = _threading.Event() + + async def fake_subagent(model, prompt, messages, push, **kwargs): + attempts.append(1) + push( + { + "kind": "start", + "tool_call_id": f"r{len(attempts)}", + "tool_call_name": RENDER_A2UI_TOOL_NAME, + } + ) + gate.wait(timeout=5) # hold the attempt open until the test closes + return None # "no tool call" -> the loop would normally retry + + monkeypatch.setattr(mod, "_stream_render_subagent", fake_subagent) + tool = get_a2ui_tools({"model": STUB_MODEL}) + + agen = tool.stream(_tool_use(), {}) + await agen.__anext__() # first pushed event reached the wire + await agen.aclose() # consumer disconnects mid-drain + gate.set() # let attempt 1 finish in the worker + + # Give the executor time to (wrongly) start attempt 2 if the disconnect + # flag were broken. + await asyncio.sleep(0.4) + assert len(attempts) == 1, "no further attempts after consumer disconnect" + # The deliberate between-attempt CancelledError lands on the future as a + # stored exception (FINISHED, not CANCELLED) — the abandoned-result + # consumer must recognize it as intentional, not warn about it. + # (caplog captures at level 0 by default; the explicit filter below keys + # off the message, so no at_level scoping is needed.) + assert not [ + r for r in caplog.records if "A2UI recovery loop failed" in r.getMessage() + ], "intentional disconnect abort must not be logged as a failure" + + +@pytest.mark.asyncio +async def test_no_flag_turn_removes_stale_auto_injected_tool(): + """Turn N+1 WITHOUT the runtime flag must remove turn N's auto-injected + generate_a2ui (the sweep runs regardless of whether a new plan injects).""" + agent = _build_agent("thread-1", []) + registry = agent._agents_by_thread["thread-1"].tool_registry + + await _collect( + agent, + _msg_input(forwarded_props={"injectA2UITool": True}, tools=[RENDER_TOOL_INPUT]), + ) + assert GENERATE_A2UI_TOOL_NAME in registry.registry + + # Flag gone on the next turn: our marked tool must not linger. + await _collect(agent, _msg_input(forwarded_props={}, tools=[])) + assert GENERATE_A2UI_TOOL_NAME not in registry.registry + + +@pytest.mark.asyncio +async def test_stream_update_intent_with_pydantic_glue_messages(monkeypatch): + """Auto-injection passes pydantic message objects (not dicts) as glue — the prior + surface must still resolve. Locks the object-shape contract against a + dict-only toolkit refactor.""" + from ag_ui.core import ToolMessage + + import ag_ui_strands.a2ui_tool as mod + + prior_envelope = json.dumps( + { + A2UI_OPS_KEY: [ + {"createSurface": {"surfaceId": "s1", "catalogId": "cat-1"}}, + { + "updateComponents": { + "surfaceId": "s1", + "components": [{"id": "root", "component": "Row"}], + } + }, + ] + } + ) + + async def fake_subagent(model, prompt, messages, push, **kwargs): + return {"components": [{"id": "root", "component": "Column"}], "data": {}} + + monkeypatch.setattr(mod, "_stream_render_subagent", fake_subagent) + tool = get_a2ui_tools( + {"model": STUB_MODEL}, + glue={ + "agui_messages": [ + ToolMessage( + id="t1", role="tool", content=prior_envelope, tool_call_id="tc1" + ) + ] + }, + ) + events = [] + async for ev in tool.stream( + { + "name": GENERATE_A2UI_TOOL_NAME, + "toolUseId": "tu-up2", + "input": {"intent": "update", "target_surface_id": "s1"}, + }, + {}, + ): + events.append(ev) + + text = str(events[-1]) + assert "updateComponents" in text + assert "createSurface" not in text + + +def test_get_a2ui_tools_requires_model(): + """Explicit wiring without a model would silently bind Strands' default Bedrock + model — fail loud instead (the TS factory enforces this in the types).""" + with pytest.raises(ValueError, match="model"): + get_a2ui_tools({}) + + +@pytest.mark.asyncio +async def test_render_subagent_zero_frames_synthesizes_triplet(monkeypatch): + """A provider that invokes the bound render tool without emitting any + current_tool_use frames must still produce start/args/end so the + middleware paints before the result (no bulk paint).""" + import ag_ui_strands.a2ui_tool as mod + + args = {"surfaceId": "s1", "components": [{"id": "root", "component": "Row"}]} + + class FakeAgent: + def __init__(self, **kwargs): + self._tools = kwargs.get("tools") or [] + + async def stream_async(self, _msg): + # No current_tool_use frames at all — only the tool invocation. + async for _ in self._tools[0].stream( + {"name": RENDER_A2UI_TOOL_NAME, "toolUseId": "r1", "input": dict(args)}, + {}, + ): + pass + if False: # pragma: no cover — make this an async generator + yield None + + monkeypatch.setattr(mod, "Agent", FakeAgent) + pushed = [] + captured = await mod._stream_render_subagent(STUB_MODEL, "prompt", [], pushed.append) + + kinds = [p["kind"] for p in pushed] + assert kinds == ["start", "args", "end"] + assert json.loads(pushed[1]["delta"]) == args + assert captured == args + + +@pytest.mark.asyncio +async def test_render_subagent_midstream_error_closes_live_call(monkeypatch): + """A provider stream dying mid-call (429, network drop) must close the + live synthetic call — an unclosed inner TOOL_CALL_START is a wire-protocol + violation and the next recovery attempt would open a fresh call on top.""" + import ag_ui_strands.a2ui_tool as mod + + class FakeAgent: + def __init__(self, **kwargs): + pass + + async def stream_async(self, _msg): + yield { + "current_tool_use": { + "name": RENDER_A2UI_TOOL_NAME, + "toolUseId": "r1", + "input": '{"surf', + } + } + raise RuntimeError("model 429") + + monkeypatch.setattr(mod, "Agent", FakeAgent) + pushed = [] + with pytest.raises(RuntimeError): + await mod._stream_render_subagent(STUB_MODEL, "prompt", [], pushed.append) + + kinds = [p["kind"] for p in pushed] + assert kinds == ["start", "args", "end"] + assert pushed[-1]["tool_call_id"] == "r1" + + +@pytest.mark.asyncio +async def test_render_subagent_second_call_id_closes_first(monkeypatch): + """A second render call with a distinct real toolUseId must close the + first call and reset the delta accumulator (no cross-call mis-attribution).""" + import ag_ui_strands.a2ui_tool as mod + + class FakeAgent: + def __init__(self, **kwargs): + pass + + async def stream_async(self, _msg): + yield { + "current_tool_use": { + "name": RENDER_A2UI_TOOL_NAME, + "toolUseId": "r1", + "input": '{"a": 1}', + } + } + yield { + "current_tool_use": { + "name": RENDER_A2UI_TOOL_NAME, + "toolUseId": "r2", + "input": '{"b', + } + } + + monkeypatch.setattr(mod, "Agent", FakeAgent) + pushed = [] + await mod._stream_render_subagent(STUB_MODEL, "prompt", [], pushed.append) + + assert [(p["kind"], p["tool_call_id"]) for p in pushed] == [ + ("start", "r1"), + ("args", "r1"), + ("end", "r1"), + ("start", "r2"), + ("args", "r2"), + ("end", "r2"), + ] + # Delta accumulator reset: r2's delta is its full prefix, not a slice + # against r1's length. + assert pushed[4]["delta"] == '{"b' + + +@pytest.mark.asyncio +async def test_stream_non_dict_glue_state_degrades(monkeypatch): + """A truthy non-dict glue state must degrade to empty state — generation + proceeds rather than crashing before the recovery loop engages.""" + import ag_ui_strands.a2ui_tool as mod + + async def fake_subagent(model, prompt, messages, push, **kwargs): + return {"components": [{"id": "root", "component": "Row"}], "data": {}} + + monkeypatch.setattr(mod, "_stream_render_subagent", fake_subagent) + tool = get_a2ui_tools( + {"model": STUB_MODEL}, glue={"state": "not-a-dict", "agui_messages": []} + ) + events = await _drive_stream(tool) + assert A2UI_OPS_KEY in str(events[-1]) + + +def test_snake_case_recovery_key_warns(caplog): + """snake_case recovery keys are silently ignored by the camelCase toolkit + contract — the factory must leave a breadcrumb.""" + import logging + + with caplog.at_level(logging.WARNING, logger="ag_ui_strands"): + get_a2ui_tools({"model": STUB_MODEL, "recovery": {"max_attempts": 5}}) + assert any("max_attempts" in r.getMessage() for r in caplog.records) + + +@pytest.mark.asyncio +async def test_stream_update_intent_finds_same_run_surface(monkeypatch): + """The auto-inject glue snapshots run-start history — a surface created EARLIER + IN THIS SAME RUN exists only in live Strands history. The glue+derived + merge must resolve it (a create-then-update turn must not error for a + surface visibly on screen).""" + import ag_ui_strands.a2ui_tool as mod + + prior_envelope = json.dumps( + { + A2UI_OPS_KEY: [ + {"createSurface": {"surfaceId": "s1", "catalogId": "c"}}, + { + "updateComponents": { + "surfaceId": "s1", + "components": [{"id": "root", "component": "Row"}], + } + }, + ] + } + ) + + async def fake_subagent(model, prompt, messages, push, **kwargs): + return {"components": [{"id": "root", "component": "Column"}], "data": {}} + + monkeypatch.setattr(mod, "_stream_render_subagent", fake_subagent) + # Glue present but EMPTY (run-start snapshot has no envelope); the + # prior surface lives only in the calling agent's live message history. + tool = get_a2ui_tools({"model": STUB_MODEL}, glue={"agui_messages": []}) + live_agent = MagicMock() + live_agent.messages = [ + { + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": "t1", + "status": "success", + "content": [{"text": prior_envelope}], + } + } + ], + } + ] + events = [] + async for ev in tool.stream( + { + "name": GENERATE_A2UI_TOOL_NAME, + "toolUseId": "tu-sr", + "input": {"intent": "update", "target_surface_id": "s1"}, + }, + {"agent": live_agent}, + ): + events.append(ev) + + text = str(events[-1]) + assert "updateComponents" in text + assert "createSurface" not in text diff --git a/integrations/aws-strands/python/tests/test_session_manager.py b/integrations/aws-strands/python/tests/test_session_manager.py index 3becba1121..8187d14bca 100644 --- a/integrations/aws-strands/python/tests/test_session_manager.py +++ b/integrations/aws-strands/python/tests/test_session_manager.py @@ -7,7 +7,13 @@ import pytest from strands.session import SessionManager -from ag_ui.core import EventType, RunAgentInput +from ag_ui.core import ( + EventType, + RunAgentInput, + Tool, + ToolMessage, + UserMessage, +) from ag_ui_strands.agent import StrandsAgent from ag_ui_strands.config import StrandsAgentConfig @@ -21,12 +27,16 @@ def _mock_session_manager() -> MagicMock: # Helpers # --------------------------------------------------------------------------- -def _make_run_input(thread_id: str | None = "thread-1", run_id: str = "run-1") -> RunAgentInput: +def _make_run_input( + thread_id: str | None = "thread-1", + run_id: str = "run-1", + messages=None, +) -> RunAgentInput: return RunAgentInput( thread_id=thread_id, run_id=run_id, state={}, - messages=[], + messages=messages or [], tools=[], context=[], forwarded_props={}, @@ -67,6 +77,19 @@ def _make_mock_instance(): return instance +class _MockStrandsAgentWithPrivateSessionManager: + def __init__(self, session_manager): + self._session_manager = session_manager + self.tool_registry = MagicMock() + self.tool_registry.registry = {} + self.stream_prompts = [] + + async def stream_async(self, prompt): + self.stream_prompts.append(prompt) + return + yield # pragma: no cover + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -236,3 +259,132 @@ async def test_provider_returns_none_logs_warning(self, caplog): event_types = [e.type for e in events] assert EventType.RUN_FINISHED in event_types assert any("returned None" in msg for msg in caplog.messages) + + @pytest.mark.asyncio + async def test_private_session_manager_disables_replay_history(self): + mock_session_manager = _mock_session_manager() + provider = MagicMock(return_value=mock_session_manager) + agent = _make_base_agent(session_manager_provider=provider) + input_data = _make_run_input( + messages=[UserMessage(id="u1", content="hello from user")] + ) + + instance = _MockStrandsAgentWithPrivateSessionManager(mock_session_manager) + with patch("ag_ui_strands.agent.StrandsAgentCore") as MockCore: + MockCore.return_value = instance + await _collect_events(agent, input_data) + + assert instance.stream_prompts == ["hello from user"] + assert not hasattr(instance, "messages") + + +class _MockSessionAgentWithHistory: + """Session-manager-backed mock that records ``stream_async`` prompts and + exposes a native Strands ``messages`` history (as a real session manager + would). ``replay_history_into_strands`` is suppressed when a session + manager is present, so this exercises the legacy + ``stream_async(user_message)`` path.""" + + def __init__(self, session_manager, messages=None): + self._session_manager = session_manager + self.messages = messages if messages is not None else [] + self.tool_registry = MagicMock() + self.tool_registry.registry = {} + self.stream_prompts = [] + + async def stream_async(self, prompt): + self.stream_prompts.append(prompt) + return + yield # pragma: no cover + + +def _delta_continuation_input(tools): + """A delta-only continuation payload: just the trailing ``tool`` result, + with NO preceding assistant message carrying ``tool_calls`` (mirrors what + CopilotKit sends after a void-handler frontend tool resolves).""" + return RunAgentInput( + thread_id="thread-delta", + run_id="run-2", + state={}, + messages=[ + ToolMessage(id="t1", role="tool", content="", tool_call_id="call-xyz"), + ], + tools=tools, + context=[], + forwarded_props={}, + ) + + +def _frontend_tool(name: str) -> Tool: + return Tool(name=name, description=f"{name} tool", parameters={}) + + +class TestFrontendToolContinuation: + """Regression tests for the 'Hello' injection on delta-only frontend-tool + continuation runs (PR #1761).""" + + @pytest.mark.asyncio + async def test_delta_only_continuation_does_not_inject_hello(self): + """Session-manager path + delta-only trailing tool message + missing + assistant tool_calls: ``stream_async`` must NOT receive ``"Hello"``, + and must not guess an arbitrary frontend tool when several exist.""" + mock_session_manager = _mock_session_manager() + provider = MagicMock(return_value=mock_session_manager) + agent = _make_base_agent(session_manager_provider=provider) + + # Multiple frontend tools — the old code would arbitrarily pick one. + tools = [_frontend_tool("setBackground"), _frontend_tool("setForeground")] + input_data = _delta_continuation_input(tools) + + # No session history that resolves call-xyz → name is unresolvable. + instance = _MockSessionAgentWithHistory(mock_session_manager, messages=[]) + with patch("ag_ui_strands.agent.StrandsAgentCore") as MockCore: + MockCore.return_value = instance + await _collect_events(agent, input_data) + + assert instance.stream_prompts == [""] + assert "Hello" not in instance.stream_prompts + # No arbitrary frontend tool name leaked into the prompt. + assert not any( + "executed successfully" in (p or "") for p in instance.stream_prompts + ) + + @pytest.mark.asyncio + async def test_delta_only_continuation_resolves_name_from_session_history(self): + """When the assistant ``tool_calls`` message is absent from the delta + payload but present in the session's native history, the correct tool + name is recovered (not an arbitrary one).""" + mock_session_manager = _mock_session_manager() + provider = MagicMock(return_value=mock_session_manager) + agent = _make_base_agent(session_manager_provider=provider) + + tools = [_frontend_tool("setBackground"), _frontend_tool("setForeground")] + input_data = _delta_continuation_input(tools) + + # Native Strands history holds the toolUse that owns call-xyz. + session_history = [ + {"role": "user", "content": [{"text": "make it blue"}]}, + { + "role": "assistant", + "content": [ + { + "toolUse": { + "toolUseId": "call-xyz", + "name": "setBackground", + "input": {"color": "blue"}, + } + } + ], + }, + ] + instance = _MockSessionAgentWithHistory( + mock_session_manager, messages=session_history + ) + with patch("ag_ui_strands.agent.StrandsAgentCore") as MockCore: + MockCore.return_value = instance + await _collect_events(agent, input_data) + + assert instance.stream_prompts == [ + "setBackground executed successfully with no return value." + ] + assert "Hello" not in instance.stream_prompts diff --git a/integrations/aws-strands/python/tests/test_tool_call_parent_message_id.py b/integrations/aws-strands/python/tests/test_tool_call_parent_message_id.py new file mode 100644 index 0000000000..e949095749 --- /dev/null +++ b/integrations/aws-strands/python/tests/test_tool_call_parent_message_id.py @@ -0,0 +1,246 @@ +"""Tests for ``ToolCallStartEvent.parent_message_id`` in Strands. + +Issue #1610 originally found a phantom parent id in streams without a visible +tool-call assistant message. Current main emits ``MessagesSnapshotEvent`` by +default, so the tool-call assistant id is visible through the snapshot and must +stay aligned with #1638's snapshot contract. These tests pin both modes. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from ag_ui.core import AssistantMessage, EventType, RunAgentInput, Tool, UserMessage +from strands.tools.registry import ToolRegistry + +from ag_ui_strands.agent import StrandsAgent +from ag_ui_strands.config import StrandsAgentConfig, ToolBehavior + + +def _template_agent() -> MagicMock: + mock = MagicMock() + mock.model = MagicMock() + mock.system_prompt = "You are helpful" + mock.tool_registry.registry = {} + mock.record_direct_tool_call = True + return mock + + +def _build_agent( + thread_id: str, + stream_events: list, + config: StrandsAgentConfig | None = None, +) -> StrandsAgent: + agent = StrandsAgent( + _template_agent(), + name="test-agent", + config=config or StrandsAgentConfig(), + ) + mock_inner = MagicMock() + mock_inner.tool_registry = ToolRegistry() + mock_inner.session_manager = None + + async def _stream(_msg): + for event in stream_events: + yield event + + mock_inner.stream_async = _stream + agent._agents_by_thread[thread_id] = mock_inner + return agent + + +def _run_input(thread_id: str, tools: list | None = None) -> RunAgentInput: + return RunAgentInput( + thread_id=thread_id, + run_id="r1", + state={}, + messages=[UserMessage(id="u1", content="hello")], + tools=tools or [], + context=[], + forwarded_props={}, + ) + + +async def _collect(agent: StrandsAgent, inp: RunAgentInput) -> list: + return [e async for e in agent.run(inp)] + + +def _tool_start(events: list, tool_call_id: str | None = None): + return next( + e + for e in events + if e.type == EventType.TOOL_CALL_START + and (tool_call_id is None or e.tool_call_id == tool_call_id) + ) + + +def _snapshot_assistant_id_for_tool(events: list, tool_call_id: str) -> str: + snapshots = [e for e in events if e.type == EventType.MESSAGES_SNAPSHOT] + assert snapshots, "expected a MessagesSnapshotEvent" + + for message in snapshots[-1].messages: + if isinstance(message, AssistantMessage) and message.tool_calls: + if any(tool_call.id == tool_call_id for tool_call in message.tool_calls): + return message.id + + raise AssertionError(f"tool call {tool_call_id!r} missing from final snapshot") + + +async def _args_streamer(context): + yield context.args_str + + +THREAD = "parent-msg-id-thread" +TOOLS = [Tool(name="frontend_tool", description="d", parameters={})] +STREAM_TEXT_THEN_TOOL = [ + {"data": "Let me check those tables:"}, + { + "current_tool_use": { + "name": "frontend_tool", + "toolUseId": "st-1", + "input": {"ok": True}, + } + }, + {"event": {"contentBlockStop": {}}}, +] + + +async def test_default_parent_id_matches_tool_call_snapshot_message_id(): + agent = _build_agent(THREAD + "-default", STREAM_TEXT_THEN_TOOL) + events = await _collect(agent, _run_input(THREAD + "-default", tools=TOOLS)) + + text_end = next(e for e in events if e.type == EventType.TEXT_MESSAGE_END) + tool_start = _tool_start(events) + snapshot_id = _snapshot_assistant_id_for_tool(events, tool_start.tool_call_id) + + assert tool_start.parent_message_id == snapshot_id + assert tool_start.parent_message_id != text_end.message_id + + +async def test_default_args_streamer_parent_id_matches_snapshot_message_id(): + config = StrandsAgentConfig( + tool_behaviors={ + "frontend_tool": ToolBehavior(args_streamer=_args_streamer), + }, + ) + agent = _build_agent(THREAD + "-args-default", STREAM_TEXT_THEN_TOOL, config) + events = await _collect( + agent, + _run_input(THREAD + "-args-default", tools=TOOLS), + ) + + tool_start = _tool_start(events) + snapshot_id = _snapshot_assistant_id_for_tool(events, tool_start.tool_call_id) + + assert tool_start.parent_message_id == snapshot_id + + +async def test_snapshot_disabled_parent_id_uses_preceding_text_message(): + config = StrandsAgentConfig(emit_messages_snapshot=False) + agent = _build_agent(THREAD + "-disabled", STREAM_TEXT_THEN_TOOL, config) + events = await _collect(agent, _run_input(THREAD + "-disabled", tools=TOOLS)) + + text_end = next(e for e in events if e.type == EventType.TEXT_MESSAGE_END) + tool_start = _tool_start(events) + + assert [e for e in events if e.type == EventType.MESSAGES_SNAPSHOT] == [] + assert tool_start.parent_message_id == text_end.message_id + + +async def test_snapshot_disabled_tool_first_call_has_no_parent_id(): + config = StrandsAgentConfig(emit_messages_snapshot=False) + stream = [ + { + "current_tool_use": { + "name": "frontend_tool", + "toolUseId": "st-1", + "input": {}, + } + }, + {"event": {"contentBlockStop": {}}}, + ] + agent = _build_agent(THREAD + "-tool-first", stream, config) + events = await _collect(agent, _run_input(THREAD + "-tool-first", tools=TOOLS)) + + assert [e for e in events if e.type == EventType.TEXT_MESSAGE_START] == [] + assert _tool_start(events).parent_message_id is None + + +async def test_snapshot_disabled_back_to_back_tool_calls_share_text_parent(): + config = StrandsAgentConfig(emit_messages_snapshot=False) + stream = [ + {"data": "Calling two tools:"}, + { + "current_tool_use": { + "name": "backend_tool", + "toolUseId": "st-a", + "input": {}, + } + }, + {"event": {"contentBlockStop": {}}}, + { + "current_tool_use": { + "name": "backend_tool", + "toolUseId": "st-b", + "input": {}, + } + }, + {"event": {"contentBlockStop": {}}}, + ] + agent = _build_agent(THREAD + "-back-to-back", stream, config) + events = await _collect(agent, _run_input(THREAD + "-back-to-back")) + + text_end = next(e for e in events if e.type == EventType.TEXT_MESSAGE_END) + tool_starts = [e for e in events if e.type == EventType.TOOL_CALL_START] + + assert len(tool_starts) == 2 + assert [e.parent_message_id for e in tool_starts] == [ + text_end.message_id, + text_end.message_id, + ] + + +async def test_snapshot_disabled_args_streamer_uses_preceding_text_parent(): + config = StrandsAgentConfig( + emit_messages_snapshot=False, + tool_behaviors={ + "frontend_tool": ToolBehavior(args_streamer=_args_streamer), + }, + ) + agent = _build_agent(THREAD + "-args-disabled", STREAM_TEXT_THEN_TOOL, config) + events = await _collect( + agent, + _run_input(THREAD + "-args-disabled", tools=TOOLS), + ) + + text_end = next(e for e in events if e.type == EventType.TEXT_MESSAGE_END) + tool_start = _tool_start(events) + + assert [e for e in events if e.type == EventType.MESSAGES_SNAPSHOT] == [] + assert tool_start.parent_message_id == text_end.message_id + + +async def test_skip_messages_snapshot_uses_visible_text_parent(): + config = StrandsAgentConfig( + tool_behaviors={ + "frontend_tool": ToolBehavior(skip_messages_snapshot=True), + }, + ) + agent = _build_agent(THREAD + "-skip", STREAM_TEXT_THEN_TOOL, config) + events = await _collect(agent, _run_input(THREAD + "-skip", tools=TOOLS)) + + text_end = next(e for e in events if e.type == EventType.TEXT_MESSAGE_END) + tool_start = _tool_start(events) + snapshots = [e for e in events if e.type == EventType.MESSAGES_SNAPSHOT] + + assert tool_start.parent_message_id == text_end.message_id + assert snapshots + for message in snapshots[-1].messages: + assert not ( + isinstance(message, AssistantMessage) + and message.tool_calls + and any( + tool_call.id == tool_start.tool_call_id + for tool_call in message.tool_calls + ) + ) diff --git a/integrations/aws-strands/python/uv.lock b/integrations/aws-strands/python/uv.lock index 80ec192e4a..c6e03ff5d6 100644 --- a/integrations/aws-strands/python/uv.lock +++ b/integrations/aws-strands/python/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12, <3.14" +[[package]] +name = "ag-ui-a2ui-toolkit" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b1/ea7ad7f0b3d1b20388d072ffbe4416577b4d4ab5471d45dfc04791a91602/ag_ui_a2ui_toolkit-0.0.3.tar.gz", hash = "sha256:468f25473ac00d098878da54c0069b7fa27dc63b4c1ff61315d4349a324c2fb7", size = 14785, upload-time = "2026-06-09T06:18:18.163Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/75/fc87bdf81bb1bf6d0fac09179e8bb17807d1bc5b3c0e8640f32e843b0857/ag_ui_a2ui_toolkit-0.0.3-py3-none-any.whl", hash = "sha256:e0354bd361c09f342fbe671cf870cbd19fdcb1b27e7a5bb2d8a392a4f00c2ba9", size = 16739, upload-time = "2026-06-09T06:18:17.316Z" }, +] + [[package]] name = "ag-ui-protocol" version = "0.1.18" @@ -16,9 +25,10 @@ wheels = [ [[package]] name = "ag-ui-strands" -version = "0.1.9" +version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "ag-ui-a2ui-toolkit" }, { name = "ag-ui-protocol" }, { name = "fastapi" }, { name = "strands-agents" }, @@ -31,6 +41,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "ag-ui-a2ui-toolkit", specifier = ">=0.0.3" }, { name = "ag-ui-protocol", specifier = ">=0.1.18" }, { name = "fastapi", specifier = ">=0.115.12" }, { name = "strands-agents", specifier = ">=1.15.0" }, diff --git a/integrations/aws-strands/typescript/LICENSE b/integrations/aws-strands/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/aws-strands/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/aws-strands/typescript/examples/package.json b/integrations/aws-strands/typescript/examples/package.json index 8a5ad9b70c..62a49de3c7 100644 --- a/integrations/aws-strands/typescript/examples/package.json +++ b/integrations/aws-strands/typescript/examples/package.json @@ -4,15 +4,15 @@ "version": "0.0.0", "description": "Runnable AG-UI + Strands examples. Each file under server/api/ is a standalone server; server.ts mounts them all at once for the dojo.", "scripts": { - "dojo": "tsx server/server.ts", - "agentic-chat": "tsx server/api/agentic-chat.ts", - "agentic-chat-multimodal": "tsx server/api/agentic-chat-multimodal.ts", - "agentic-chat-reasoning": "tsx server/api/agentic-chat-reasoning.ts", - "agentic-generative-ui": "tsx server/api/agentic-generative-ui.ts", - "backend-tool-rendering": "tsx server/api/backend-tool-rendering.ts", - "human-in-the-loop": "tsx server/api/human-in-the-loop.ts", - "shared-state": "tsx server/api/shared-state.ts", - "tool-based-generative-ui": "tsx server/api/tool-based-generative-ui.ts" + "dojo": "tsx --env-file-if-exists=.env server/server.ts", + "agentic-chat": "tsx --env-file-if-exists=.env server/api/agentic-chat.ts", + "agentic-chat-multimodal": "tsx --env-file-if-exists=.env server/api/agentic-chat-multimodal.ts", + "agentic-chat-reasoning": "tsx --env-file-if-exists=.env server/api/agentic-chat-reasoning.ts", + "agentic-generative-ui": "tsx --env-file-if-exists=.env server/api/agentic-generative-ui.ts", + "backend-tool-rendering": "tsx --env-file-if-exists=.env server/api/backend-tool-rendering.ts", + "human-in-the-loop": "tsx --env-file-if-exists=.env server/api/human-in-the-loop.ts", + "shared-state": "tsx --env-file-if-exists=.env server/api/shared-state.ts", + "tool-based-generative-ui": "tsx --env-file-if-exists=.env server/api/tool-based-generative-ui.ts" }, "dependencies": { "@ag-ui/aws-strands": "workspace:*", diff --git a/integrations/aws-strands/typescript/examples/server/api/a2ui-dynamic-schema.ts b/integrations/aws-strands/typescript/examples/server/api/a2ui-dynamic-schema.ts new file mode 100644 index 0000000000..59bc561e3b --- /dev/null +++ b/integrations/aws-strands/typescript/examples/server/api/a2ui-dynamic-schema.ts @@ -0,0 +1,76 @@ +/** + * Dynamic A2UI example for AWS Strands (TypeScript). + * + * A plain agent with no a2ui wiring. When the runtime enables A2UI tool + * injection, the adapter auto-injects `generate_a2ui` and renders surfaces + * generated from the conversation. + */ + +import { Agent } from "@strands-agents/sdk"; +import { StrandsAgent } from "@ag-ui/aws-strands"; +import { createModel } from "../model-factory"; + +// The dojo registers its dynamic component catalog (HotelCard, ProductCard, +// TeamMemberCard) under this id; auto-injected surfaces must reference it so the +// renderer can resolve their components. +const DOJO_CATALOG_ID = "https://a2ui.org/demos/dojo/dynamic_catalog.json"; + +// Teaches the sub-agent how to compose the dojo catalog's components. Mirrors +// the LangGraph dynamic-schema demo's COMPOSITION_GUIDE so a real model (not +// just the e2e mock) can produce valid surfaces. +const COMPOSITION_GUIDE = ` +## Available Pre-made Components + +You have 3 card components. Use Row as the root with structural children to +repeat a card per item. + +### Row +Layout container. Repeat a card template via structural children: + {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}} + +### HotelCard +Props: name, location, rating (number 0-5), pricePerNight, action + +### ProductCard +Props: name, price, rating (number 0-5), description (optional), action + +### TeamMemberCard +Props: name, role, department (optional), email (optional), action + +## RULES +- Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"} +- ALWAYS include the referenced card component in the components array. +- Inside templates use RELATIVE paths (no leading slash): {"path":"name"}. +- Always provide data in the "data" argument as {"items":[...]}. +- Pick the card type that best matches the request; generate 3-4 realistic items. +`; + +const SYSTEM_PROMPT = `You are a helpful assistant that creates rich visual UI on the fly. + +When the user asks for visual content (product comparisons, dashboards, team +rosters, lists, cards, etc.), use the generate_a2ui tool to create a dynamic +A2UI surface. +IMPORTANT: After calling the tool, do NOT repeat the data in your text response. +The tool renders UI automatically. Just confirm what was rendered.`; + +export async function createA2UIDynamicSchemaAgent(): Promise { + const agent = new Agent({ + // Chat Completions API: the Responses adapter buffers tool-call argument + // deltas, which would defeat A2UI's progressive surface streaming. + model: await createModel({ openaiApi: "chat" }), + systemPrompt: SYSTEM_PROMPT, + // generate_a2ui is auto-injected by the adapter; nothing wired here. + }); + + return new StrandsAgent({ + agent, + name: "a2ui_dynamic_schema", + description: "Dynamic A2UI surfaces generated on the fly (auto-injected tool)", + config: { + a2ui: { + defaultCatalogId: DOJO_CATALOG_ID, + guidelines: { compositionGuide: COMPOSITION_GUIDE }, + }, + }, + }); +} diff --git a/integrations/aws-strands/typescript/examples/server/api/a2ui-recovery.ts b/integrations/aws-strands/typescript/examples/server/api/a2ui-recovery.ts new file mode 100644 index 0000000000..8f646401e5 --- /dev/null +++ b/integrations/aws-strands/typescript/examples/server/api/a2ui-recovery.ts @@ -0,0 +1,67 @@ +/** + * A2UI Error Recovery example for AWS Strands (TypeScript). + * + * A plain agent with no a2ui wiring. The adapter auto-injects `generate_a2ui`, + * which validates each generated surface and retries on failure (up to 3 + * total attempts) before falling back to a tasteful hard-failure. + */ + +import { Agent } from "@strands-agents/sdk"; +import { StrandsAgent } from "@ag-ui/aws-strands"; +import { createModel } from "../model-factory"; + +// The dojo registers its dynamic component catalog under this id; auto-injected +// surfaces must reference it so the renderer can resolve their components. +const DOJO_CATALOG_ID = "https://a2ui.org/demos/dojo/dynamic_catalog.json"; + +// Teaches the sub-agent how to compose the dojo catalog's components. Mirrors +// the LangGraph recovery demo's COMPOSITION_GUIDE. +const COMPOSITION_GUIDE = ` +## Available Pre-made Components + +Use Row as the root with structural children to repeat a card per item. + +### Row +Repeat a card template via structural children: + {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}} + +### HotelCard / ProductCard / TeamMemberCard +Card components bound to per-item data (relative paths inside the template). + +## RULES +- Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"} +- ALWAYS include the referenced card component in the components array. +- Inside templates use RELATIVE paths (no leading slash): {"path":"name"}. +- Always provide data in the "data" argument as {"items":[...]}. +- Generate 3-4 realistic items with diverse data. +`; + +const SYSTEM_PROMPT = `You are a helpful assistant that creates rich visual UI on the fly. + +When the user asks for visual content (hotel/product comparisons, team rosters, +lists, cards, etc.), use the generate_a2ui tool to create a dynamic A2UI surface. +IMPORTANT: After calling the tool, do NOT repeat the data in your text response. +The tool renders UI automatically. Just confirm what was rendered.`; + +export async function createA2UIRecoveryAgent(): Promise { + const agent = new Agent({ + // Chat Completions API: the Responses adapter buffers tool-call argument + // deltas, which would defeat A2UI's progressive surface streaming. + model: await createModel({ openaiApi: "chat" }), + systemPrompt: SYSTEM_PROMPT, + // generate_a2ui is auto-injected by the adapter; nothing wired here. + }); + + return new StrandsAgent({ + agent, + name: "a2ui_recovery", + description: + "Dynamic A2UI with automatic error recovery (auto-injected tool)", + config: { + a2ui: { + defaultCatalogId: DOJO_CATALOG_ID, + guidelines: { compositionGuide: COMPOSITION_GUIDE }, + }, + }, + }); +} diff --git a/integrations/aws-strands/typescript/examples/server/model-factory.ts b/integrations/aws-strands/typescript/examples/server/model-factory.ts index bb3d8bee0b..df93c92cc7 100644 --- a/integrations/aws-strands/typescript/examples/server/model-factory.ts +++ b/integrations/aws-strands/typescript/examples/server/model-factory.ts @@ -25,6 +25,14 @@ export interface CreateModelOptions { * Responses API drops reasoning blocks across multi-turn conversations. */ reasoning?: boolean; + /** + * OpenAI API mode. Defaults to the SDK default (Responses). Pass `"chat"` + * for demos that need tool-call ARGUMENTS to stream incrementally — the + * Strands Responses adapter buffers `function_call_arguments.delta` and only + * emits the complete toolUse at `…arguments.done`, so e.g. A2UI progressive + * surface painting never streams on the Responses API. + */ + openaiApi?: "chat" | "responses"; } export async function createModel( @@ -49,6 +57,7 @@ export async function createModel( return new OpenAIModel({ apiKey, modelId: process.env.MODEL_ID ?? "gpt-5.4", + ...(options.openaiApi ? { api: options.openaiApi } : {}), ...(reasoning ? { params: { reasoning: { effort: "medium", summary: "auto" } } } : {}), diff --git a/integrations/aws-strands/typescript/examples/server/server.ts b/integrations/aws-strands/typescript/examples/server/server.ts index fcb55cdf8f..6722f02471 100644 --- a/integrations/aws-strands/typescript/examples/server/server.ts +++ b/integrations/aws-strands/typescript/examples/server/server.ts @@ -14,6 +14,8 @@ import { addCapabilities, } from "@ag-ui/aws-strands/server"; import { createModel } from "./model-factory"; +import { createA2UIDynamicSchemaAgent } from "./api/a2ui-dynamic-schema"; +import { createA2UIRecoveryAgent } from "./api/a2ui-recovery"; function mountAgent( app: express.Express, @@ -340,6 +342,14 @@ Do not respond with plain text — always use the tool.`, }), ); + /* ---------------- a2ui (auto-injected tool) ---------------- */ + // Both demos are PLAIN Strands agents with NO a2ui tool wiring (each in its + // own file under ./agents). The CopilotKit runtime sends `injectA2UITool`; + // the @ag-ui/aws-strands adapter infers the model and auto-injects + // `generate_a2ui` (which runs the toolkit's validate→retry recovery loop). + mountAgent(app, "/a2ui-dynamic-schema", await createA2UIDynamicSchemaAgent()); + mountAgent(app, "/a2ui-recovery", await createA2UIRecoveryAgent()); + const port = Number(process.env.PORT ?? 8022); const host = process.env.HOST ?? "0.0.0.0"; app.listen(port, host, () => { diff --git a/integrations/aws-strands/typescript/package.json b/integrations/aws-strands/typescript/package.json index 7d9aaea52d..d2f596d667 100644 --- a/integrations/aws-strands/typescript/package.json +++ b/integrations/aws-strands/typescript/package.json @@ -1,7 +1,12 @@ { "name": "@ag-ui/aws-strands", "author": "AG-UI Contributors", - "version": "0.1.0", + "version": "0.2.1", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ag-ui-protocol/ag-ui.git" + }, "description": "AWS Strands Agents integration for the AG-UI protocol", "publishConfig": { "access": "public" @@ -26,6 +31,7 @@ "unlink:global": "pnpm unlink --global" }, "peerDependencies": { + "@ag-ui/a2ui-toolkit": ">=0.0.3", "@ag-ui/client": ">=0.0.37", "@ag-ui/core": ">=0.0.37", "@ag-ui/encoder": ">=0.0.37", @@ -46,6 +52,7 @@ } }, "devDependencies": { + "@ag-ui/a2ui-toolkit": "workspace:*", "@ag-ui/client": "workspace:*", "@ag-ui/core": "workspace:*", "@ag-ui/encoder": "workspace:*", diff --git a/integrations/aws-strands/typescript/src/__tests__/a2ui-tool.test.ts b/integrations/aws-strands/typescript/src/__tests__/a2ui-tool.test.ts new file mode 100644 index 0000000000..a3cae6ceb6 --- /dev/null +++ b/integrations/aws-strands/typescript/src/__tests__/a2ui-tool.test.ts @@ -0,0 +1,507 @@ +/** + * Unit tests for the AWS Strands A2UI subagent tool, covering both wiring + * modes (explicit + auto-injected) and message-shape helpers: + * + * Explicit wiring: `getA2UITools(params)` returns a Strands tool named + * `generate_a2ui` that runs the toolkit recovery loop. + * + * Auto-injection: `planA2UIInjection(...)` is the pure decision the + * adapter makes per run — read the runtime `injectA2UITool` flag off + * `forwardedProps`, infer the model from the wrapped agent, resolve the + * catalog from `input.context`, and decide whether to inject `generate_a2ui` + * (and which injected render tool to drop). Returns `null` when it must NOT + * inject. + * + * String literals below mirror the shared constants (`GENERATE_A2UI_TOOL_NAME` + * from @ag-ui/a2ui-toolkit, `RENDER_A2UI_TOOL_NAME` + + * `A2UI_SCHEMA_CONTEXT_DESCRIPTION` from @ag-ui/a2ui-middleware), hardcoded to + * avoid a cross-package dep just for test constants. + */ +import { describe, it, expect, vi } from "vitest"; + +import { EventType } from "@ag-ui/core"; +import { + getA2UITools, + planA2UIInjection, + isAutoInjectedA2UITool, + stripInFlightToolCall, + strandsToolResultsToAgui, + classifyA2UISubagentError, +} from "../a2ui-tool"; +import { collect, minimalRunInput, scriptedStrandsAgent } from "./helpers"; + +/** Minimal registry that records adds, supporting the methods the adapter uses. */ +function fakeRegistry(opts: { withList?: boolean; throwOnAdd?: string } = {}) { + const tools = new Map(); + const reg: Record = { + add: (t: { name: string }) => { + if (opts.throwOnAdd && t?.name === opts.throwOnAdd) { + throw new Error(`add boom: ${t.name}`); + } + tools.set(t.name, t); + }, + get: (n: string) => tools.get(n), + getByName: (n: string) => tools.get(n), + remove: (t: unknown) => + tools.delete(typeof t === "string" ? t : (t as { name?: string })?.name ?? ""), + removeByName: (n: string) => tools.delete(n), + values: () => Array.from(tools.values()), + }; + if (opts.withList !== false) reg.list = () => Array.from(tools.values()); + return { reg, tools }; +} + +const RENDER_TOOL_INPUT = { + name: "render_a2ui", + description: "render", + parameters: { type: "object", properties: {} }, +}; + +const GENERATE_A2UI_TOOL_NAME = "generate_a2ui"; +const RENDER_A2UI_TOOL_NAME = "render_a2ui"; +const A2UI_SCHEMA_CONTEXT_DESCRIPTION = + "A2UI Component Schema — available components for generating UI surfaces. Use these component names and properties when creating A2UI operations."; + +const stubModel = { modelId: "stub-model" }; +const CATALOG = { + components: { + Row: { required: ["children"] }, + HotelCard: { required: ["name", "rating"] }, + }, +}; + +describe("getA2UITools — explicit factory", () => { + it("requires a model (silent default-Bedrock fallback is a footgun)", () => { + expect(() => getA2UITools({} as never)).toThrow(/model/); + }); + + it("returns a Strands tool named 'generate_a2ui' by default", () => { + const tool = getA2UITools({ model: stubModel }); + expect(tool.name).toBe(GENERATE_A2UI_TOOL_NAME); + // Strands tool contract: has a toolSpec + an async stream(). + expect(tool.toolSpec?.name).toBe(GENERATE_A2UI_TOOL_NAME); + expect(typeof tool.stream).toBe("function"); + }); + + it("honors a custom tool name", () => { + const tool = getA2UITools({ model: stubModel, toolName: "make_ui" }); + expect(tool.name).toBe("make_ui"); + }); +}); + +describe("planA2UIInjection — auto-inject decision", () => { + it("injects generate_a2ui when the runtime flag is true and a model is inferable", () => { + const input = minimalRunInput({ forwardedProps: { injectA2UITool: true } }); + const plan = planA2UIInjection({ + model: stubModel, + input, + existingToolNames: [], + }); + expect(plan).not.toBeNull(); + expect(plan!.tool.name).toBe(GENERATE_A2UI_TOOL_NAME); + expect(plan!.toolName).toBe(GENERATE_A2UI_TOOL_NAME); + // The injected render tool (default name) is dropped from advertised tools + // so the model calls generate_a2ui, not render_a2ui directly. + expect(plan!.dropToolNames).toContain(RENDER_A2UI_TOOL_NAME); + }); + + it("drops the injected render tool under its CUSTOM name when the flag is a string", () => { + const input = minimalRunInput({ + forwardedProps: { injectA2UITool: "render_ui_custom" }, + }); + const plan = planA2UIInjection({ + model: stubModel, + input, + existingToolNames: [], + }); + expect(plan).not.toBeNull(); + // The string names the INJECTED render tool to drop — the server-side + // sub-agent tool we register stays `generate_a2ui`. + expect(plan!.toolName).toBe(GENERATE_A2UI_TOOL_NAME); + expect(plan!.dropToolNames).toContain("render_ui_custom"); + }); + + it("does NOT inject and warns when no model is inferable (orchestrator: Graph/Swarm)", () => { + const warn = vi.fn(); + const input = minimalRunInput({ forwardedProps: { injectA2UITool: true } }); + const plan = planA2UIInjection({ + model: null, + input, + existingToolNames: [], + log: { warn }, + }); + expect(plan).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toMatch(/orchestrator|model/i); + }); + + it("does NOT inject when neither the runtime flag nor a backend override is set", () => { + const plan = planA2UIInjection({ + model: stubModel, + input: minimalRunInput(), + existingToolNames: [], + }); + expect(plan).toBeNull(); + }); + + it("injects on a backend override even without the runtime flag (non-CopilotKit hosts)", () => { + const plan = planA2UIInjection({ + model: stubModel, + input: minimalRunInput(), + existingToolNames: [], + config: { injectA2UITool: true }, + }); + expect(plan).not.toBeNull(); + expect(plan!.tool.name).toBe(GENERATE_A2UI_TOOL_NAME); + }); + + // THE "USER PREVAILS" REQUIREMENT. + it("USER PREVAILS: does NOT double-inject when the dev already wired generate_a2ui and the runtime flag is on", () => { + const input = minimalRunInput({ forwardedProps: { injectA2UITool: true } }); + const plan = planA2UIInjection({ + model: stubModel, + input, + existingToolNames: [GENERATE_A2UI_TOOL_NAME], // dev's explicit getA2UITools() + }); + // Explicit dev wiring wins: no second generate_a2ui is registered. + expect(plan).toBeNull(); + }); + + it("ignores the catalog in the schema context entry (no validation auto-resolve)", () => { + // Mirrors the LangGraph adapter: a catalog carried in RunAgentInput.context + // is NOT auto-resolved into the validation catalog. Only an explicit + // config.catalog enables catalog-aware recovery. + const input = minimalRunInput({ + forwardedProps: { injectA2UITool: true }, + context: [ + { + description: A2UI_SCHEMA_CONTEXT_DESCRIPTION, + value: JSON.stringify(CATALOG), + }, + ], + }); + const plan = planA2UIInjection({ + model: stubModel, + input, + existingToolNames: [], + }); + expect(plan).not.toBeNull(); + expect(plan!.catalog).toBeUndefined(); + }); + + it("uses an explicit config.catalog unchanged", () => { + const plan = planA2UIInjection({ + model: stubModel, + input: minimalRunInput({ forwardedProps: { injectA2UITool: true } }), + existingToolNames: [], + config: { catalog: CATALOG }, + }); + expect(plan).not.toBeNull(); + expect(plan!.catalog).toEqual(CATALOG); + }); + + it("tags the injected tool so the adapter can distinguish it from a dev-wired one", () => { + const plan = planA2UIInjection({ + model: stubModel, + input: minimalRunInput({ forwardedProps: { injectA2UITool: true } }), + existingToolNames: [], + }); + expect(plan).not.toBeNull(); + expect(isAutoInjectedA2UITool(plan!.tool)).toBe(true); + // A dev-wired tool carries no marker. + expect(isAutoInjectedA2UITool(getA2UITools({ model: stubModel }))).toBe( + false, + ); + }); +}); + +describe("Strands message-shape helpers (real SDK block types)", () => { + // Real @strands-agents/sdk blocks use `type: "toolUseBlock" | "toolResultBlock" + // | "textBlock"` — NOT "toolUse"/"ToolResultBlock". These tests pin the + // discriminants so a regression doesn't silently no-op the strip / conversion. + const A2UI_OPS_KEY = "a2ui_operations"; + + it("stripInFlightToolCall drops a trailing toolUseBlock for the tool", () => { + const messages = [ + { role: "user", content: [{ type: "textBlock", text: "compare hotels" }] }, + { + role: "assistant", + content: [ + { type: "toolUseBlock", name: "generate_a2ui", toolUseId: "t1", input: {} }, + ], + }, + ]; + const stripped = stripInFlightToolCall(messages, "generate_a2ui"); + expect(stripped).toHaveLength(1); + expect(stripped[0].role).toBe("user"); + }); + + it("stripInFlightToolCall keeps a trailing user turn", () => { + const messages = [ + { role: "user", content: [{ type: "textBlock", text: "compare hotels" }] }, + ]; + expect(stripInFlightToolCall(messages, "generate_a2ui")).toHaveLength(1); + }); + + it("strandsToolResultsToAgui reconstructs tool messages from real toolResultBlock content", () => { + const envelope = JSON.stringify({ [A2UI_OPS_KEY]: [{ version: "v0.9" }] }); + const messages = [ + { + role: "user", + content: [ + { + type: "toolResultBlock", + toolUseId: "tc1", + content: [{ type: "textBlock", text: envelope }], + }, + ], + }, + ]; + const agui = strandsToolResultsToAgui(messages); + expect(agui).toHaveLength(1); + expect(agui[0].role).toBe("tool"); + expect((agui[0] as { toolCallId?: string }).toolCallId).toBe("tc1"); + expect(agui[0].content).toContain(A2UI_OPS_KEY); + }); + + it("strandsToolResultsToAgui reconstructs from SERIALIZED bare {text}/{json} blocks (no type discriminant)", () => { + const envelope = JSON.stringify({ [A2UI_OPS_KEY]: [{ version: "v0.9" }] }); + // Bare {text} — what _buildStrandsHistory emits / fromMessageData carries. + const fromText = strandsToolResultsToAgui([ + { + role: "user", + content: [{ toolResult: { toolUseId: "tc1", content: [{ text: envelope }] } }], + }, + ]); + expect(fromText).toHaveLength(1); + expect(fromText[0].content).toContain(A2UI_OPS_KEY); + // Bare {json}. + const fromJson = strandsToolResultsToAgui([ + { + role: "user", + content: [ + { + type: "toolResultBlock", + toolUseId: "tc2", + content: [{ json: { [A2UI_OPS_KEY]: [{ version: "v0.9" }] } }], + }, + ], + }, + ]); + expect(fromJson).toHaveLength(1); + expect(fromJson[0].content).toContain(A2UI_OPS_KEY); + }); + + it("strandsToolResultsToAgui ignores non-A2UI tool results", () => { + const messages = [ + { + role: "user", + content: [ + { + type: "toolResultBlock", + toolUseId: "tc1", + content: [{ type: "textBlock", text: "just a weather result" }], + }, + ], + }, + ]; + expect(strandsToolResultsToAgui(messages)).toHaveLength(0); + }); +}); + +describe("auto-inject across turns (F1 regression)", () => { + // The middleware injects render_a2ui into RunAgentInput.tools on EVERY turn. + const renderProxyTool = { + name: RENDER_A2UI_TOOL_NAME, + description: "render a2ui", + parameters: { type: "object", properties: {} }, + }; + const turnInput = () => + minimalRunInput({ + forwardedProps: { injectA2UITool: true }, + tools: [renderProxyTool], + }); + + it("re-injects generate_a2ui and keeps render_a2ui dropped on the 2nd turn of a cached thread", async () => { + const agent = scriptedStrandsAgent([]); + const registry = ( + agent as unknown as { + _agentsByThread: Map; + } + )._agentsByThread.get("thread-1")!.toolRegistry; + + // Turn 1 + await collect(agent, turnInput()); + let names = registry.list().map((t) => t.name); + expect(names).toContain("generate_a2ui"); + expect(names).not.toContain("render_a2ui"); + + // Turn 2 on the SAME cached agent: render_a2ui is re-synced by + // syncProxyTools, and must be dropped again (the bug left it registered + // alongside generate_a2ui, letting the model bypass the recovery loop). + await collect(agent, turnInput()); + names = registry.list().map((t) => t.name); + expect(names).toContain("generate_a2ui"); + expect(names).not.toContain("render_a2ui"); + expect(names.filter((n) => n === "generate_a2ui")).toHaveLength(1); + }); +}); + +describe("A2UI sub-agent streaming → synthetic inner TOOL_CALL events", () => { + // The generate_a2ui tool yields ToolStreamEvents carrying the sub-agent's + // render_a2ui progress; the adapter must re-emit them as TOOL_CALL_START/ + // ARGS/END so the a2ui middleware can drive the "building" skeleton and + // progressive paint (without them the surface only bulk-paints from the + // final result). + const A2UI_STREAM_KEY = "__a2uiRenderStream"; + const streamEvt = (payload: Record) => ({ + type: "toolStreamEvent", + data: { [A2UI_STREAM_KEY]: payload }, + }); + + it("re-emits start/args/end payloads as inner TOOL_CALL events on the wire", async () => { + const agent = scriptedStrandsAgent([ + streamEvt({ kind: "start", toolCallId: "r1", toolCallName: "render_a2ui" }), + streamEvt({ kind: "args", toolCallId: "r1", delta: '{"surfaceId":' }), + streamEvt({ kind: "args", toolCallId: "r1", delta: '"s1"}' }), + streamEvt({ kind: "end", toolCallId: "r1" }), + ]); + const events = await collect(agent); + + const start = events.find( + (e) => + e.type === EventType.TOOL_CALL_START && + (e as { toolCallName?: string }).toolCallName === "render_a2ui", + ) as { toolCallId?: string } | undefined; + expect(start).toBeDefined(); + expect(start!.toolCallId).toBe("r1"); + + const argDeltas = events + .filter( + (e) => + e.type === EventType.TOOL_CALL_ARGS && + (e as { toolCallId?: string }).toolCallId === "r1", + ) + .map((e) => (e as { delta?: string }).delta); + expect(argDeltas.join("")).toBe('{"surfaceId":"s1"}'); + + expect( + events.some( + (e) => + e.type === EventType.TOOL_CALL_END && + (e as { toolCallId?: string }).toolCallId === "r1", + ), + ).toBe(true); + }); + + it("ignores non-a2ui toolStreamEvent payloads (state path unaffected)", async () => { + const agent = scriptedStrandsAgent([ + { type: "toolStreamEvent", data: { state: { steps: [1] } } }, + ]); + const events = await collect(agent); + expect( + events.some( + (e) => + e.type === EventType.STATE_SNAPSHOT && + JSON.stringify((e as { snapshot?: unknown }).snapshot).includes("steps"), + ), + ).toBe(true); + expect(events.some((e) => e.type === EventType.TOOL_CALL_START)).toBe(false); + }); +}); + +describe("classifyA2UISubagentError (cancel / adapter-bug vs recoverable)", () => { + it("rethrows on an aborted signal regardless of error", () => { + expect(classifyA2UISubagentError(new Error("any"), true)).toBe("rethrow"); + }); + it("rethrows AbortError / CancelledError", () => { + const abort = Object.assign(new Error("x"), { name: "AbortError" }); + const cancelled = Object.assign(new Error("x"), { name: "CancelledError" }); + expect(classifyA2UISubagentError(abort, false)).toBe("rethrow"); + expect(classifyA2UISubagentError(cancelled, false)).toBe("rethrow"); + }); + it("rethrows programmer errors (TypeError / ReferenceError = adapter bug)", () => { + expect(classifyA2UISubagentError(new TypeError("x"), false)).toBe("rethrow"); + expect(classifyA2UISubagentError(new ReferenceError("x"), false)).toBe("rethrow"); + }); + it("treats undici network TypeErrors as recoverable, not adapter bugs", () => { + // Node 18+ fetch rejects with `TypeError: fetch failed` (+ errno cause) — + // the canonical TRANSIENT network error the recovery loop must absorb. + const fetchFailed = new TypeError("fetch failed"); + (fetchFailed as { cause?: unknown }).cause = new Error("ECONNREFUSED"); + expect(classifyA2UISubagentError(fetchFailed, false)).toBe("recoverable"); + expect(classifyA2UISubagentError(new TypeError("fetch failed"), false)).toBe( + "recoverable", + ); + // A bare TypeError with no network shape stays an adapter bug — and so + // does a CAUSED non-network TypeError or one merely mentioning "fetch" + // (exact-message match only). + expect( + classifyA2UISubagentError(new TypeError("x is not a function"), false), + ).toBe("rethrow"); + const causedBug = new TypeError("oops"); + (causedBug as { cause?: unknown }).cause = new Error("inner"); + expect(classifyA2UISubagentError(causedBug, false)).toBe("rethrow"); + expect( + classifyA2UISubagentError( + new TypeError("this.fetchCatalog is not a function"), + false, + ), + ).toBe("rethrow"); + }); + it("treats a genuine model/network error as a recoverable failed attempt", () => { + expect(classifyA2UISubagentError(new Error("Bedrock 429"), false)).toBe( + "recoverable", + ); + }); +}); + +describe("auto-inject error handling in the adapter run (R4/R5 behaviors)", () => { + it("degrades gracefully when injecting the tool throws — run still finishes, no RUN_ERROR", async () => { + // Registry that lets proxy-sync succeed but throws when adding generate_a2ui. + const { reg } = fakeRegistry({ throwOnAdd: "generate_a2ui" }); + const agent = scriptedStrandsAgent([], { + stubOverrides: { toolRegistry: reg as never }, + config: { a2ui: { injectA2UITool: true } }, + }); + const events = await collect( + agent, + minimalRunInput({ tools: [RENDER_TOOL_INPUT] }), + ); + const types = events.map((e) => e.type); + expect(types).toContain(EventType.RUN_STARTED); + expect(types).toContain(EventType.RUN_FINISHED); + expect(types).not.toContain(EventType.RUN_ERROR); + }); + + it("skips injection (no crash) when the registry exposes no list()", async () => { + const { reg, tools } = fakeRegistry({ withList: false }); + const agent = scriptedStrandsAgent([], { + stubOverrides: { toolRegistry: reg as never }, + config: { a2ui: { injectA2UITool: true } }, + }); + const events = await collect( + agent, + minimalRunInput({ tools: [RENDER_TOOL_INPUT] }), + ); + expect(events.map((e) => e.type)).toContain(EventType.RUN_FINISHED); + // Could not enumerate to dedup/refresh → must NOT inject (never clobber). + expect(tools.has("generate_a2ui")).toBe(false); + }); +}); + +describe("planA2UIInjection — nullish flag + catalog degradation", () => { + it("explicit runtime injectA2UITool=false beats a backend opt-in (?? not ||)", () => { + const plan = planA2UIInjection({ + model: {}, + input: minimalRunInput({ forwardedProps: { injectA2UITool: false } }), + existingToolNames: [], + config: { injectA2UITool: true }, + }); + expect(plan).toBeNull(); + }); + + // Catalog-id resolution + config-overrides-runtime precedence is unit-tested + // at the toolkit level (resolveA2UICatalog). The streamed-args catalogId stamp + // is covered by the aws-strands-typescript A2UI e2e specs. +}); diff --git a/integrations/aws-strands/typescript/src/__tests__/cors.test.ts b/integrations/aws-strands/typescript/src/__tests__/cors.test.ts new file mode 100644 index 0000000000..150bc7a65c --- /dev/null +++ b/integrations/aws-strands/typescript/src/__tests__/cors.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { EventType, type BaseEvent, type RunAgentInput } from "@ag-ui/core"; +import type { AddressInfo } from "net"; + +import { createStrandsApp, type CreateStrandsAppOptions } from "../server"; +import { StrandsAgent } from "../agent"; + +class FixedAgent extends StrandsAgent { + private readonly _events: BaseEvent[]; + constructor(events: BaseEvent[]) { + super({ + agent: { + model: {}, + tools: [], + toolRegistry: { + list: () => [], + add() {}, + get: () => undefined, + remove() {}, + }, + sessionManager: undefined, + } as unknown as import("@strands-agents/sdk").Agent, + name: "fixed", + }); + this._events = events; + } + async *run(_input: RunAgentInput): AsyncGenerator { + for (const e of this._events) yield e; + } +} + +async function startApp(options?: CreateStrandsAppOptions): Promise<{ + port: number; + close: () => Promise; +}> { + const app = await createStrandsApp( + new FixedAgent([ + { type: EventType.RUN_STARTED, threadId: "t", runId: "r" }, + { type: EventType.RUN_FINISHED, threadId: "t", runId: "r" }, + ]), + options, + ); + const server = await new Promise((resolve) => { + const s = app.listen(0, () => resolve(s)); + }); + const port = (server.address() as AddressInfo).port; + return { + port, + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ), + }; +} + +/** Issue a CORS preflight (OPTIONS) carrying an Origin and read back the ACA-* headers. */ +async function preflight( + port: number, + origin: string, +): Promise<{ allowOrigin: string | null; allowCredentials: string | null }> { + const res = await fetch(`http://127.0.0.1:${port}/`, { + method: "OPTIONS", + headers: { + Origin: origin, + "Access-Control-Request-Method": "POST", + }, + }); + return { + allowOrigin: res.headers.get("access-control-allow-origin"), + allowCredentials: res.headers.get("access-control-allow-credentials"), + }; +} + +describe("createStrandsApp CORS", () => { + it("defaults to a literal `*` origin (matches the Python adapter), not a reflected one", async () => { + const { port, close } = await startApp(); + try { + const { allowOrigin, allowCredentials } = await preflight( + port, + "https://evil.example.com", + ); + // Literal wildcard, NOT the reflected request Origin. `origin: true` + // (the previous default) would have echoed "https://evil.example.com". + expect(allowOrigin).toBe("*"); + expect(allowOrigin).not.toBe("https://evil.example.com"); + expect(allowCredentials).toBe("true"); + } finally { + await close(); + } + }); + + it("honours an explicit single-origin override", async () => { + const allowed = "https://app.example.com"; + const { port, close } = await startApp({ corsOrigin: allowed }); + try { + const { allowOrigin, allowCredentials } = await preflight(port, allowed); + expect(allowOrigin).toBe(allowed); + expect(allowCredentials).toBe("true"); + } finally { + await close(); + } + }); +}); diff --git a/integrations/aws-strands/typescript/src/__tests__/helpers.ts b/integrations/aws-strands/typescript/src/__tests__/helpers.ts index 0cc821f2b7..f17c960e7b 100644 --- a/integrations/aws-strands/typescript/src/__tests__/helpers.ts +++ b/integrations/aws-strands/typescript/src/__tests__/helpers.ts @@ -60,7 +60,14 @@ export function scriptedAgent( const registry = { add: (t: unknown) => { const name = (t as { name?: string })?.name; - if (name) tools.set(name, t); + if (!name) return; + // Match the real `@strands-agents/sdk` ToolRegistry.add(): it throws + // ToolValidationError on a duplicate name. Overwriting silently would let + // a double-inject regression (the F1 bug class) pass undetected. + if (tools.has(name)) { + throw new Error(`Tool "${name}" is already registered`); + } + tools.set(name, t); }, get: (n: string) => tools.get(n), getByName: (n: string) => tools.get(n), @@ -70,6 +77,8 @@ export function scriptedAgent( }, removeByName: (n: string) => tools.delete(n), values: () => Array.from(tools.values()), + // Mirrors the real `@strands-agents/sdk` ToolRegistry.list(). + list: () => Array.from(tools.values()), }; return { model: { name: "stub-model", modelId: "stub-model" }, diff --git a/integrations/aws-strands/typescript/src/a2ui-tool.ts b/integrations/aws-strands/typescript/src/a2ui-tool.ts new file mode 100644 index 0000000000..4805c54dc0 --- /dev/null +++ b/integrations/aws-strands/typescript/src/a2ui-tool.ts @@ -0,0 +1,844 @@ +/** + * A2UI subagent tool for AWS Strands agents. + * + * Thin adapter over `@ag-ui/a2ui-toolkit` — the recovery loop, validation, op + * builders, prompt assembly and output envelope all live in the toolkit. This + * file owns only the Strands-specific glue: + * + * - `getA2UITools(params, glue?)` — explicit wiring: builds a Strands tool the + * dev adds to their agent's `tools`. The tool runs the toolkit's + * validate→retry recovery loop, driving a sub-agent that calls `render_a2ui`. + * - `planA2UIInjection(...)` — auto-injection: the pure decision the + * adapter makes per run. Reads the runtime `injectA2UITool` flag, infers the + * model, resolves the catalog, threads the run's AG-UI messages + state, and + * returns the tool to register (+ the injected render tool to drop) — or + * `null` when it must not inject. + * + * Message shapes: the toolkit expects AG-UI-shaped history (a `render_a2ui` + * result is a `role:"tool"` message whose `content` is the JSON `a2ui_operations` + * envelope — this is what `findPriorSurface` walks on an `update`). The Strands + * SDK uses its own block-structured messages. So the tool keeps BOTH: + * - AG-UI messages for the toolkit (`prepareA2UIRequest` / `findPriorSurface`), + * supplied by the adapter on auto-injection, else converted from Strands. + * - Strands messages for the sub-agent `invoke`, taken from `ctx.agent.messages` + * with the in-flight `generate_a2ui` tool call stripped (Bedrock rejects an + * assistant `toolUse` with no matching `toolResult`). + */ + +import { + Agent, + TextBlock, + ToolResultBlock, + ToolStreamEvent, + type Model, + type Tool, + type ToolContext, + type ToolStreamGenerator, +} from "@strands-agents/sdk"; +import type { Message as AguiMessage, RunAgentInput } from "@ag-ui/core"; +import { + A2UI_OPERATIONS_KEY, + GENERATE_A2UI_ARG_DESCRIPTIONS, + GENERATE_A2UI_TOOL_NAME, + RENDER_A2UI_TOOL_DEF, + buildA2UIEnvelope, + prepareA2UIRequest, + resolveA2UICatalog, + resolveA2UIToolParams, + runA2UIGenerationWithRecovery, + splitA2UISchemaContext, + wrapErrorEnvelope, + type A2UIGuidelines, + type A2UIRecoveryConfig, + type A2UIToolParams, + type A2UIValidationCatalog, +} from "@ag-ui/a2ui-toolkit"; + +import { flattenContentToText } from "./utils"; +import { DEFAULT_LOGGER, type Logger } from "./logger"; + +export type { A2UIToolParams }; + +/** Default name of the render tool the A2UI middleware injects (and we drop). */ +const RENDER_A2UI_TOOL_NAME = RENDER_A2UI_TOOL_DEF.function.name; + +/** + * Marks a `generate_a2ui` tool this adapter auto-injected, so the + * per-run hook can tell its OWN prior-turn injection (safe to refresh) apart + * from a `generate_a2ui` the developer wired explicitly (USER PREVAILS, + * never touched). Without this, the second turn of a cached thread can't + * distinguish the two and leaks the raw `render_a2ui` tool back to the model. + */ +export const A2UI_AUTOINJECT_MARKER = Symbol.for( + "@ag-ui/aws-strands.a2uiAutoInjected", +); + +/** Tool arguments exposed to the main agent's planner. */ +interface GenerateA2UIArgs { + intent?: "create" | "update"; + target_surface_id?: string; + changes?: string; +} + +/** + * Marker key on `ToolStreamEvent.data` payloads carrying the sub-agent's + * render_a2ui streaming progress out of the `generate_a2ui` tool. The adapter + * (`agent.ts`) translates these into synthetic inner TOOL_CALL_START/ARGS/END + * events on the AG-UI wire — the shape the a2ui middleware's streaming path + * needs to drive the "building" skeleton and progressive paint. + */ +export const A2UI_STREAM_KEY = "__a2uiRenderStream"; + +// Per-process fallback-id sequence: providers that never stamp toolUseId must +// not reuse one id across recovery attempts (Date.now() can collide within a +// millisecond — two full lifecycles under one toolCallId mis-merge in +// id-keyed consumers). +let a2uiRenderSeq = 0; + +/** One sub-agent render_a2ui streaming step, re-emitted on the AG-UI wire. */ +export interface A2UIRenderStreamEvent { + kind: "start" | "args" | "end"; + /** The sub-agent's toolUseId — fresh per recovery attempt. */ + toolCallId: string; + /** Tool name (start only). */ + toolCallName?: string; + /** Raw args-JSON fragment (args only). */ + delta?: string; +} + +/** + * Per-run glue the adapter threads into the tool. Optional: when omitted + * (dev-wired), the tool derives AG-UI history from `ctx.agent.messages` + * and runs with empty state. + */ +export interface A2UIToolGlue { + /** + * The run's AG-UI messages (`RunAgentInput.messages`). Used by the toolkit's + * `findPriorSurface` for `intent:"update"`. When omitted, derived from the + * Strands conversation. + */ + aguiMessages?: AguiMessage[]; + /** + * The run's `RunAgentInput.state`. `buildContextPrompt` reads + * `state["ag-ui"]` to put available-component context into the sub-agent + * prompt. When omitted, defaults to `{}`. + */ + state?: Record; +} + +/** + * Build a Strands tool that delegates A2UI surface generation to a sub-agent + * running the toolkit recovery loop. Add the returned tool to a Strands + * `Agent`'s `tools` list yourself, or let `planA2UIInjection` build it. + */ +export function getA2UITools( + params: A2UIToolParams, + glue: A2UIToolGlue = {}, +): Tool { + if ((params as { model?: unknown })?.model == null) { + // Type-level enforcement doesn't protect plain-JS callers — and the + // Strands Agent silently falls back to a default BedrockModel, binding + // the render sub-agent to an unintended provider. + throw new Error( + "getA2UITools requires a 'model' (the Strands model instance the " + + "render sub-agent runs on).", + ); + } + const { + model, + guidelines, + defaultSurfaceId, + defaultCatalogId, + toolName, + toolDescription, + catalog, + recovery, + onA2UIAttempt, + } = resolveA2UIToolParams(params); + const subagentModel = model as Model; + + return { + name: toolName, + description: toolDescription, + toolSpec: { + name: toolName, + description: toolDescription, + inputSchema: { + type: "object", + properties: { + intent: { + type: "string", + enum: ["create", "update"], + description: GENERATE_A2UI_ARG_DESCRIPTIONS.intent, + }, + target_surface_id: { + type: "string", + description: GENERATE_A2UI_ARG_DESCRIPTIONS.target_surface_id, + }, + changes: { + type: "string", + description: GENERATE_A2UI_ARG_DESCRIPTIONS.changes, + }, + }, + }, + }, + async *stream(ctx: ToolContext): ToolStreamGenerator { + const input = (ctx.toolUse.input ?? {}) as GenerateA2UIArgs; + + // Strands history for the sub-agent invoke, minus the in-flight + // generate_a2ui call (an unbalanced toolUse is rejected by Bedrock and is + // for a tool the sub-agent doesn't have). + const strandsMessages = stripInFlightToolCall( + (ctx.agent.messages ?? []) as StrandsLikeMessage[], + toolName, + ); + + // AG-UI history for the toolkit's findPriorSurface (update intent + // only). MERGE the adapter-supplied glue snapshot (run-start history) + // with the + // live Strands-derived results: the snapshot alone misses a surface + // created EARLIER IN THIS SAME RUN, so a same-run create-then-update + // would error for a surface visibly on screen. Derived results go + // last — findPriorSurface walks backwards, so same-run state wins. + const aguiMessages = [ + ...(glue.aguiMessages ?? []), + ...strandsToolResultsToAgui(strandsMessages), + ]; + + const prep = prepareA2UIRequest({ + intent: input.intent, + targetSurfaceId: input.target_surface_id, + changes: input.changes, + messages: aguiMessages, + // `RunAgentInput.state` is `any` on the wire; a truthy non-object + // must degrade to empty state (mirrors the Python adapter's guard). + state: + glue.state && typeof glue.state === "object" && !Array.isArray(glue.state) + ? glue.state + : {}, + guidelines, + }); + + // The sub-agent's render_a2ui call must STREAM to the AG-UI wire — the + // a2ui middleware's "building" skeleton and progressive paint key off the + // inner tool-call's arg deltas, not the final result (LangGraph gets this + // for free from nested LLM callbacks; the result-only path falls back to + // a bulk paint with no lifecycle). The recovery loop runs concurrently as + // a promise; each sub-agent stream event is queued and re-yielded here as + // a ToolStreamEvent, which the adapter translates into synthetic inner + // TOOL_CALL_START/ARGS/END events. + const queue: A2UIRenderStreamEvent[] = []; + let notify: (() => void) | null = null; + const push = (e: A2UIRenderStreamEvent) => { + queue.push(e); + notify?.(); + notify = null; + }; + + if (prep.error) { + // The model still reads the envelope (it can self-correct), but + // leave a server-side breadcrumb so these are countable. + DEFAULT_LOGGER.warn( + `[@ag-ui/aws-strands] A2UI request prep failed: ${prep.error}`, + ); + } + // Disconnect channel (mirrors the Python adapter's threading.Event): + // set when the consumer abandons this generator so the recovery loop + // stops before firing further sub-agent attempts nobody will drain. + let disconnected = false; + const envelopePromise: Promise = prep.error + ? Promise.resolve(wrapErrorEnvelope(prep.error)) + : runA2UIGenerationWithRecovery({ + basePrompt: prep.prompt, + catalog, + config: recovery, + onAttempt: onA2UIAttempt, + invokeSubagent: (prompt) => { + if (disconnected) { + const abort = new Error( + "consumer disconnected; abandoning A2UI recovery", + ); + abort.name = "CancelledError"; + throw abort; + } + return invokeRenderSubagent(subagentModel, prompt, strandsMessages, { + // Propagate the run's cancellation so an abandoned outer run + // (client disconnect) doesn't leave the sub-agent's model + // call running and burning tokens. The signal lives on + // `ctx.agent.cancelSignal` (LocalAgent), not on the context. + cancelSignal: (ctx.agent as { cancelSignal?: AbortSignal }) + .cancelSignal, + onStreamEvent: push, + catalogId: defaultCatalogId, + }); + }, + buildEnvelope: (args) => + buildA2UIEnvelope({ + args, + isUpdate: prep.isUpdate, + targetSurfaceId: input.target_surface_id, + prior: prep.prior, + defaultSurfaceId, + defaultCatalogId, + }), + }).then((r) => r.envelope); + + // Track settlement WITHOUT consuming the rejection (rethrow below). + let settled = false; + const settledSignal = envelopePromise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + + try { + while (!settled || queue.length > 0) { + while (queue.length > 0) { + yield new ToolStreamEvent({ + data: { [A2UI_STREAM_KEY]: queue.shift()! }, + }); + } + if (settled) break; + await Promise.race([ + settledSignal, + new Promise((resolve) => { + notify = resolve; + }), + ]); + } + } finally { + if (!settled) { + // Generator abandoned mid-drain (executor return()/throw() at a + // suspended yield): stop the recovery loop before its next attempt, + // and consume its eventual outcome so a rethrow-class error isn't + // silently dropped (the settledSignal handler swallows rejections + // by design — mirror Python's _log_abandoned_recovery_result). + disconnected = true; + envelopePromise.catch((err: unknown) => { + const name = (err as { name?: string })?.name; + if (name === "CancelledError" || name === "AbortError") return; + DEFAULT_LOGGER.warn( + `[@ag-ui/aws-strands] A2UI recovery loop failed after the consumer disconnected: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + } + } + + const envelope = await envelopePromise; + return new ToolResultBlock({ + toolUseId: ctx.toolUse.toolUseId, + status: "success", + content: [new TextBlock(envelope)], + }); + }, + }; +} + +/** + * Classify a sub-agent invoke error. `"rethrow"` must unwind the tool call — + * no recovery retries; Strands' tool executor surfaces it as a tool error: + * - cancellation (client disconnect) — retrying would defeat the cancel and + * burn MORE tokens, the opposite of why the signal is threaded through; + * - programmer errors (TypeError/ReferenceError = adapter bugs) — must surface + * loudly, not masquerade as a recoverable "failed attempt". + * `"recoverable"` is a genuine model/network error the recovery loop should + * record as a failed attempt (retry or tasteful hard-failure). + */ +export function classifyA2UISubagentError( + err: unknown, + aborted: boolean, +): "rethrow" | "recoverable" { + const name = (err as { name?: string })?.name; + if (aborted || name === "AbortError" || name === "CancelledError") return "rethrow"; + if (err instanceof TypeError) { + // Node's undici rejects a failed fetch with exactly `TypeError: fetch + // failed` — the canonical TRANSIENT network error for fetch-based + // providers, which the recovery loop must absorb. Exact-match only: + // substring/cause heuristics would misclassify adapter bugs like + // `this.fetchCatalog is not a function` or any caused TypeError. + return (err as Error).message === "fetch failed" ? "recoverable" : "rethrow"; + } + if (err instanceof ReferenceError) return "rethrow"; + return "recoverable"; +} + +/** + * Run the structured-output sub-agent once: bind a `render_a2ui` tool, invoke + * the model with the (already error-augmented) prompt, and return the captured + * `render_a2ui` args — or `null` if the model produced no call. + */ +async function invokeRenderSubagent( + model: Model, + prompt: string, + messages: ReadonlyArray, + options: { + cancelSignal?: AbortSignal; + /** Called for each render_a2ui streaming step (start / args delta / end). */ + onStreamEvent?: (e: A2UIRenderStreamEvent) => void; + /** + * Host-resolved `defaultCatalogId`, stamped into the streamed args. The + * model never emits `catalogId` (render schema omits it; host owns the + * catalog), so without this the middleware's progressive paint falls back + * to the basic catalog and the renderer throws "Catalog not found". The id + * matches what `buildA2UIEnvelope` stamps on the final surface. + */ + catalogId?: string; + } = {}, +): Promise | null> { + let captured: Record | null = null; + const renderTool: Tool = { + name: RENDER_A2UI_TOOL_NAME, + description: RENDER_A2UI_TOOL_DEF.function.description, + toolSpec: { + name: RENDER_A2UI_TOOL_NAME, + description: RENDER_A2UI_TOOL_DEF.function.description, + inputSchema: RENDER_A2UI_TOOL_DEF.function + .parameters as Tool["toolSpec"]["inputSchema"], + }, + // eslint-disable-next-line require-yield + async *stream(ctx: ToolContext): ToolStreamGenerator { + captured = (ctx.toolUse.input ?? {}) as Record; + return new ToolResultBlock({ + toolUseId: ctx.toolUse.toolUseId, + status: "success", + content: [new TextBlock("ok")], + }); + }, + }; + + const subagent = new Agent({ + model, + tools: [renderTool], + systemPrompt: prompt, + }); + const emit = options.onStreamEvent; + const catalogId = options.catalogId; + // Tracks the in-flight render_a2ui block between toolUseStart and blockStop. + let liveRenderCallId: string | null = null; + let sawRenderStart = false; + // Whether the host catalog id has been spliced into the streamed args for + // the current call yet (reset per render start). + let catalogPrefixed = false; + try { + // Stream (not invoke) so the render_a2ui arg deltas can be surfaced to the + // AG-UI wire as they generate — the middleware's building/progressive-paint + // lifecycle depends on seeing them live. + const gen = subagent.stream( + messages as never, + options.cancelSignal ? { cancelSignal: options.cancelSignal } : undefined, + ); + for await (const ev of gen) { + if (!emit) continue; + // Agent.stream() wraps raw model events in `modelStreamUpdateEvent` + // decorators (same unwrap the adapter's main loop performs). + const unwrapped = + ev && + typeof ev === "object" && + (ev as { type?: string }).type === "modelStreamUpdateEvent" && + "event" in (ev as object) + ? (ev as { event: unknown }).event + : ev; + const e = unwrapped as { + type?: string; + start?: { type?: string; toolUseId?: string; name?: string }; + delta?: { type?: string; input?: string }; + }; + if ( + e?.type === "modelContentBlockStartEvent" && + e.start?.type === "toolUseStart" + ) { + // ANY new tool block closes a still-open render call first (a missing + // blockStop must not leave an unclosed inner TOOL_CALL_START on the + // wire, and a foreign tool's arg deltas must never attribute to it). + if (liveRenderCallId) { + emit({ kind: "end", toolCallId: liveRenderCallId }); + liveRenderCallId = null; + } + if (e.start.name !== RENDER_A2UI_TOOL_NAME) continue; + // `||` (not `??`): an empty-string toolUseId must take the fallback — + // a falsy live id would disable every close/delta guard below. + liveRenderCallId = e.start.toolUseId || `a2ui-render-${++a2uiRenderSeq}`; + sawRenderStart = true; + catalogPrefixed = false; + emit({ + kind: "start", + toolCallId: liveRenderCallId, + toolCallName: RENDER_A2UI_TOOL_NAME, + }); + } else if ( + liveRenderCallId && + e?.type === "modelContentBlockDeltaEvent" && + e.delta?.type === "toolUseInputDelta" && + typeof e.delta.input === "string" + ) { + let delta = e.delta.input; + // Stamp the host catalog id into the FIRST chunk by splicing it right + // after the opening brace, so the accumulated args become + // `{"catalogId": "", ...}` — valid JSON the middleware's progressive + // paint reads the id from. The model never emits catalogId itself. + if (catalogId && !catalogPrefixed) { + const brace = delta.indexOf("{"); + if (brace !== -1) { + delta = + delta.slice(0, brace + 1) + + `"catalogId": ${JSON.stringify(catalogId)}, ` + + delta.slice(brace + 1); + catalogPrefixed = true; + } + } + emit({ kind: "args", toolCallId: liveRenderCallId, delta }); + } else if (liveRenderCallId && e?.type === "modelContentBlockStopEvent") { + emit({ kind: "end", toolCallId: liveRenderCallId }); + liveRenderCallId = null; + } + } + } catch (err) { + if (emit && liveRenderCallId) { + // The provider stream died mid-call: close the live synthetic call + // before unwinding — an unclosed inner TOOL_CALL_START is a + // wire-protocol violation, and the next recovery attempt would open a + // fresh call on top of it. + emit({ kind: "end", toolCallId: liveRenderCallId }); + liveRenderCallId = null; + } + if (classifyA2UISubagentError(err, !!options.cancelSignal?.aborted) === "rethrow") { + throw err; + } + // A genuine model/network error must not crash the whole turn — the recovery + // design guarantees the conversation stays usable. Log it (fail-loud) and + // return null so the loop records a failed attempt and retries or emits the + // tasteful hard-failure envelope. + DEFAULT_LOGGER.warn( + `[@ag-ui/aws-strands] A2UI sub-agent invoke failed; treating as a failed attempt: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } + if (emit) { + if (liveRenderCallId) { + // Stream ended without a blockStop for the live call — close it. + emit({ kind: "end", toolCallId: liveRenderCallId }); + liveRenderCallId = null; + } else if (!sawRenderStart && captured !== null) { + // The provider invoked the bound render tool without emitting any + // content-block events: synthesize the full triplet so the middleware + // still sees components before the result (no bulk paint). NOTE: the + // Python adapter additionally handles a mid-call parsed-dict input + // shape; the TS SDK delivers tool input exclusively via + // toolUseInputDelta frames, so that fallback has no analog here. + const syntheticId = `a2ui-render-${++a2uiRenderSeq}`; + emit({ + kind: "start", + toolCallId: syntheticId, + toolCallName: RENDER_A2UI_TOOL_NAME, + }); + emit({ + kind: "args", + toolCallId: syntheticId, + delta: JSON.stringify( + catalogId && captured + ? { ...(captured as Record), catalogId } + : captured, + ), + }); + emit({ kind: "end", toolCallId: syntheticId }); + } + } + return captured; +} + +// --------------------------------------------------------------------------- +// Message-shape helpers +// --------------------------------------------------------------------------- + +/** Minimal structural view of a Strands message (role + content blocks). */ +interface StrandsLikeMessage { + role?: string; + content?: unknown; +} + +/** + * Extract a toolUse `{ name }` from a Strands content block, handling both the + * class-instance form (`ToolUseBlock`, `type:"toolUseBlock"`, `name` on the + * block) and the serialized wrapped-data form (`{ toolUse: { name } }`). + */ +function readToolUse(block: unknown): { name?: string } | null { + const b = block as { type?: string; name?: string; toolUse?: { name?: string } }; + if (b?.type === "toolUseBlock") return { name: b.name }; + if (b?.toolUse) return { name: b.toolUse.name }; + return null; +} + +/** + * Extract a toolResult `{ toolUseId, content }` from a Strands content block, + * handling the class-instance form (`ToolResultBlock`, `type:"toolResultBlock"`) + * and the serialized wrapped-data form (`{ toolResult: { ... } }`). + */ +function readToolResult( + block: unknown, +): { toolUseId?: string; content?: unknown } | null { + const b = block as { + type?: string; + toolUseId?: string; + content?: unknown; + toolResult?: { toolUseId?: string; content?: unknown }; + }; + if (b?.type === "toolResultBlock") + return { toolUseId: b.toolUseId, content: b.content }; + if (b?.toolResult) return b.toolResult; + return null; +} + +/** Returns true if a message's content holds a toolUse block for `toolName`. */ +function hasToolUseFor(message: StrandsLikeMessage, toolName: string): boolean { + const content = message?.content; + if (!Array.isArray(content)) return false; + return content.some((block) => readToolUse(block)?.name === toolName); +} + +/** + * Drop the trailing in-flight `toolName` call. When the model invokes the + * generate tool, the assistant turn carrying that `toolUse` is the last message + * and has no matching `toolResult` yet — passing it to the sub-agent (which + * lacks the tool) is malformed. Only strips when the LAST message is that call, + * so a normal user turn at the tail is preserved. + */ +export function stripInFlightToolCall( + messages: T[], + toolName: string, +): T[] { + const last = messages[messages.length - 1]; + if (last && last.role === "assistant" && hasToolUseFor(last, toolName)) { + return messages.slice(0, -1); + } + // Copy in the no-strip branch too — the input is live agent state + // (`ctx.agent.messages`); returning it by reference invites accidental + // mutation of the agent's history. + return messages.slice(); +} + +/** + * Reconstruct the AG-UI `role:"tool"` messages the toolkit's `findPriorSurface` + * needs (used only for `intent:"update"`) from Strands history. Strands carries + * tool results as `toolResult` blocks (typically nested in user turns); we emit + * one AG-UI tool message per result whose content is the result text — i.e. the + * prior `a2ui_operations` envelope JSON string when the result was an A2UI + * render. Non-result content is ignored; this is intentionally narrow because + * `findPriorSurface` only inspects `role:"tool"` JSON-string content. + */ +/** + * Extract text from a Strands `toolResult.content` for A2UI detection. Robust to + * every shape the SDK produces: a raw string; class-instance blocks + * (`{ type:"textBlock", text }` / `{ type:"jsonBlock", json }`); and the + * SERIALIZED data form, which is a bare `{ text }` / `{ json }` with NO `type` + * discriminant (what `_buildStrandsHistory` emits and `fromMessageData` carries). + * `flattenContentToText` only handles the `type`-tagged text forms, so relying + * on it alone silently misses prior surfaces in dev-wired update history. + */ +function extractToolResultText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return flattenContentToText(content); + const parts: string[] = []; + for (const block of content) { + const b = block as { type?: string; text?: unknown; json?: unknown }; + if (typeof b?.text === "string") parts.push(b.text); + else if (b?.json !== undefined) parts.push(JSON.stringify(b.json)); + } + return parts.join(""); +} + +export function strandsToolResultsToAgui( + messages: StrandsLikeMessage[], +): AguiMessage[] { + const out: AguiMessage[] = []; + let fallbackSeq = 0; + for (const message of messages) { + const content = message?.content; + if (!Array.isArray(content)) continue; + for (const block of content) { + const result = readToolResult(block); + if (!result) continue; + const text = extractToolResultText(result.content); + if (!text || !text.includes(A2UI_OPERATIONS_KEY)) continue; + // Unique fallback id per result so two id-less prior results don't alias. + // `||` (not `??`): empty-string ids must also take the unique fallback. + const id = result.toolUseId || `a2ui-prior-${fallbackSeq++}`; + out.push({ + id, + role: "tool", + toolCallId: id, + content: text, + } as AguiMessage); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Auto-inject decision +// --------------------------------------------------------------------------- + +/** Backend override knobs (mirrors the runtime `injectA2UITool` flag). */ +export interface A2UIInjectConfig { + /** + * Inject `generate_a2ui` regardless of the runtime flag (for non-CopilotKit + * hosts). `true` uses the default tool name; a string also sets the name of + * the injected render tool to drop. + */ + injectA2UITool?: boolean | string; + /** Inline catalog forwarded to the recovery loop (overrides context). */ + catalog?: A2UIValidationCatalog; + /** + * Catalog id stamped into every `createSurface` op. Must match the catalog + * the host's renderer registered (e.g. the dojo's dynamic catalog), otherwise + * the renderer can't resolve the surface's components. Mirrors LangGraph's + * `getA2UITools({ defaultCatalogId })`. Falls back to the toolkit's basic + * catalog when unset. + */ + defaultCatalogId?: string; + /** + * Generation/design/composition prompt knobs forwarded to the sub-agent. Set + * `guidelines.compositionGuide` to teach the sub-agent the host catalog's + * components (names + props) — required for a real model to compose them, + * mirroring LangGraph's `getA2UITools({ guidelines })`. + */ + guidelines?: A2UIGuidelines; + /** + * Recovery loop config (attempt cap, retry-UI threshold) for the + * auto-injected tool. Defaults to the toolkit's `MAX_A2UI_ATTEMPTS` (3). + */ + recovery?: A2UIRecoveryConfig; +} + +/** The injection decision: what to register and what to drop. */ +export interface A2UIInjectionPlan { + /** The `generate_a2ui` tool to register on the agent. */ + tool: Tool; + /** Name the tool is registered under. */ + toolName: string; + /** Injected render-tool names to drop so the model calls `generate_a2ui`. */ + dropToolNames: string[]; + /** Catalog resolved from context / config, passed to the recovery loop. */ + catalog?: A2UIValidationCatalog; +} + +export interface PlanA2UIInjectionInput { + /** Model inferred from the wrapped agent (`null` for orchestrators). */ + model: TModel | null | undefined; + /** The run input — read for `forwardedProps.injectA2UITool`, messages, state, catalog context. */ + input: RunAgentInput; + /** Tool names already on the agent (user-prevails dedup). */ + existingToolNames: string[]; + /** Backend override config. */ + config?: A2UIInjectConfig; + /** Logger for the orchestrator skip warning (only `warn` is used). */ + log?: Pick; +} + +/** + * Decide whether to auto-inject `generate_a2ui` for this run, mirroring the + * LangGraph contract ("no injectA2UITool, no injection"): + * + * 1. Off unless the runtime forwarded `injectA2UITool` (`true`, or a string + * naming the injected RENDER tool to drop) OR a backend + * `config.injectA2UITool` override is set. + * 2. USER PREVAILS — if the dev already wired `generate_a2ui`, do not + * double-inject. (The per-run hook removes our OWN marked tool before + * computing `existingToolNames`, so this only catches a dev-wired tool.) + * Deliberately, NOTHING else is touched in this branch: the dev opted out + * of adapter management, so any runtime-injected render tool stays too. + * Limitation: the check is name-based — a dev-wired tool under a custom + * `toolName` is not recognized and auto-injection proceeds alongside it. + * 3. No inferable model (Graph/Swarm orchestrators) → warn + skip. + * 4. Otherwise build the tool (threading the run's AG-UI messages + state + + * guidelines), resolve the catalog, and drop the injected render tool. + */ +export function planA2UIInjection( + args: PlanA2UIInjectionInput, +): A2UIInjectionPlan | null { + const { input, existingToolNames, config, log = DEFAULT_LOGGER } = args; + + const forwarded = input.forwardedProps as + | { injectA2UITool?: boolean | string } + | undefined; + const flag = forwarded?.injectA2UITool ?? config?.injectA2UITool; + if (!flag) return null; + + const toolName = GENERATE_A2UI_TOOL_NAME; + // USER PREVAILS: explicit dev wiring wins — never double-inject. + if (existingToolNames.includes(toolName)) return null; + + if (args.model == null) { + log.warn( + "[@ag-ui/aws-strands] A2UI tool injection requested but no model could be " + + "inferred from the agent (multi-agent orchestrators like Graph/Swarm have " + + "no `.model`). Skipping auto-injection — wire getA2UITools() explicitly.", + ); + return null; + } + + const renderToolName = typeof flag === "string" ? flag : RENDER_A2UI_TOOL_NAME; + + // Lift the A2UI schema + remaining context under state["ag-ui"] so the + // sub-agent prompt carries the component schema + context, same as the + // LangGraph adapter routes context into graph state. Uses the shared toolkit + // split so both adapters agree on the schema-context description. + const [schemaValue, regularContext] = splitA2UISchemaContext( + input.context as Array> | undefined, + ); + const baseState: Record = + input.state && typeof input.state === "object" && !Array.isArray(input.state) + ? { ...(input.state as Record) } + : {}; + const agUi: Record = { context: regularContext }; + if (schemaValue !== undefined) agUi.a2ui_schema = schemaValue; + baseState["ag-ui"] = agUi; + + // Resolve the frontend-registered catalog from run state (native a2ui_schema + // or an "A2UI catalog" context entry) so surfaces bind to the host's catalog + // without the host hardcoding it. Backend config WINS when set. + const resolved = resolveA2UICatalog(baseState); + const [runtimeSchema, runtimeCatalogId] = resolved ?? [undefined, undefined]; + + // Explicit `config.catalog` still feeds the semantic-validation catalog; + // recovery stays structural-only when absent (the catalog is never + // auto-resolved from context for VALIDATION, only the id/guide below). + const catalog = config?.catalog; + const defaultCatalogId = config?.defaultCatalogId ?? runtimeCatalogId; + let guidelines = config?.guidelines; + if (guidelines === undefined && runtimeSchema !== undefined) { + guidelines = { compositionGuide: runtimeSchema }; + } + + const tool = getA2UITools( + { + model: args.model as unknown as Model, + toolName, + catalog, + defaultCatalogId, + guidelines, + recovery: config?.recovery, + }, + { aguiMessages: input.messages as AguiMessage[], state: baseState }, + ); + // Tag as ours so the per-run hook can refresh (not "user-prevails") it. + (tool as { [A2UI_AUTOINJECT_MARKER]?: true })[A2UI_AUTOINJECT_MARKER] = true; + + return { tool, toolName, dropToolNames: [renderToolName], catalog }; +} + +/** True if `tool` is a `generate_a2ui` this adapter auto-injected. */ +export function isAutoInjectedA2UITool(tool: unknown): boolean { + return ( + typeof tool === "object" && + tool !== null && + (tool as { [A2UI_AUTOINJECT_MARKER]?: boolean })[A2UI_AUTOINJECT_MARKER] === + true + ); +} diff --git a/integrations/aws-strands/typescript/src/agent.ts b/integrations/aws-strands/typescript/src/agent.ts index 274514d5e7..7fc713b221 100644 --- a/integrations/aws-strands/typescript/src/agent.ts +++ b/integrations/aws-strands/typescript/src/agent.ts @@ -45,6 +45,11 @@ import { type ToolResultContext, } from "./config"; import { syncProxyTools } from "./client-proxy-tool"; +import { + planA2UIInjection, + isAutoInjectedA2UITool, + A2UI_STREAM_KEY, +} from "./a2ui-tool"; import { convertAguiContentToStrands, flattenContentToText } from "./utils"; import type { SeenToolCall } from "./types"; import { DEFAULT_LOGGER, resolveLogger, type Logger } from "./logger"; @@ -702,6 +707,59 @@ export class StrandsAgent { } } + // A2UI auto-injection. When the runtime forwards + // `injectA2UITool` (or the host opts in via config), register a + // `generate_a2ui` recovery tool bound to this agent's model and drop the + // injected `render_a2ui` proxy so the model calls generate_a2ui directly. + // `planA2UIInjection` returns null when injection is off, the model can't be + // inferred (orchestrator), or the dev already wired generate_a2ui. + // Wrapped so a failure here can NEVER escape after RUN_STARTED with no + // terminal RUN_ERROR (this block runs before the main try/catch below). + // Auto-injection is best-effort: if it throws, log and run without A2UI + // rather than crashing the turn. + try { + const registry = strandsAgent.toolRegistry; + // Auto-inject requires enumerating the registry to (a) remove our OWN + // prior-turn tool so the refresh carries THIS turn's messages/state, and + // (b) honor USER-PREVAILS (never touch a dev-wired generate_a2ui). Without + // `list()` we can do neither safely, so SKIP rather than risk clobbering a + // developer's tool. The real @strands-agents/sdk ToolRegistry always + // provides list(); this guard is a fail-loud backstop for alternates. + if (typeof registry.list !== "function") { + const wantsInject = + (inputData.forwardedProps as { injectA2UITool?: unknown } | undefined) + ?.injectA2UITool ?? this.config.a2ui?.injectA2UITool; + if (wantsInject) { + this._log.warn( + "[@ag-ui/aws-strands] A2UI tool injection requested but toolRegistry.list() " + + "is unavailable; skipping auto-injection for this run.", + ); + } + } else { + for (const t of registry.list()) { + if (isAutoInjectedA2UITool(t)) registry.remove(t.name); + } + const existingToolNames = registry.list().map((t) => t.name); + const plan = planA2UIInjection({ + model: (strandsAgent as { model?: unknown }).model ?? null, + input: inputData, + existingToolNames, + config: this.config.a2ui, + log: this._log, + }); + if (plan) { + for (const name of plan.dropToolNames) registry.remove(name); + registry.add(plan.tool); + } + } + } catch (e) { + this._log.warn( + `[@ag-ui/aws-strands] A2UI auto-injection failed; running without A2UI for this turn: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + try { // Seed the running ``MessagesSnapshotEvent`` payload from the full // conversation history so each emitted snapshot carries prior turns @@ -1708,6 +1766,37 @@ export class StrandsAgent { type: EventType.STATE_SNAPSHOT, snapshot: (data as { state: Record }).state, }; + } else if (data && typeof data === "object" && A2UI_STREAM_KEY in data) { + // A2UI sub-agent streaming: re-emit the generate_a2ui + // tool's inner render_a2ui progress as synthetic TOOL_CALL events. + // The a2ui middleware's streaming path keys its "building" + // skeleton + progressive paint off these — without them the + // surface only paints in bulk from the final TOOL_CALL_RESULT. + const a2ui = ( + data as { + [A2UI_STREAM_KEY]: { + kind: "start" | "args" | "end"; + toolCallId: string; + toolCallName?: string; + delta?: string; + }; + } + )[A2UI_STREAM_KEY]; + if (a2ui.kind === "start") { + yield { + type: EventType.TOOL_CALL_START, + toolCallId: a2ui.toolCallId, + toolCallName: a2ui.toolCallName ?? "render_a2ui", + }; + } else if (a2ui.kind === "args" && a2ui.delta) { + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: a2ui.toolCallId, + delta: a2ui.delta, + }; + } else if (a2ui.kind === "end") { + yield { type: EventType.TOOL_CALL_END, toolCallId: a2ui.toolCallId }; + } } continue; } diff --git a/integrations/aws-strands/typescript/src/config.ts b/integrations/aws-strands/typescript/src/config.ts index 8d093004ad..31e97d5525 100644 --- a/integrations/aws-strands/typescript/src/config.ts +++ b/integrations/aws-strands/typescript/src/config.ts @@ -2,6 +2,7 @@ import type { RunAgentInput, BaseEvent } from "@ag-ui/core"; import type { SessionManager } from "@strands-agents/sdk"; +import type { A2UIInjectConfig } from "./a2ui-tool"; import type { Logger } from "./logger"; @@ -161,6 +162,22 @@ export interface StrandsAgentConfig { * TypeScript-only. Default: `false`. */ emitChunkEvents?: boolean; + /** + * A2UI auto-injection config — everything A2UI-related in one place. + * When the CopilotKit runtime forwards `injectA2UITool` (or `a2ui.injectA2UITool` + * opts in on a host that doesn't), the adapter injects a `generate_a2ui` + * recovery tool and infers the model from the wrapped agent — no manual + * `getA2UITools()` needed. Knobs: + * - `injectA2UITool` — opt in without the runtime flag; a string also names + * the injected render tool to drop. + * - `defaultCatalogId` — catalog id stamped into auto-injected surfaces + * (must match the host renderer's catalog). + * - `guidelines.compositionGuide` — teaches the sub-agent the catalog's + * components; required for a real model to compose them. + * - `catalog` — inline catalog for catalog-aware (semantic) recovery. + * - `recovery` — attempt cap / retry-UI threshold. + */ + a2ui?: A2UIInjectConfig; /** * Optional injectable logger. Mirrors the Python adapter's * `logging.getLogger("ag_ui_strands")`: the default surfaces `warn` / `error` diff --git a/integrations/aws-strands/typescript/src/index.ts b/integrations/aws-strands/typescript/src/index.ts index a3054763c5..274f35c869 100644 --- a/integrations/aws-strands/typescript/src/index.ts +++ b/integrations/aws-strands/typescript/src/index.ts @@ -17,6 +17,21 @@ export type { StrandsToolRegistry } from "./client-proxy-tool"; export { convertAguiContentToStrands, flattenContentToText } from "./utils"; +export { + getA2UITools, + planA2UIInjection, + isAutoInjectedA2UITool, + A2UI_STREAM_KEY, +} from "./a2ui-tool"; +export type { + A2UIToolParams, + A2UIToolGlue, + A2UIInjectConfig, + A2UIInjectionPlan, + A2UIRenderStreamEvent, + PlanA2UIInjectionInput, +} from "./a2ui-tool"; + // Server-side Express transport helpers (`createStrandsApp`, // `addStrandsExpressEndpoint`, `addPing`, `addCapabilities`, // `capabilitiesFor`, `DEFAULT_CAPABILITIES`, and associated types) live at diff --git a/integrations/aws-strands/typescript/src/server.ts b/integrations/aws-strands/typescript/src/server.ts index 31baaf9c81..2a121117c3 100644 --- a/integrations/aws-strands/typescript/src/server.ts +++ b/integrations/aws-strands/typescript/src/server.ts @@ -41,7 +41,15 @@ export interface CreateStrandsAppOptions { capabilitiesPath?: string | null; /** Override capabilities advertised at {@link CreateStrandsAppOptions.capabilitiesPath}. */ capabilities?: StrandsAguiCapabilitiesOverrides; - /** Override CORS origin. Default `*` (wide-open, matches the Python adapter). */ + /** + * Override CORS origin. Default `"*"` (wide-open, matches the Python adapter, + * which configures Starlette `CORSMiddleware` with `allow_origins=["*"]`). + * + * Note: with the `cors` package, a literal `"*"` is emitted verbatim as + * `Access-Control-Allow-Origin: *`, whereas `true` would reflect the request's + * `Origin` header back per-request — a different (more permissive) posture when + * combined with credentials. Stick to `"*"` to match the Python adapter. + */ corsOrigin?: string | string[] | boolean; } @@ -55,7 +63,7 @@ export async function createStrandsApp( pingPath = "/ping", capabilitiesPath = "/capabilities", capabilities, - corsOrigin = true, + corsOrigin = "*", } = options; // Lazy dynamic imports so `express` / `cors` are only required at runtime diff --git a/integrations/claude-agent-sdk/python/.gitignore b/integrations/claude-agent-sdk/python/.gitignore new file mode 100644 index 0000000000..25aacffde0 --- /dev/null +++ b/integrations/claude-agent-sdk/python/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +*.egg-info/ diff --git a/integrations/claude-agent-sdk/python/LICENSE b/integrations/claude-agent-sdk/python/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/claude-agent-sdk/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/claude-agent-sdk/python/README.md b/integrations/claude-agent-sdk/python/README.md index 6cfd8b71a0..7a5f5c54b4 100644 --- a/integrations/claude-agent-sdk/python/README.md +++ b/integrations/claude-agent-sdk/python/README.md @@ -51,7 +51,7 @@ The integration includes 5 example agents: cd integrations/claude-agent-sdk/python pip install -e . -# Start server (port 8888) +# Start server (port 8019) cd examples ANTHROPIC_API_KEY=sk-ant-xxx python server.py diff --git a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/__init__.py b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/__init__.py index a8c64def4e..8b5c4d278d 100644 --- a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/__init__.py +++ b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/__init__.py @@ -14,6 +14,8 @@ https://platform.claude.com/docs/en/agent-sdk/python """ +from importlib.metadata import version, PackageNotFoundError + from .adapter import ClaudeAgentAdapter from .endpoint import add_claude_fastapi_endpoint from .config import ( @@ -22,7 +24,10 @@ AG_UI_MCP_SERVER_NAME, ) -__version__ = "0.1.0" +try: + __version__ = version("ag-ui-claude-sdk") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" __all__ = [ "ClaudeAgentAdapter", "add_claude_fastapi_endpoint", diff --git a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/adapter.py b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/adapter.py index de7a09fad4..08ec9d95bf 100644 --- a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/adapter.py +++ b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/adapter.py @@ -86,7 +86,7 @@ def __init__( description: str = "", max_workers: int = 1000, worker_ttl_seconds: float = 1800, # 30 min - query_timeout_seconds: Optional[float] = None, + query_timeout_seconds: Optional[float] = 300, # 5 min; bounds a hung/slow worker ): self.name = name self.description = description @@ -94,10 +94,44 @@ def __init__( self._max_workers = max_workers self._worker_ttl_seconds = worker_ttl_seconds self._query_timeout_seconds = query_timeout_seconds - self._workers: Dict[str, Dict] = {} # changed from Dict[str, SessionWorker] + # thread_id -> {"worker": SessionWorker, "last_used": datetime, "active": bool, "active_runs": int} + self._workers: Dict[str, Dict] = {} self._state_locks: Dict[str, asyncio.Lock] = {} + # Per-thread RUN-ADMISSION lock. This is a SEPARATE lock from + # ``_state_locks`` on purpose: ``_state_locks[thread_id]`` is acquired + # mid-stream by the state-management-tool path (``async with lock:`` in + # ``_stream_claude_sdk``), and ``asyncio.Lock`` is non-reentrant, so + # reusing it for run admission would self-deadlock the instant the model + # emits a state-update tool call. Lock ordering is fixed: the run-lock is + # OUTERMOST (acquired at admission, before streaming / before + # RUN_STARTED) and the state-lock is INNERMOST (acquired only mid-stream). + # No path may hold ``_state_locks`` then wait on ``_run_locks``. + self._run_locks: Dict[str, asyncio.Lock] = {} self._per_thread_state: Dict[str, Any] = {} # thread_id -> current state - self._per_thread_result: Dict[str, Any] = {} # thread_id -> last result data + # Per-RUN result keyed by (thread_id, run_id). ``RUN_FINISHED.result`` is + # per-run by definition, so it must not share a per-thread slot that a + # concurrent/serialized peer run could clobber. + self._per_run_result: Dict[tuple, Any] = {} # (thread_id, run_id) -> result data + # Strong references to fire-and-forget cleanup tasks (e.g. worker.stop() + # during eviction). Without this the only reference is local and the + # event loop keeps only a weak reference, so a pending stop task can be + # garbage-collected mid-flight before the worker actually shuts down. + # We discard each task from the set when it completes. (Item 7) + self._pending_tasks: set = set() + + def _spawn_cleanup_task(self, coro) -> "asyncio.Task": + """Schedule a fire-and-forget cleanup coroutine, retaining a strong + reference until it completes so it cannot be GC'd mid-flight.""" + task = asyncio.create_task(coro) + self._pending_tasks.add(task) + + def _done(t: "asyncio.Task") -> None: + self._pending_tasks.discard(t) + if t.exception() is not None: + logger.warning(f"Worker eviction error: {t.exception()}") + + task.add_done_callback(_done) + return task async def interrupt(self, thread_id: Optional[str] = None) -> None: """Interrupt the active query for a thread, or all workers if no thread specified.""" @@ -107,15 +141,50 @@ async def interrupt(self, thread_id: Optional[str] = None) -> None: for entry in self._workers.values(): await entry["worker"].interrupt() + def _drop_thread_results(self, thread_id: str) -> None: + """Drop every per-run result entry belonging to ``thread_id``. + + ``_per_run_result`` is keyed by ``(thread_id, run_id)``; thread-scoped + cleanup (eviction / clear_session / error path) must purge all of a + thread's run results, not a single run.""" + for key in [k for k in self._per_run_result if k[0] == thread_id]: + self._per_run_result.pop(key, None) + async def shutdown(self) -> None: """Gracefully stop all session workers. Call on server shutdown.""" for entry in list(self._workers.values()): await entry["worker"].stop() self._workers.clear() self._state_locks.clear() + # ``_run_locks`` is cleared ONLY here, on full adapter shutdown (no run + # can be in-flight or waiting past this point); it is intentionally NOT + # evicted per-thread — see ``_evict_workers`` for the rationale. + self._run_locks.clear() + self._per_run_result.clear() def _evict_workers(self) -> None: - """Evict idle workers by TTL and LRU cap.""" + """Evict idle workers by TTL and LRU cap. + + IMPORTANT: ``_run_locks[tid]`` is deliberately NOT popped here (nor on + ``clear_session`` / the run error path). The run-admission lock's + lifecycle is run SERIALIZATION, which must stay decoupled from + worker-cache eviction. Popping it opens an orphan race: a run B parked on + ``await run_lock.acquire()`` in the window after run A released the lock + (worker now idle, active_runs==0) but before B wakes can have its lock + entry popped by eviction; a later run D then ``setdefault``s a FRESH lock + and runs CONCURRENTLY with B — serialization defeated. Re-validating + identity after acquire (in ``run``) is not sufficient alone, because a + held/waited lock can still be popped and re-created. So we leave run-lock + entries resident. Only ``_run_locks`` is exempt from eviction here: + ``_state_locks`` IS still popped (below), because it is acquired only + mid-stream UNDER the run-lock, so a live run always holds the run-lock + while touching it and it can never be orphaned by eviction. This is + bounded by the number of distinct ``thread_id`` + values seen (each maps to one tiny ``asyncio.Lock``); a future + ``ThreadContext`` unification (one record per thread owning worker + all + locks + state, reaped together) is the long-term home for bounding it — + do NOT add a separate reaper here now. + """ now = datetime.now() # TTL eviction: remove idle workers older than TTL to_remove = [ @@ -124,11 +193,10 @@ def _evict_workers(self) -> None: ] for tid in to_remove: entry = self._workers.pop(tid) - task = asyncio.create_task(entry["worker"].stop()) - task.add_done_callback(lambda t: t.exception() and logger.warning(f"Worker eviction error: {t.exception()}")) + self._spawn_cleanup_task(entry["worker"].stop()) self._state_locks.pop(tid, None) self._per_thread_state.pop(tid, None) - self._per_thread_result.pop(tid, None) + self._drop_thread_results(tid) # LRU eviction: if still over cap, remove oldest idle entries while len(self._workers) > self._max_workers: @@ -137,9 +205,10 @@ def _evict_workers(self) -> None: break oldest_tid = min(idle, key=lambda x: x[1]["last_used"])[0] entry = self._workers.pop(oldest_tid) - task = asyncio.create_task(entry["worker"].stop()) - task.add_done_callback(lambda t: t.exception() and logger.warning(f"Worker eviction error: {t.exception()}")) + self._spawn_cleanup_task(entry["worker"].stop()) self._state_locks.pop(oldest_tid, None) + self._per_thread_state.pop(oldest_tid, None) + self._drop_thread_results(oldest_tid) async def clear_session(self, thread_id: str) -> None: """Stop and remove the session worker for a thread.""" @@ -147,6 +216,10 @@ async def clear_session(self, thread_id: str) -> None: if entry: await entry["worker"].stop() self._state_locks.pop(thread_id, None) + # see _evict_workers: _run_locks intentionally not evicted (only full + # ``shutdown`` clears the map). + self._per_thread_state.pop(thread_id, None) + self._drop_thread_results(thread_id) async def run(self, input_data: RunAgentInput) -> AsyncIterator[BaseEvent]: """Run the agent and yield AG-UI events.""" @@ -154,22 +227,119 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[BaseEvent]: thread_id = input_data.thread_id or str(uuid.uuid4()) run_id = input_data.run_id or str(uuid.uuid4()) - + result_key = (thread_id, run_id) + + # ── Run-admission serialization (Fix 1) ── + # Acquire the per-thread RUN lock at admission — BEFORE worker.query() / + # RUN_STARTED — and hold it across the WHOLE run, releasing in the + # ``finally`` (and therefore on every ``except`` path too). Effect: a + # second run on the same thread_id waits here until the first emits + # RUN_FINISHED and releases; different thread_ids stay concurrent (the + # lock is per-thread). This is a DISTINCT lock from ``_state_locks`` + # (acquired mid-stream on the state-update-tool path); reusing the + # non-reentrant state-lock would self-deadlock. Lock ordering is fixed: + # run-lock OUTERMOST, state-lock INNERMOST. + # Acquire the CURRENT lock entry, then re-validate identity: if the entry + # in ``_run_locks`` changed while we were parked (defense-in-depth against + # any residual repopulation race — note eviction no longer pops the lock, + # so this loop normally runs once), release the stale lock and retry on + # the current one. Loop until we hold the lock that is actually the live + # ``_run_locks[thread_id]``, so no two runs can ever hold "the" run-lock + # for the same thread at once. + while True: + run_lock = self._run_locks.setdefault(thread_id, asyncio.Lock()) + await run_lock.acquire() + if self._run_locks.get(thread_id) is run_lock: + break + # A different lock is now the live entry; we acquired a stale one. + run_lock.release() + + # Re-seed per-thread state for THIS run, now that we hold the thread + # exclusively. Fresh ``input_data.state`` REPLACES any prior thread state + # (documented reset semantics); doing it under the run-lock keeps the + # reset per-run rather than racing a peer's seed. ``_per_run_result`` is + # keyed per-run so a serialized peer can never clobber it (Fix 4). self._per_thread_state[thread_id] = input_data.state - self._per_thread_result[thread_id] = None - + self._per_run_result[result_key] = None + + # Set True only once this run has been counted into a worker's + # ``active_runs`` refcount, so the ``finally`` block decrements exactly + # the runs it incremented. The fail-loud dead-worker-with-live-peer path + # below returns WITHOUT counting itself in, so it must leave this False + # to avoid decrementing the peer's refcount. (Item 7a) + counted_in = False + try: - # Get or create worker for this thread + # Get or create worker for this thread. + # Guard against a poisoned cache entry: if a previously-cached + # worker's background task has died (e.g. client.connect() failed), + # reusing it would hang forever on a queue nothing drains. Evict the + # dead worker and fall through to creating a fresh one. entry = self._workers.get(thread_id) + if entry is not None and not entry["worker"].is_alive(): + if entry.get("active_runs", 0) > 0: + # DEFENSE-IN-DEPTH / UNREACHABLE under run-admission + # serialization (Fix 1): the per-thread run-lock admits one + # run at a time, so while THIS run holds the lock no peer run + # on the same thread can be mid-stream (``active_runs`` is + # per-thread and capped at 1). This branch is retained as a + # belt-and-suspenders guard in case that invariant is ever + # violated by a future change. If somehow a peer IS streaming + # on this (now-dead) worker, we are wedged between two + # unacceptable options: + # * REUSE the dead worker — querying it would hang this + # arriving run forever (the peer's exited run-loop will + # never service our output queue). + # * EVICT (pop+stop) the shared entry — that tears the + # worker out from under the live peer (item-7 violation). + # So FAIL LOUD instead: surface a descriptive RunErrorEvent + # and stop, leaving the peer's entry (and its refcount) + # completely untouched. ``counted_in`` stays False so the + # ``finally`` block does NOT decrement the peer's refcount. + logger.error( + f"Worker for thread={thread_id} is dead but a peer run is " + f"still active (active_runs={entry.get('active_runs')}); " + f"failing this run loudly rather than reusing (hang risk) " + f"or evicting (would corrupt the live peer)" + ) + yield RunErrorEvent( + type=EventType.RUN_ERROR, + thread_id=thread_id, + run_id=run_id, + message=( + f"cannot start run on thread {thread_id}: its worker " + f"has terminated while another run is still active" + ), + ) + return + else: + logger.warning( + f"Evicting dead worker for thread={thread_id} (task terminated); creating fresh worker" + ) + dead_entry = self._workers.pop(thread_id, None) + if dead_entry is not None: + await dead_entry["worker"].stop() + self._state_locks.pop(thread_id, None) + entry = None + if entry is None: options = self.build_options(input_data, thread_id=thread_id) worker = SessionWorker(thread_id, options) await worker.start() - entry = {"worker": worker, "last_used": datetime.now(), "active": True} + # ``active_runs`` is a refcount of in-flight run() invocations + # sharing this worker. A plain ``active`` bool wedged on + # concurrent reuse: the first run to finish flipped it False + # while a second run was still streaming, making the worker + # evictable mid-stream. The bool is kept (derived from the + # count) for callers/tests that read it. (Item 7a) + entry = {"worker": worker, "last_used": datetime.now(), "active": True, "active_runs": 1} self._workers[thread_id] = entry + counted_in = True self._evict_workers() logger.debug(f"Created worker for thread={thread_id}") else: + entry["active_runs"] = entry.get("active_runs", 0) + 1 + counted_in = True entry["active"] = True entry["last_used"] = datetime.now() worker = entry["worker"] @@ -227,12 +397,13 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[BaseEvent]: ): yield event - # Emit RUN_FINISHED + # Emit RUN_FINISHED — read THIS run's own result (keyed per-run, so a + # serialized peer on the same thread cannot have clobbered it). (Fix 4) yield RunFinishedEvent( type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id, - result=self._per_thread_result.get(thread_id, None), + result=self._per_run_result.get(result_key, None), ) except asyncio.TimeoutError as e: @@ -245,11 +416,29 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[BaseEvent]: ) except Exception as e: logger.error(f"Error in run: {e}") - # Evict broken worker - broken_entry = self._workers.pop(thread_id, None) - if broken_entry: - await broken_entry["worker"].stop() - self._state_locks.pop(thread_id, None) + # Evict the broken worker — but ONLY if this is the last in-flight run + # sharing it. The ``active_runs > 1`` guard below is DEFENSE-IN-DEPTH / + # UNREACHABLE under run-admission serialization (Fix 1): the per-thread + # run-lock caps a thread's concurrent runs at 1, so when this run + # errors there is no peer run still streaming on the same thread, and + # the ``else`` branch (pop + stop the solo worker) is the live path. + # The guard is retained so that, were serialization ever broken, + # tearing the worker down here would not yank it out from under a peer; + # instead we would leave the shared entry intact and let the + # ``finally`` block decrement this run's refcount exactly once. (Item 7a) + entry = self._workers.get(thread_id) + if entry is not None and entry.get("active_runs", 1) > 1: + logger.warning( + f"Run errored but a peer run is still active on thread={thread_id}; " + f"keeping shared worker (active_runs={entry.get('active_runs')})" + ) + else: + broken_entry = self._workers.pop(thread_id, None) + if broken_entry: + await broken_entry["worker"].stop() + self._state_locks.pop(thread_id, None) + self._per_thread_state.pop(thread_id, None) + self._drop_thread_results(thread_id) yield RunErrorEvent( type=EventType.RUN_ERROR, thread_id=thread_id, @@ -257,11 +446,34 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[BaseEvent]: message=str(e), ) finally: + # Only decrement if THIS run was counted into the refcount. The + # fail-loud dead-worker-with-live-peer path returns early without + # counting itself in (``counted_in`` stays False), so it must not + # decrement — doing so would corrupt the live peer's refcount. (Item 7a) entry = self._workers.get(thread_id) - if entry: - entry["active"] = False + if entry and counted_in: + # Decrement the in-flight refcount. Under run-admission + # serialization (Fix 1) ``active_runs`` for a given thread is + # capped at 1, so this normally takes it 1 -> 0; the + # multi-run-sharing semantics below are DEFENSE-IN-DEPTH for the + # (now-unreachable) case of concurrent same-thread runs. As coded, + # the worker only becomes idle (and thus evictable) once ALL runs + # counted into it have finished, so a peer run could never be + # evicted mid-stream even if serialization were bypassed. (Item 7a) + remaining = entry.get("active_runs", 1) - 1 + entry["active_runs"] = max(remaining, 0) + entry["active"] = entry["active_runs"] > 0 entry["last_used"] = datetime.now() + # Drop THIS run's result slot (per-run keyed; thread-scoped cleanup + # paths above may already have purged it, hence pop with default). + self._per_run_result.pop(result_key, None) + + # Release the run-admission lock on EVERY exit path (success, error, + # timeout, and the fail-loud early return) so a waiting same-thread + # run can proceed. We acquired it unconditionally before this try. + run_lock.release() + def build_options(self, input_data: Optional[RunAgentInput] = None, thread_id: Optional[str] = None) -> "ClaudeAgentOptions": """Build ClaudeAgentOptions from base config + RunAgentInput.""" from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server @@ -389,6 +601,24 @@ def build_options(self, input_data: Optional[RunAgentInput] = None, thread_id: O ) + # Guard against kwargs that are not valid ClaudeAgentOptions fields. + # forwarded_props are whitelisted by NAME (ALLOWED_FORWARDED_PROPS), but + # some whitelisted runtime controls (e.g. ``temperature``, ``max_tokens``) + # are NOT ClaudeAgentOptions dataclass fields, so passing them straight + # through would raise a TypeError at runtime and crash the whole run. + # Drop unknown keys (with a warning) so an unexpected/forwarded prop can + # never wedge a run. (Item 6) + import dataclasses + valid_fields = {f.name for f in dataclasses.fields(ClaudeAgentOptions)} + unknown_keys = [k for k in merged_kwargs if k not in valid_fields] + if unknown_keys: + for k in unknown_keys: + logger.warning( + f"Dropping unsupported ClaudeAgentOptions kwarg: {k!r} " + f"(not a valid option field)" + ) + merged_kwargs.pop(k, None) + logger.debug(f"Creating ClaudeAgentOptions with merged kwargs: {merged_kwargs}") return ClaudeAgentOptions(**merged_kwargs) @@ -597,15 +827,26 @@ def flush_pending_msg(): message_id=reasoning_message_id, ) - # Emit encrypted signature if present - if accumulated_signature and current_message_id: + # Emit encrypted signature if present. + # + # Tie it to THIS thinking block (reasoning_message_id), + # not the enclosing assistant message id. A single + # message can contain multiple thinking blocks, each + # with its own signature_delta; binding to the message + # id (and resetting per block) attached a later block's + # signature to the wrong entity. Capture the block id + # before it is cleared below. (Item 2) + if accumulated_signature and reasoning_message_id: yield ReasoningEncryptedValueEvent( type=EventType.REASONING_ENCRYPTED_VALUE, subtype="message", - entity_id=current_message_id, + entity_id=reasoning_message_id, encrypted_value=accumulated_signature, ) + # Reset per-block signature accumulation so the next + # thinking block starts clean and cannot inherit this + # block's signature. accumulated_signature = "" reasoning_message_id = None @@ -621,8 +862,22 @@ def flush_pending_msg(): updates = json.loads(updates) lock = self._state_locks.setdefault(thread_id, asyncio.Lock()) async with lock: - prev_state_json = json.dumps(self._per_thread_state.get(thread_id), sort_keys=True, default=str) - new_state = {**self._per_thread_state.get(thread_id), **updates} if isinstance(self._per_thread_state.get(thread_id), dict) and isinstance(updates, dict) else updates + prior = self._per_thread_state.get(thread_id) + prev_state_json = json.dumps(prior, sort_keys=True, default=str) + # Merge dict updates onto the prior dict. + # When there is no prior state (None), + # treat it as an empty dict so a dict + # update MERGES onto {} rather than the + # `else` branch silently replacing state + # with `updates` (functionally the same + # for a bare dict, but the explicit form + # keeps the merge/replace semantics + # unambiguous and consistent with the + # non-streaming handler). + if isinstance(updates, dict) and (prior is None or isinstance(prior, dict)): + new_state = {**(prior or {}), **updates} + else: + new_state = updates new_state = fix_surrogates_deep(new_state) self._per_thread_state[thread_id] = new_state if json.dumps(self._per_thread_state.get(thread_id), sort_keys=True, default=str) != prev_state_json: @@ -735,7 +990,8 @@ def flush_pending_msg(): if tool_id and tool_id in processed_tool_ids: continue updated_state, tool_events = await handle_tool_use_block( - block, message, thread_id, run_id, self._per_thread_state.get(thread_id) + block, message, thread_id, run_id, self._per_thread_state.get(thread_id), + parent_message_id=current_message_id, ) if tool_id: processed_tool_ids.add(tool_id) @@ -784,8 +1040,10 @@ def flush_pending_msg(): is_error = getattr(message, 'is_error', None) result_text = getattr(message, 'result', None) - # Capture metadata for RunFinished event - self._per_thread_result[thread_id] = { + # Capture metadata for RunFinished event. Key per-run + # (thread_id, run_id) so a serialized peer on the same thread + # cannot clobber this run's result. (Fix 4) + self._per_run_result[(thread_id, run_id)] = { "is_error": is_error, "duration_ms": getattr(message, 'duration_ms', None), "duration_api_ms": getattr(message, 'duration_api_ms', None), diff --git a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/handlers.py b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/handlers.py index f69c8bc149..531c1013ba 100644 --- a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/handlers.py +++ b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/handlers.py @@ -1,5 +1,4 @@ """ -import uuid Event handlers for Claude SDK stream processing. Breaks down stream processing into focused handler functions. @@ -7,6 +6,7 @@ import json import logging +import uuid from typing import AsyncIterator, Any, Optional from ag_ui.core import ( @@ -31,77 +31,119 @@ async def handle_tool_use_block( thread_id: str, run_id: str, current_state: Optional[Any], + parent_message_id: Optional[str] = None, ) -> tuple[Optional[Any], AsyncIterator[BaseEvent]]: """ Handle ToolUseBlock from Claude SDK. - + Intercepts state management tool calls and emits STATE_SNAPSHOT. For regular tools, emits TOOL_CALL_START/ARGS events. - + Args: block: ToolUseBlock from Claude SDK message: Parent message containing the block thread_id: Thread identifier run_id: Run identifier current_state: Current state for state management tools - + parent_message_id: ID of the assistant message that owns this tool + call. The streaming path uses the current assistant message id for + ``ToolCallStartEvent.parent_message_id``; this mirrors that + semantics on the non-streaming fallback path. + Returns: Tuple of (updated_state, event_generator) """ tool_name = getattr(block, 'name', '') or 'unknown' tool_input = getattr(block, 'input', {}) or {} tool_id = getattr(block, 'id', None) or str(uuid.uuid4()) - parent_tool_use_id = getattr(message, 'parent_tool_use_id', None) - + # Strip MCP prefix for client matching (same as streaming path) tool_display_name = strip_mcp_prefix(tool_name) if tool_display_name != tool_name: logger.debug(f"Stripped MCP prefix in handler: {tool_name} -> {tool_display_name}") logger.debug(f"ToolUseBlock detected: {tool_name}") - - async def event_gen(): - nonlocal current_state - - # Intercept state management tool calls (check both prefixed and unprefixed names) - if _is_state_management_tool(tool_name): - logger.debug("Intercepting ag_ui_update_state tool call") - - # Extract state updates from tool input - state_updates = tool_input.get("state_updates", {}) - - # Parse if it's a JSON string - if isinstance(state_updates, str): - try: - state_updates = json.loads(state_updates) - logger.debug("Parsed state_updates from JSON string") - except json.JSONDecodeError as e: - logger.warning(f"Failed to parse state_updates JSON: {e}") - state_updates = {} - yield CustomEvent( - type=EventType.CUSTOM, - name="state_update_error", - value={"error": str(e)}, - ) - + + # Compute the merged state SYNCHRONOUSLY, before building the generator, so + # the returned first element reflects the post-merge state. The adapter + # persists this returned value (self._per_thread_state[thread_id]) BEFORE it + # iterates the event generator, so a value computed inside event_gen() would + # not yet exist when the tuple is built — the adapter would persist the + # stale pre-merge state while the emitted STATE_SNAPSHOT carried the merged + # state. Computing here keeps the returned/persisted state == the snapshot. + merged_state = current_state + # When the state_updates JSON fails to parse we emit ONLY a CUSTOM error and + # must NOT mutate state nor emit a STATE_SNAPSHOT (mirrors the streaming + # path in adapter.py). This flag carries that decision out to the generator. + state_parse_error: Optional[str] = None + # Whether the merge actually changed state. The streaming path only emits a + # STATE_SNAPSHOT when the merged state differs from the prior; mirror that + # here so a no-op update doesn't emit a spurious snapshot (Item 3). + state_changed: bool = False + + if _is_state_management_tool(tool_name): + logger.debug("Intercepting ag_ui_update_state tool call") + + # Extract state updates from tool input. Mirror the streaming path + # (adapter.py): when the "state_updates" key is absent, fall back to the + # whole tool_input object instead of an empty {} (Item 4). + state_updates = tool_input.get("state_updates", tool_input) + + # Parse if it's a JSON string (streaming re-parses nested JSON strings + # too — Item 4). + if isinstance(state_updates, str): + try: + state_updates = json.loads(state_updates) + logger.debug("Parsed state_updates from JSON string") + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse state_updates JSON: {e}") + state_parse_error = str(e) + + if state_parse_error is None: + prev_state_json = json.dumps(merged_state, sort_keys=True, default=str) + # Update current state - if isinstance(current_state, dict) and isinstance(state_updates, dict): - current_state = {**current_state, **state_updates} + if isinstance(merged_state, dict) and isinstance(state_updates, dict): + merged_state = {**merged_state, **state_updates} else: - current_state = state_updates + merged_state = state_updates # Fix any UTF-16 surrogates before Pydantic serialisation - current_state = fix_surrogates_deep(current_state) + merged_state = fix_surrogates_deep(merged_state) - # Emit STATE_SNAPSHOT with updated state - yield StateSnapshotEvent( - type=EventType.STATE_SNAPSHOT, - snapshot=current_state - ) - - logger.debug(f"Emitted STATE_SNAPSHOT with updated state") + # Mirror the streaming change check (adapter.py): only emit a + # snapshot if the merge actually changed the persisted state. + new_state_json = json.dumps(merged_state, sort_keys=True, default=str) + state_changed = new_state_json != prev_state_json + + async def event_gen(): + # Intercept state management tool calls (check both prefixed and unprefixed names) + if _is_state_management_tool(tool_name): + if state_parse_error is not None: + yield CustomEvent( + type=EventType.CUSTOM, + name="state_update_error", + value={"error": state_parse_error}, + ) + # Emit ONLY the error event — do not fall through and emit a + # spurious STATE_SNAPSHOT with un-updated state. Mirrors the + # streaming path (adapter.py), which emits the error alone. + return + + # Emit STATE_SNAPSHOT only when the merge actually changed state, + # matching the streaming path (Item 3). The snapshot carries the + # SAME merged state we return below, so the persisted state and the + # snapshot never diverge. + if state_changed: + yield StateSnapshotEvent( + type=EventType.STATE_SNAPSHOT, + snapshot=merged_state + ) + logger.debug("Emitted STATE_SNAPSHOT with updated state") + else: + logger.debug("State unchanged — suppressing no-op STATE_SNAPSHOT") return # Skip normal tool call events - + # Regular tool handling for non-state tools yield ToolCallStartEvent( type=EventType.TOOL_CALL_START, @@ -109,7 +151,7 @@ async def event_gen(): run_id=run_id, tool_call_id=tool_id, tool_call_name=tool_display_name, # Use unprefixed name - parent_message_id=parent_tool_use_id, + parent_message_id=parent_message_id, ) if tool_input: @@ -133,7 +175,7 @@ async def event_gen(): tool_call_id=tool_id, ) - return current_state, event_gen() + return merged_state, event_gen() async def handle_tool_result_block( @@ -165,33 +207,77 @@ async def handle_tool_result_block( # Parse tool result content for frontend rendering # Claude SDK tools return: [{"type": "text", "text": "{json_data}"}] # Frontend expects just the parsed json_data + # + # We track both the final string AND, when the content is a JSON *object*, + # the parsed object. The error path (below) needs the parsed object so it + # can add an "error" marker WITHOUT double-encoding it into a string. result_str = "" + parsed_obj = None # set only when the content is a JSON object (dict) + + def _normalize_text(text: str) -> None: + """Normalise a plain-text payload: parse JSON when possible (so the + frontend can access fields) else pass the raw text through unquoted. + + This is the single canonical encoding for textual content. Both the + list-of-text-blocks path and the bare-string path route through here so + the SAME logical payload reaches the frontend with the SAME encoding + regardless of which SDK shape delivered it (Item 5).""" + nonlocal result_str, parsed_obj + try: + parsed_json = json.loads(text) + result_str = json.dumps(parsed_json) + if isinstance(parsed_json, dict): + parsed_obj = parsed_json + except (json.JSONDecodeError, ValueError): + # Not JSON — pass the raw text through unquoted (NOT json.dumps, + # which would quote it and diverge from the list-text-block path). + result_str = text + if content is not None: try: # If content is a list of content blocks (Claude SDK format) if isinstance(content, list) and len(content) > 0: first_block = content[0] if isinstance(first_block, dict) and first_block.get("type") == "text": - # Extract the text content - text_content = first_block.get("text", "") - # Try to parse as JSON (tools often return JSON strings) - try: - parsed_json = json.loads(text_content) - # Use the parsed JSON directly so frontend can access fields - result_str = json.dumps(parsed_json) - except (json.JSONDecodeError, ValueError): - # Not JSON, use as-is - result_str = text_content + _normalize_text(first_block.get("text", "")) else: # Fallback: stringify the whole content result_str = json.dumps(content) + elif isinstance(content, str): + # Bare-string content: normalise identically to the inner text + # of a text block (Item 5) instead of json.dumps-quoting it. + _normalize_text(content) else: - # Fallback: stringify as-is + # Fallback: stringify as-is (dicts, scalars, empty lists, ...) result_str = json.dumps(content) except (TypeError, ValueError): result_str = str(content) - result_str = fix_surrogates(result_str) + # Propagate the SDK's error indication. AG-UI's ToolCallResultEvent has no + # dedicated error field, so a failed tool result would otherwise look + # identical to a successful one. Surface the error indicator (and log it) + # so downstream consumers can distinguish failures — but do it WITHOUT + # corrupting the payload: + # * JSON-object content: add an "error": True key to the object and emit + # the single-encoded object (consistent with the success shape). + # * Plain-string content: wrap as {"error": True, "content": } + # exactly once (no nested re-encode). + # + # Surrogate repair must happen on the string VALUE *before* it is embedded + # in any json.dumps: json.dumps (ensure_ascii) escapes lone surrogates into + # literal "\ud83c" text, which fix_surrogates (a UTF-16 round-trip) cannot + # subsequently repair. So we fix the raw content first, then serialise, and + # do not re-escape the already-repaired value. + if is_error: + logger.warning( + f"Tool result for tool_use_id={tool_use_id} reported is_error=True" + ) + if parsed_obj is not None: + result_str = json.dumps(fix_surrogates_deep({**parsed_obj, "error": True})) + else: + result_str = json.dumps({"error": True, "content": fix_surrogates(result_str)}) + else: + result_str = fix_surrogates(result_str) if tool_use_id: # NOTE: Do NOT emit TOOL_CALL_END here — it was already emitted @@ -200,8 +286,16 @@ async def handle_tool_result_block( # errors in the CopilotKit runtime. The TS adapter follows the same # pattern: tool result handling only emits TOOL_CALL_RESULT. - # Emit ToolCallResult with the actual result content + # Emit ToolCallResult with the actual result content. + # + # Nested / sub-agent results (e.g. Task calling WebSearch) carry a + # parent_tool_use_id. AG-UI's ToolCallResultEvent has no first-class + # field for it, so we surface the linkage via the protocol-standard + # ``raw_event`` escape hatch — only when present, so top-level results + # don't gain a spurious raw_event. (Item 8: previously this argument was + # accepted but never used, leaving the documented nested behavior inert.) result_message_id = f"{tool_use_id}-result" + raw_event = {"parent_tool_use_id": parent_tool_use_id} if parent_tool_use_id else None yield ToolCallResultEvent( type=EventType.TOOL_CALL_RESULT, thread_id=thread_id, @@ -210,4 +304,5 @@ async def handle_tool_result_block( tool_call_id=tool_use_id, content=result_str, role="tool", + raw_event=raw_event, ) diff --git a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/session.py b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/session.py index 38298036d1..71389e4327 100644 --- a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/session.py +++ b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/session.py @@ -36,6 +36,12 @@ def __init__(self, thread_id: str, options: Any): self._task: Optional[asyncio.Task] = None self._client: Optional[Any] = None self.session_id: Optional[str] = None + # Every output queue that has an in-flight consumer waiting on it. A + # query's queue is registered the instant it is enqueued (in ``query``) + # and deregistered once its terminal ``None`` sentinel has been pushed. + # On fatal worker death we fan out a terminal signal to ALL of these so a + # peer/queued query whose item never got serviced cannot hang forever. + self._inflight_queues: set[asyncio.Queue] = set() async def start(self) -> None: """Spawn the background task that owns the SDK client.""" @@ -44,6 +50,54 @@ async def start(self) -> None: self._task = asyncio.create_task( self._run(), name=f"session-worker-{self.thread_id}" ) + # If the background task dies for any reason (including a path that does + # not flow through the fatal-error branch, e.g. cancellation), make sure + # every still-waiting consumer gets a terminal signal rather than + # hanging on a queue nothing will ever drain. + self._task.add_done_callback(self._on_task_done) + + def _fanout_terminal(self, exc: Exception) -> None: + """Push WorkerError(exc) + the None sentinel to EVERY in-flight output + queue, then clear the registry. Idempotent per queue: a queue is removed + from the registry as soon as its own ``finally`` pushes its sentinel, so + this never double-signals a queue that already terminated normally.""" + queues = list(self._inflight_queues) + self._inflight_queues.clear() + for q in queues: + # ``put_nowait`` is safe: these are unbounded queues, and we are + # off the consumer's await path. + q.put_nowait(WorkerError(exc)) + q.put_nowait(None) + + def _on_task_done(self, task: "asyncio.Task") -> None: + """Done-callback: if the worker task ended while consumers were still + waiting (e.g. cancelled, or an exit path that bypassed the fatal-error + fan-out), terminate them so they don't hang.""" + if not self._inflight_queues: + return + exc: Exception + try: + task_exc = task.exception() + except asyncio.CancelledError: + task_exc = None + if task_exc is not None: + exc = task_exc if isinstance(task_exc, Exception) else RuntimeError(str(task_exc)) + else: + exc = RuntimeError( + f"session worker for thread={self.thread_id} terminated " + f"while a query was still in flight" + ) + self._fanout_terminal(exc) + + def is_alive(self) -> bool: + """Return True if the background task is running and able to serve queries. + + A worker whose ``_run`` task has finished (e.g. ``client.connect()`` + failed and the task fell through its ``finally``) can no longer drain + the input queue, so reusing it would hang the next ``query()`` forever. + Callers must treat a non-alive worker as dead and create a fresh one. + """ + return self._task is not None and not self._task.done() async def _run(self) -> None: """Main loop — runs entirely inside one stable async context.""" @@ -63,6 +117,11 @@ async def _run(self) -> None: break prompt, session_id, output_queue = item + # ``output_queue`` is a loop-local Optional that is unconditionally + # bound here (the ``_SHUTDOWN`` sentinel already broke out above), + # so it is never None on the ``.put`` calls below. Narrow it for + # the type checker (no runtime behavior change). + assert output_queue is not None try: await client.query(prompt, session_id=session_id) async for msg in client.receive_response(): @@ -78,12 +137,19 @@ async def _run(self) -> None: await output_queue.put(WorkerError(exc)) finally: await output_queue.put(None) + # This query terminated normally; drop it from the in-flight + # registry so a later fatal-death fan-out won't double-signal. + self._inflight_queues.discard(output_queue) except Exception as exc: logger.error(f"Session worker fatal error for thread={self.thread_id}: {exc}") - if output_queue is not None: - await output_queue.put(WorkerError(exc)) - await output_queue.put(None) # signal end-of-stream to consumer + # Fan the fatal error out to EVERY in-flight consumer — not just the + # currently-dequeued one. A peer/queued query whose item never got + # serviced (it is still sitting on the input queue, its output queue + # already registered by ``query``) would otherwise hang forever on a + # queue nothing drains. ``_fanout_terminal`` covers ``output_queue`` + # too (it is in the registry until its ``finally`` discards it). + self._fanout_terminal(exc) finally: self._client = None await self._graceful_disconnect(client) @@ -99,6 +165,11 @@ async def _graceful_disconnect(client: Any) -> None: async def query(self, prompt: str, session_id: str = "default") -> AsyncIterator[Any]: """Send prompt to the worker and yield SDK Message objects.""" output_queue: asyncio.Queue = asyncio.Queue() + # Register the output queue in the in-flight set BEFORE enqueuing the + # request, so that if the worker dies while this query is still queued + # (never dequeued), the fatal-death fan-out still terminates it. The + # worker's per-query ``finally`` (or the fan-out itself) deregisters it. + self._inflight_queues.add(output_queue) await self._input_queue.put((prompt, session_id, output_queue)) while True: item = await output_queue.get() diff --git a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/utils.py b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/utils.py index 3ed4ce1f58..c6224fb450 100644 --- a/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/utils.py +++ b/integrations/claude-agent-sdk/python/ag_ui_claude_sdk/utils.py @@ -17,7 +17,7 @@ def fix_surrogates(s: str) -> str: """Re-assemble lone UTF-16 surrogate pairs into proper Unicode codepoints. - LLMock (JavaScript) chunks JSON via ``String.slice()`` which operates on + Streamed JSON chunked in JavaScript via ``String.slice()`` operates on 16-bit code units. Emoji outside the BMP (e.g. U+1F35D 🍝) are two code units in JS (a surrogate pair), and ``slice`` can split them. When the chunks are reassembled in Python the string contains *paired* surrogates @@ -356,18 +356,26 @@ def build_agui_assistant_message( Returns: AG-UI AssistantMessage, or None if no user-visible content. """ + from claude_agent_sdk.types import TextBlock, ToolUseBlock + content_blocks = getattr(sdk_message, "content", []) or [] text_content = "" tool_calls: List[ToolCall] = [] for block in content_blocks: + # Dispatch on the real SDK block classes. The genuine + # claude_agent_sdk TextBlock/ToolUseBlock dataclasses do NOT expose a + # ``.type`` attribute, so keying off ``getattr(block, "type", None)`` + # silently dropped every real block. We keep a ``.type`` string + # fallback so dict-shaped / mock blocks that carry an explicit type + # still work. block_type = getattr(block, "type", None) - if block_type == "text": + if isinstance(block, TextBlock) or block_type == "text": text_content += getattr(block, "text", "") - elif block_type == "tool_use": + elif isinstance(block, ToolUseBlock) or block_type == "tool_use": raw_name = getattr(block, "name", "unknown") # Skip internal state management tool — not conversation history @@ -418,18 +426,36 @@ def build_agui_tool_message( Returns: AG-UI ToolMessage """ + def _normalize_text(text: str) -> str: + """Canonical textual-payload encoding: parse JSON when possible (so the + frontend can access fields) else pass the raw text through UNQUOTED. + + This mirrors ``handlers.py``'s ``_normalize_text`` for the live + TOOL_CALL_RESULT path (Item 5). Routing both the list-of-text-blocks + branch and the bare-string branch through here keeps MESSAGES_SNAPSHOT + encoding identical to TOOL_CALL_RESULT: the SAME logical tool result + reaches the frontend with the SAME encoding regardless of which SDK + shape (list vs. bare string) delivered it — in particular a bare string + is NOT json.dumps-quoted into '"plain"'.""" + try: + return json.dumps(json.loads(text)) + except (json.JSONDecodeError, ValueError): + # Not JSON — raw passthrough (NOT json.dumps, which would quote it + # and diverge from the list-text-block path). + return text + result_str = "" try: if isinstance(content, list) and len(content) > 0: first_block = content[0] if isinstance(first_block, dict) and first_block.get("type") == "text": - text = first_block.get("text", "") - try: - result_str = json.dumps(json.loads(text)) - except (json.JSONDecodeError, ValueError): - result_str = text + result_str = _normalize_text(first_block.get("text", "")) else: result_str = json.dumps(content) + elif isinstance(content, str): + # Bare-string content: normalise identically to the inner text of a + # text block (Item 5) instead of json.dumps-quoting it. + result_str = _normalize_text(content) elif content is not None: result_str = json.dumps(content) except (TypeError, ValueError): diff --git a/integrations/claude-agent-sdk/python/examples/README.md b/integrations/claude-agent-sdk/python/examples/README.md index 0f1c1eea2a..69ac97a162 100644 --- a/integrations/claude-agent-sdk/python/examples/README.md +++ b/integrations/claude-agent-sdk/python/examples/README.md @@ -14,7 +14,7 @@ cd examples ANTHROPIC_API_KEY=sk-ant-xxx python server.py ``` -Server runs on **http://localhost:8888** +Server runs on **http://localhost:8019** ## Testing with Dojo diff --git a/integrations/claude-agent-sdk/python/pyproject.toml b/integrations/claude-agent-sdk/python/pyproject.toml index 300d01e20e..86ba879641 100644 --- a/integrations/claude-agent-sdk/python/pyproject.toml +++ b/integrations/claude-agent-sdk/python/pyproject.toml @@ -1,14 +1,16 @@ [project] name = "ag-ui-claude-sdk" -version = "0.1.1" +version = "0.1.5" description = "AG-UI integration for Anthropic Claude Agent SDK" +license-files = ["LICENSE"] readme = "README.md" requires-python = ">=3.11" +license = "MIT" authors = [ - { name = "Ambient Code Platform" } + { name = "Ambient Code Platform", email = "gkrumbac@redhat.com" } ] dependencies = [ - "ag-ui-protocol>=0.1.0", + "ag-ui-protocol>=0.1.15", "claude-agent-sdk>=0.1.12", "anthropic>=0.68.0", "fastapi>=0.100.0", @@ -16,14 +18,24 @@ dependencies = [ "pydantic>=2.0.0", ] -[project.optional-dependencies] +[dependency-groups] dev = [ "pytest>=7.4.0", "pytest-asyncio>=0.21.0", "httpx>=0.24.0", ] +[tool.ag-ui.scripts] +test = "python -m pytest" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=77.0.0"] build-backend = "setuptools.build_meta" +[tool.setuptools.packages.find] +include = ["ag_ui_claude_sdk*"] + diff --git a/integrations/claude-agent-sdk/python/tests/__init__.py b/integrations/claude-agent-sdk/python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/integrations/claude-agent-sdk/python/tests/conftest.py b/integrations/claude-agent-sdk/python/tests/conftest.py new file mode 100644 index 0000000000..2dccf7885f --- /dev/null +++ b/integrations/claude-agent-sdk/python/tests/conftest.py @@ -0,0 +1,65 @@ +"""Shared fixtures and lightweight fakes for the Claude Agent SDK adapter tests. + +These tests exercise the *translation* layer (Claude Agent SDK message +objects -> AG-UI protocol events) and the pure helper utilities. None of them +call the Anthropic / Claude LLM API: the adapter is fed pre-constructed SDK +message objects, so the network is never touched and no aimock recording is +required. (aimock would be used only for a test that actually drives the LLM, +e.g. a live ``SessionWorker.query`` end-to-end test.) +""" + +from typing import Any, AsyncIterator, List + +import pytest + +from ag_ui.core import RunAgentInput + + +# --------------------------------------------------------------------------- +# Fake Claude Agent SDK stream / message shapes +# +# The adapter consumes objects from ``claude_agent_sdk``. We import the real +# block/message classes where their constructors are simple, and build tiny +# stand-ins for the streaming ``StreamEvent`` (which is just a wrapper around a +# raw event dict). +# --------------------------------------------------------------------------- + +from claude_agent_sdk.types import StreamEvent # noqa: E402 + + +def stream_event(event: dict, *, uuid: str = "evt", session_id: str = "thread-1") -> StreamEvent: + """Build a real StreamEvent wrapping a raw streaming event dict.""" + return StreamEvent(uuid=uuid, session_id=session_id, event=event) + + +async def aiter(items: List[Any]) -> AsyncIterator[Any]: + """Turn a list into an async iterator (a fake message stream).""" + for item in items: + yield item + + +@pytest.fixture +def make_input(): + """Factory for RunAgentInput with sensible defaults.""" + + def _make( + *, + thread_id: str = "thread-1", + run_id: str = "run-1", + messages=None, + tools=None, + state=None, + context=None, + forwarded_props=None, + ) -> RunAgentInput: + return RunAgentInput( + thread_id=thread_id, + run_id=run_id, + messages=messages or [], + tools=tools or [], + state=state if state is not None else None, + context=context or [], + forwarded_props=forwarded_props or {}, + ) + + return _make diff --git a/integrations/claude-agent-sdk/python/tests/test_adapter.py b/integrations/claude-agent-sdk/python/tests/test_adapter.py new file mode 100644 index 0000000000..6b12ac091c --- /dev/null +++ b/integrations/claude-agent-sdk/python/tests/test_adapter.py @@ -0,0 +1,828 @@ +"""Tests for ClaudeAgentAdapter event translation and option building. + +The adapter's job is to translate a Claude Agent SDK message stream into the +AG-UI protocol event sequence. We drive ``_stream_claude_sdk`` directly with a +fake stream of SDK ``StreamEvent`` / message objects, so no LLM call is made. + +We also test ``run()`` error handling by injecting a fake SessionWorker, and +``build_options`` merging behavior. +""" + +import json + +import pytest + +from ag_ui.core import EventType +from ag_ui_claude_sdk.adapter import ClaudeAgentAdapter +from ag_ui_claude_sdk.config import STATE_MANAGEMENT_TOOL_FULL_NAME, AG_UI_MCP_SERVER_NAME + +from ag_ui_claude_sdk.utils import extract_tool_names + +from .conftest import stream_event, aiter + + +def _types(events): + return [e.type for e in events] + + +async def _drive(adapter, stream_items, make_input, **input_kwargs): + """Run _stream_claude_sdk over a fake message stream and collect events.""" + inp = make_input(**input_kwargs) + frontend = set(extract_tool_names(inp.tools)) if inp.tools else set() + # Seed per-thread state as run() would. + adapter._per_thread_state[inp.thread_id] = inp.state + events = [] + async for ev in adapter._stream_claude_sdk( + aiter(stream_items), inp.thread_id, inp.run_id, inp, frontend + ): + events.append(ev) + return events + + +class TestStreamTextMessage: + @pytest.mark.asyncio + async def test_streamed_text_produces_start_content_end(self, make_input): + adapter = ClaudeAgentAdapter(name="t") + stream = [ + stream_event({"type": "message_start"}), + stream_event( + {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "Hello "}} + ), + stream_event( + {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "world"}} + ), + stream_event({"type": "message_stop"}), + ] + events = await _drive(adapter, stream, make_input) + types = _types(events) + assert EventType.TEXT_MESSAGE_START in types + assert EventType.TEXT_MESSAGE_END in types + contents = [e for e in events if e.type == EventType.TEXT_MESSAGE_CONTENT] + assert "".join(c.delta for c in contents) == "Hello world" + # START precedes content precedes END + assert types.index(EventType.TEXT_MESSAGE_START) < types.index(EventType.TEXT_MESSAGE_END) + + @pytest.mark.asyncio + async def test_messages_snapshot_emitted_at_end(self, make_input): + adapter = ClaudeAgentAdapter(name="t") + stream = [ + stream_event({"type": "message_start"}), + stream_event( + {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "Hi"}} + ), + stream_event({"type": "message_stop"}), + ] + events = await _drive(adapter, stream, make_input) + snapshots = [e for e in events if e.type == EventType.MESSAGES_SNAPSHOT] + assert len(snapshots) == 1 + assert any(getattr(m, "content", None) == "Hi" for m in snapshots[0].messages) + + +class TestStreamToolCall: + @pytest.mark.asyncio + async def test_backend_tool_call_sequence(self, make_input): + adapter = ClaudeAgentAdapter(name="t") + stream = [ + stream_event({"type": "message_start"}), + stream_event( + { + "type": "content_block_start", + "content_block": {"type": "tool_use", "id": "tc1", "name": "mcp__srv__lookup"}, + } + ), + stream_event( + { + "type": "content_block_delta", + "delta": {"type": "input_json_delta", "partial_json": '{"q":"x"}'}, + } + ), + stream_event({"type": "content_block_stop"}), + stream_event({"type": "message_stop"}), + ] + events = await _drive(adapter, stream, make_input) + types = _types(events) + assert EventType.TOOL_CALL_START in types + assert EventType.TOOL_CALL_ARGS in types + assert EventType.TOOL_CALL_END in types + start = next(e for e in events if e.type == EventType.TOOL_CALL_START) + assert start.tool_call_name == "lookup" # prefix stripped + # exactly one END for the one tool call + assert types.count(EventType.TOOL_CALL_END) == 1 + + @pytest.mark.asyncio + async def test_frontend_tool_halts_stream(self, make_input): + adapter = ClaudeAgentAdapter(name="t") + # Register a frontend tool named "confirm" + tools = [{"name": "confirm", "description": "", "parameters": {}}] + stream = [ + stream_event({"type": "message_start"}), + stream_event( + { + "type": "content_block_start", + "content_block": {"type": "tool_use", "id": "tc1", "name": "mcp__ag_ui__confirm"}, + } + ), + stream_event( + { + "type": "content_block_delta", + "delta": {"type": "input_json_delta", "partial_json": "{}"}, + } + ), + stream_event({"type": "content_block_stop"}), + # This message_stop must NOT be processed -- stream halts on the frontend tool + stream_event( + {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "AFTER"}} + ), + ] + events = await _drive(adapter, stream, make_input, tools=tools) + # The post-halt text must not appear. + contents = [e for e in events if e.type == EventType.TEXT_MESSAGE_CONTENT] + assert all(c.delta != "AFTER" for c in contents) + assert EventType.TOOL_CALL_END in _types(events) + + +class TestStreamStateMerge: + # ── Item 1: state merge when prior thread state is None ── + @pytest.mark.asyncio + async def test_state_update_with_none_prior_merges_onto_empty(self, make_input): + # When no prior state exists (None) and the update is a dict, the result + # must be the dict itself (merge onto empty), and a STATE_SNAPSHOT must + # be emitted — NOT silently treated as a non-dict replace that skips the + # change check. + adapter = ClaudeAgentAdapter(name="t") + stream = [ + stream_event({"type": "message_start"}), + stream_event( + { + "type": "content_block_start", + "content_block": { + "type": "tool_use", + "id": "tc1", + "name": STATE_MANAGEMENT_TOOL_FULL_NAME, + }, + } + ), + stream_event( + { + "type": "content_block_delta", + "delta": { + "type": "input_json_delta", + "partial_json": '{"state_updates": {"count": 5}}', + }, + } + ), + stream_event({"type": "content_block_stop"}), + stream_event({"type": "message_stop"}), + ] + # state=None seeds _per_thread_state[thread] = None + events = await _drive(adapter, stream, make_input, state=None) + snaps = [e for e in events if e.type == EventType.STATE_SNAPSHOT] + assert len(snaps) == 1 + assert snaps[0].snapshot == {"count": 5} + assert adapter._per_thread_state["thread-1"] == {"count": 5} + + +class TestStreamReasoning: + @pytest.mark.asyncio + async def test_thinking_block_emits_reasoning_events(self, make_input): + adapter = ClaudeAgentAdapter(name="t") + stream = [ + stream_event({"type": "message_start"}), + stream_event( + {"type": "content_block_start", "content_block": {"type": "thinking"}} + ), + stream_event( + {"type": "content_block_delta", "delta": {"type": "thinking_delta", "thinking": "hmm"}} + ), + stream_event( + {"type": "content_block_delta", "delta": {"type": "signature_delta", "signature": "sig"}} + ), + stream_event({"type": "content_block_stop"}), + stream_event({"type": "message_stop"}), + ] + events = await _drive(adapter, stream, make_input) + types = _types(events) + assert EventType.REASONING_START in types + assert EventType.REASONING_MESSAGE_START in types + assert EventType.REASONING_MESSAGE_CONTENT in types + assert EventType.REASONING_END in types + # signature was accumulated -> encrypted value emitted + assert EventType.REASONING_ENCRYPTED_VALUE in types + enc = next(e for e in events if e.type == EventType.REASONING_ENCRYPTED_VALUE) + assert enc.encrypted_value == "sig" + # The encrypted value must be tied to the reasoning block it belongs to, + # not to the enclosing assistant message id. + rstart = next(e for e in events if e.type == EventType.REASONING_START) + assert enc.entity_id == rstart.message_id + + # ── Item 2: signature must not clobber across multiple thinking blocks ── + @pytest.mark.asyncio + async def test_two_thinking_blocks_each_emit_their_own_signature(self, make_input): + # Two thinking blocks in ONE message, each with its own signature. Each + # block's encrypted value must carry that block's signature, tied to + # that block's reasoning id. The old code reset accumulated_signature on + # the first block's stop but emitted with the message id, so a later + # block's signature attached to the wrong entity / got dropped. + adapter = ClaudeAgentAdapter(name="t") + stream = [ + stream_event({"type": "message_start"}), + # Block 1 + stream_event({"type": "content_block_start", "content_block": {"type": "thinking"}}), + stream_event( + {"type": "content_block_delta", "delta": {"type": "thinking_delta", "thinking": "one"}} + ), + stream_event( + {"type": "content_block_delta", "delta": {"type": "signature_delta", "signature": "SIG1"}} + ), + stream_event({"type": "content_block_stop"}), + # Block 2 + stream_event({"type": "content_block_start", "content_block": {"type": "thinking"}}), + stream_event( + {"type": "content_block_delta", "delta": {"type": "thinking_delta", "thinking": "two"}} + ), + stream_event( + {"type": "content_block_delta", "delta": {"type": "signature_delta", "signature": "SIG2"}} + ), + stream_event({"type": "content_block_stop"}), + stream_event({"type": "message_stop"}), + ] + events = await _drive(adapter, stream, make_input) + encs = [e for e in events if e.type == EventType.REASONING_ENCRYPTED_VALUE] + rstarts = [e for e in events if e.type == EventType.REASONING_START] + assert len(rstarts) == 2 + # Exactly two signatures, one per block, no clobber. + assert len(encs) == 2 + sigs = {e.encrypted_value for e in encs} + assert sigs == {"SIG1", "SIG2"} + # Each encrypted value is tied to a distinct reasoning block entity. + entity_ids = {e.entity_id for e in encs} + assert entity_ids == {r.message_id for r in rstarts} + # And the pairing is correct: SIG1 -> block 1, SIG2 -> block 2. + by_entity = {e.entity_id: e.encrypted_value for e in encs} + assert by_entity[rstarts[0].message_id] == "SIG1" + assert by_entity[rstarts[1].message_id] == "SIG2" + + +class TestStreamCleanup: + @pytest.mark.asyncio + async def test_hanging_tool_call_closed_on_stream_end(self, make_input): + adapter = ClaudeAgentAdapter(name="t") + # tool_use opened but stream ends without content_block_stop + stream = [ + stream_event({"type": "message_start"}), + stream_event( + { + "type": "content_block_start", + "content_block": {"type": "tool_use", "id": "tc1", "name": "lookup"}, + } + ), + ] + events = await _drive(adapter, stream, make_input) + # Cleanup must close the hanging tool call. + assert EventType.TOOL_CALL_END in _types(events) + + +class TestBuildOptions: + def test_dict_options_merged(self): + adapter = ClaudeAgentAdapter(name="t", options={"model": "claude-x"}) + opts = adapter.build_options() + assert opts.model == "claude-x" + # include_partial_messages default applied + assert opts.include_partial_messages is True + + def test_api_key_stripped(self): + # api_key must be popped from the merged kwargs before constructing + # ClaudeAgentOptions (it is handled via env var, and the options + # dataclass has no such field). Build must succeed (proving the pop + # happened — otherwise ClaudeAgentOptions(**kwargs) would raise on the + # unexpected api_key kwarg) and the secret must be absent from vars(opts). + adapter = ClaudeAgentAdapter(name="t", options={"api_key": "secret", "model": "m"}) + opts = adapter.build_options() + opts_vars = vars(opts) + assert "api_key" not in opts_vars + assert "secret" not in opts_vars.values() + # The non-secret kwargs still flow through. + assert opts.model == "m" + + def test_state_adds_state_management_tool(self, make_input): + adapter = ClaudeAgentAdapter(name="t") + inp = make_input(state={"count": 1}) + opts = adapter.build_options(inp) + assert STATE_MANAGEMENT_TOOL_FULL_NAME in (opts.allowed_tools or []) + assert AG_UI_MCP_SERVER_NAME in (opts.mcp_servers or {}) + + def test_state_addendum_appended_to_system_prompt(self, make_input): + adapter = ClaudeAgentAdapter(name="t", options={"system_prompt": "BASE"}) + inp = make_input(state={"count": 1}) + opts = adapter.build_options(inp) + assert opts.system_prompt.startswith("BASE") + assert "Current Shared State" in opts.system_prompt + + # ── Item 6: forwarded prop that isn't a valid ClaudeAgentOptions kwarg ── + def test_forwarded_prop_invalid_kwarg_does_not_crash(self, make_input): + # `temperature` is whitelisted in ALLOWED_FORWARDED_PROPS but is NOT a + # valid ClaudeAgentOptions field. Applying it must not raise a TypeError + # from ClaudeAgentOptions(**kwargs); the invalid kwarg is dropped and a + # valid one alongside it still flows through. + adapter = ClaudeAgentAdapter(name="t") + inp = make_input(forwarded_props={"temperature": 0.5, "model": "claude-x"}) + opts = adapter.build_options(inp) # must not raise + assert opts.model == "claude-x" + assert not hasattr(opts, "temperature") + + def test_forwarded_prop_valid_kwarg_still_applied(self, make_input): + adapter = ClaudeAgentAdapter(name="t") + inp = make_input(forwarded_props={"max_turns": 3}) + opts = adapter.build_options(inp) + assert opts.max_turns == 3 + + +class _FakeFailingWorker: + """A SessionWorker stand-in whose query raises immediately.""" + + def __init__(self, *args, **kwargs): + pass + + async def start(self): + pass + + def query(self, prompt, session_id="default"): + async def _gen(): + raise RuntimeError("boom") + yield # pragma: no cover + + return _gen() + + async def stop(self): + pass + + +class TestRunErrorPath: + @pytest.mark.asyncio + async def test_run_emits_run_error_on_worker_failure(self, make_input, monkeypatch): + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _FakeFailingWorker) + + inp = make_input(messages=[{"id": "1", "role": "user", "content": "hi"}]) + events = [e async for e in adapter.run(inp)] + types = _types(events) + # RUN_STARTED then RUN_ERROR (not RUN_FINISHED) + assert EventType.RUN_STARTED in types + assert EventType.RUN_ERROR in types + assert EventType.RUN_FINISHED not in types + err = next(e for e in events if e.type == EventType.RUN_ERROR) + assert "boom" in err.message + + @pytest.mark.asyncio + async def test_error_path_cleans_all_three_dicts(self, make_input, monkeypatch): + # The run() error path must evict the worker AND drop per-thread state + # and per-run results, not just the worker + lock. Otherwise an errored + # thread leaks _per_thread_state / _per_run_result forever. + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _FakeFailingWorker) + + inp = make_input( + thread_id="leaky", + state={"x": 1}, + messages=[{"id": "1", "role": "user", "content": "hi"}], + ) + _ = [e async for e in adapter.run(inp)] + assert "leaky" not in adapter._workers + assert "leaky" not in adapter._state_locks + assert "leaky" not in adapter._per_thread_state + # No per-run result entry for the errored thread survives. + assert not any(k[0] == "leaky" for k in adapter._per_run_result) + + +class _FakeAliveWorker: + """A SessionWorker stand-in that stays alive and is never queried.""" + + def __init__(self, *args, **kwargs): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + async def stop(self): + pass + + +class _FakeDeadWorker: + """A SessionWorker stand-in whose background task has died.""" + + def __init__(self, *args, **kwargs): + self.stopped = False + + async def start(self): + pass + + def is_alive(self): + return False + + def query(self, prompt, session_id="default"): + async def _gen(): + # A dead worker can never serve a query; if reuse isn't guarded the + # real worker would hang here forever. Make the test fail loudly. + raise AssertionError("dead worker was reused for a query") + yield # pragma: no cover + + return _gen() + + async def stop(self): + self.stopped = True + + +class TestEviction: + @pytest.mark.asyncio + async def test_lru_eviction_cleans_all_three_dicts(self): + # LRU eviction must pop _per_thread_state and per-run results, not + # just _workers + _state_locks. Cap at 1 worker, insert 2 idle entries. + # Async so _evict_workers' asyncio.create_task has a running loop. + import asyncio + from datetime import datetime, timedelta + + adapter = ClaudeAgentAdapter(name="t", max_workers=1) + for i, tid in enumerate(["old", "new"]): + adapter._workers[tid] = { + "worker": _FakeAliveWorker(), + "last_used": datetime.now() + timedelta(seconds=i), + "active": False, + } + adapter._state_locks[tid] = asyncio.Lock() + adapter._per_thread_state[tid] = {"v": i} + adapter._per_run_result[(tid, "r")] = {"r": i} + + adapter._evict_workers() + + # "old" (lowest last_used) is evicted; all per-thread state cleaned for it. + assert "old" not in adapter._workers + assert "old" not in adapter._state_locks + assert "old" not in adapter._per_thread_state + assert not any(k[0] == "old" for k in adapter._per_run_result) + # "new" survives. + assert "new" in adapter._workers + assert any(k[0] == "new" for k in adapter._per_run_result) + + @pytest.mark.asyncio + async def test_clear_session_cleans_all_three_dicts(self): + import asyncio + + adapter = ClaudeAgentAdapter(name="t") + adapter._workers["s"] = {"worker": _FakeAliveWorker(), "last_used": None, "active": False} + adapter._state_locks["s"] = asyncio.Lock() + adapter._per_thread_state["s"] = {"v": 1} + adapter._per_run_result[("s", "r")] = {"r": 1} + + await adapter.clear_session("s") + + assert "s" not in adapter._workers + assert "s" not in adapter._state_locks + assert "s" not in adapter._per_thread_state + assert not any(k[0] == "s" for k in adapter._per_run_result) + + +class _FakeSlowStopWorker: + """A worker whose stop() yields control, so the eviction task is pending + when _evict_workers returns — exercising the fire-and-forget GC hazard.""" + + def __init__(self, *args, **kwargs): + self.stopped = False + + async def start(self): + pass + + def is_alive(self): + return True + + async def stop(self): + # Yield so the task is not synchronously complete. + import asyncio + await asyncio.sleep(0) + self.stopped = True + + +class TestWorkerLifecycle: + # ── Item 7(b): eviction stop tasks must not be GC-able before completion ── + @pytest.mark.asyncio + async def test_eviction_stop_tasks_are_retained_until_complete(self): + import asyncio + from datetime import datetime, timedelta + + adapter = ClaudeAgentAdapter(name="t", max_workers=1) + for i, tid in enumerate(["old", "new"]): + adapter._workers[tid] = { + "worker": _FakeSlowStopWorker(), + "last_used": datetime.now() + timedelta(seconds=i), + "active": False, + } + + evicted_worker = adapter._workers["old"]["worker"] + adapter._evict_workers() + + # A strong reference to the in-flight stop task must be retained by the + # adapter so the garbage collector cannot reap it mid-flight. + assert hasattr(adapter, "_pending_tasks") + assert len(adapter._pending_tasks) >= 1 + + # Let the retained task run to completion. + await asyncio.gather(*list(adapter._pending_tasks)) + assert evicted_worker.stopped is True + # Completed tasks are dropped from the retention set. + assert len(adapter._pending_tasks) == 0 + + # ── Run-admission serialization (Fix 1): two same-thread runs no longer run + # concurrently — the run-lock serializes them, so the refcount never exceeds + # 1. The active_runs refcount machinery is retained purely as + # DEFENSE-IN-DEPTH: ``active_runs`` is PER-THREAD, and the per-thread run-lock + # caps it at 1, so ``active_runs > 1`` is unreachable on every path — both + # same-thread (serialized) AND cross-thread (distinct threads have distinct + # refcounts, so a single thread's count is never bumped by a peer thread). ── + @pytest.mark.asyncio + async def test_same_thread_runs_serialized_refcount_bounded_at_one(self, make_input, monkeypatch): + import asyncio + + gate = asyncio.Event() + max_seen = {"n": 0} + + class _GatedWorker: + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + async def _gen(): + # Block the FIRST admitted run's stream open; while it holds + # the run-lock the second run cannot even increment the + # refcount (it waits at admission). + await gate.wait() + return + yield # pragma: no cover + + return _gen() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _GatedWorker) + inp = make_input(thread_id="shared", messages=[{"id": "1", "role": "user", "content": "hi"}]) + + async def drive(): + return [e async for e in adapter.run(inp)] + + t1 = asyncio.create_task(drive()) + t2 = asyncio.create_task(drive()) + # Let scheduling settle; the refcount must NEVER exceed 1 (serialized). + for _ in range(60): + await asyncio.sleep(0) + entry = adapter._workers.get("shared") + if entry: + max_seen["n"] = max(max_seen["n"], entry.get("active_runs", 0)) + assert max_seen["n"] == 1, ( + f"same-thread runs were not serialized; refcount reached {max_seen['n']}" + ) + + # Release the gate so the first run finishes and the second proceeds. + gate.set() + await asyncio.gather(t1, t2) + entry = adapter._workers.get("shared") + assert entry is not None + # After BOTH ran (serially) the worker is idle and evictable. + assert entry["active_runs"] == 0 + assert entry["active"] is False + + # ── Run-lock release on the error path (Fix 1): a same-thread run that + # raises must release the run-lock so the next same-thread run proceeds; the + # shared worker must not be torn down out from under a still-pending run. ── + @pytest.mark.asyncio + async def test_erroring_run_releases_lock_for_next_same_thread_run( + self, make_input, monkeypatch + ): + import asyncio + + stop_calls = {"n": 0} + + class _FailThenOkWorker: + call_index = 0 + + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + idx = _FailThenOkWorker.call_index + _FailThenOkWorker.call_index += 1 + + async def _fail(): + raise RuntimeError("boom") + yield # pragma: no cover + + async def _ok(): + return + yield # pragma: no cover + + return _fail() if idx == 0 else _ok() + + async def stop(self): + stop_calls["n"] += 1 + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _FailThenOkWorker) + inp = make_input( + thread_id="shared", messages=[{"id": "1", "role": "user", "content": "hi"}] + ) + + async def drive(): + return [e async for e in adapter.run(inp)] + + # A (fails) is admitted first; B waits on the run-lock. Launch overlapping. + t_a = asyncio.create_task(drive()) + t_b = asyncio.create_task(drive()) + events_a, events_b = await asyncio.wait_for( + asyncio.gather(t_a, t_b), timeout=5.0 + ) + + # A surfaced RUN_ERROR; B then proceeded once the run-lock was released. + assert EventType.RUN_ERROR in _types(events_a) + assert EventType.RUN_FINISHED in _types(events_b) + + # A's error path tore down its (solo, at that moment) worker; B re-created + # a fresh one and finished cleanly. End state: idle/evictable, no leak. + entry = adapter._workers.get("shared") + assert entry is not None + assert entry["active_runs"] == 0 + assert entry["active"] is False + + # ── Single erroring run (the common path) still pops + stops the worker ── + @pytest.mark.asyncio + async def test_single_erroring_run_still_evicts_worker(self, make_input, monkeypatch): + stop_calls = {"n": 0} + + class _SoloFailingWorker: + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + async def _gen(): + raise RuntimeError("boom") + yield # pragma: no cover + + return _gen() + + async def stop(self): + stop_calls["n"] += 1 + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _SoloFailingWorker) + inp = make_input( + thread_id="solo", messages=[{"id": "1", "role": "user", "content": "hi"}] + ) + events = [e async for e in adapter.run(inp)] + assert EventType.RUN_ERROR in _types(events) + # No peer: the worker is popped and stopped exactly as before. + assert "solo" not in adapter._workers + assert stop_calls["n"] == 1 + assert "solo" not in adapter._state_locks + assert "solo" not in adapter._per_thread_state + assert not any(k[0] == "solo" for k in adapter._per_run_result) + + @pytest.mark.asyncio + async def test_active_worker_not_evicted_by_ttl(self): + from datetime import datetime, timedelta + + adapter = ClaudeAgentAdapter(name="t", worker_ttl_seconds=0.0) + w = _FakeAliveWorker() + # active=True simulates a concurrent in-flight run holding the worker. + adapter._workers["busy"] = { + "worker": w, + "last_used": datetime.now() - timedelta(seconds=10), + "active": True, + } + adapter._evict_workers() + # An active worker must survive TTL eviction even though it is stale. + assert "busy" in adapter._workers + + +class TestPoisonedWorkerCache: + @pytest.mark.asyncio + async def test_dead_cached_worker_is_evicted_and_replaced(self, make_input, monkeypatch): + # A cached worker whose task has died must be evicted so the next run + # creates a fresh worker instead of reusing the dead one (which would + # hang forever waiting on a queue nothing drains). + adapter = ClaudeAgentAdapter(name="t") + dead = _FakeDeadWorker() + adapter._workers["th"] = {"worker": dead, "last_used": None, "active": False} + + # The fresh worker created on the retry uses a fake that errors on query + # (so run still completes via RUN_ERROR rather than touching the LLM), + # but crucially the DEAD worker must NOT be the one queried. + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _FakeFailingWorker) + + inp = make_input(thread_id="th", messages=[{"id": "1", "role": "user", "content": "hi"}]) + events = [e async for e in adapter.run(inp)] + types = _types(events) + # Dead worker was stopped during eviction. + assert dead.stopped is True + # A fresh worker replaced it (RUN_ERROR comes from _FakeFailingWorker, + # NOT the AssertionError the dead worker would have raised). + assert EventType.RUN_ERROR in types + err = next(e for e in events if e.type == EventType.RUN_ERROR) + assert "boom" in err.message + + @pytest.mark.asyncio + async def test_dead_cached_worker_with_live_peer_fails_loud(self, make_input): + # The dead-worker branch is refcount-aware: when a cached worker reports + # is_alive()==False BUT a concurrent peer still holds it (active_runs > 0), + # the arriving NEW run must FAIL LOUD. It must neither reuse the dead + # worker (querying it would hang — the peer's exited run-loop will never + # service the new run's output queue) nor evict it (that would tear the + # worker out from under the live peer). Instead it emits a descriptive + # RunErrorEvent and stops WITHOUT disturbing the peer's entry. (Item 7a) + stop_calls = {"n": 0} + query_calls = {"n": 0} + + class _DeadWorkerWithLivePeer: + """Reports dead. If the new run ever reuses it and calls query(), + that is the hang-risk bug — flag it loudly so the test catches a + regression to the reuse behavior.""" + + def __init__(self, *args, **kwargs): + pass + + async def start(self): + pass + + def is_alive(self): + return False + + def query(self, prompt, session_id="default"): + query_calls["n"] += 1 + + async def _gen(): + # A real dead worker would hang here forever; raise instead + # so a reuse regression fails fast rather than blocking. + raise AssertionError( + "dead worker was queried by the arriving run (hang risk)" + ) + yield # pragma: no cover + + return _gen() + + async def stop(self): + stop_calls["n"] += 1 + + adapter = ClaudeAgentAdapter(name="t") + worker = _DeadWorkerWithLivePeer() + # Pre-seed the cache as if a concurrent peer run already holds this + # (now-dead) worker: active_runs=1 simulates the live peer. + adapter._workers["shared"] = { + "worker": worker, + "last_used": None, + "active": True, + "active_runs": 1, + } + + inp = make_input( + thread_id="shared", messages=[{"id": "1", "role": "user", "content": "hi"}] + ) + events = [e async for e in adapter.run(inp)] + + # LOUD FAILURE: the arriving run emits RUN_ERROR (never reuses → never + # queries the dead worker → no hang). + assert EventType.RUN_ERROR in _types(events), ( + "arriving run on a dead-worker-with-live-peer must fail loud" + ) + assert EventType.RUN_FINISHED not in _types(events) + assert query_calls["n"] == 0, "dead worker must not be queried (hang risk)" + + # PEER UNTOUCHED: the shared entry survives, is not popped, not stopped. + entry = adapter._workers.get("shared") + assert entry is not None, "shared worker evicted while a peer run was live" + assert entry["worker"] is worker + assert stop_calls["n"] == 0, "shared worker stopped while a peer run was live" + # REFCOUNT INTACT: the peer's count must be exactly what it was (1). The + # arriving run must not increment-then-abandon, nor decrement the peer's + # count via the finally block. + assert entry["active_runs"] == 1, ( + f"peer refcount corrupted: expected 1, got {entry['active_runs']}" + ) + assert entry["active"] is True diff --git a/integrations/claude-agent-sdk/python/tests/test_concurrency_integration.py b/integrations/claude-agent-sdk/python/tests/test_concurrency_integration.py new file mode 100644 index 0000000000..cf601d8a92 --- /dev/null +++ b/integrations/claude-agent-sdk/python/tests/test_concurrency_integration.py @@ -0,0 +1,359 @@ +"""Thread-level concurrency integration tests for the Claude Agent SDK adapter. + +Unlike ``test_adapter.py`` — whose ``TestWorkerLifecycle`` / +``TestPoisonedWorkerCache`` suites monkeypatch the whole ``SessionWorker`` class +with ``_Fake*Worker`` stand-ins — these tests drive the **real** adapter + +the **real** :class:`ag_ui_claude_sdk.session.SessionWorker`. Only the leaf +``ClaudeSDKClient`` (the thing that would actually spawn the Claude CLI and hit +the Anthropic API) is substituted. + +Why this matters: the white-box fakes replace ``SessionWorker.query`` directly, +so they never exercise the worker's background task, its input/output queue +plumbing, ``client.connect()`` / ``client.query()`` / ``client.receive_response()``, +or its ``start()`` / ``stop()`` lifecycle. The per-thread ``active_runs`` refcount +hardening (PR #1878, "item 7") is therefore proven today only against fakes. +These tests close that gap: two genuinely-concurrent ``run()`` invocations share +one real worker through the full adapter stack, with the LLM substituted at the +SDK-client boundary (the same boundary the dojo e2e mocks via aimock + +``ANTHROPIC_BASE_URL``, just pushed down into the process instead of over HTTP). + +LLM substitution mechanism +--------------------------- +``SessionWorker._run`` does ``from claude_agent_sdk import ClaudeSDKClient`` at +call time, so monkeypatching ``claude_agent_sdk.ClaudeSDKClient`` swaps the real +client for a scripted one while leaving the worker (and the adapter) entirely +real. The fake client implements the exact surface the worker uses: +``connect()``, ``query()``, ``receive_response()``, ``disconnect()``, +``interrupt()`` — and streams back real ``claude_agent_sdk`` message objects +(``StreamEvent`` / ``ResultMessage``), so the adapter's translation layer runs +for real too. +""" + +import asyncio + +import pytest + +from ag_ui.core import EventType +from ag_ui_claude_sdk.adapter import ClaudeAgentAdapter +from ag_ui_claude_sdk import session as session_module +from ag_ui_claude_sdk.session import SessionWorker + +from .conftest import stream_event + + +def _types(events): + return [e.type for e in events] + + +# --------------------------------------------------------------------------- +# Scripted ClaudeSDKClient — the ONLY substituted component. Everything above +# it (SessionWorker queues/lifecycle, adapter run()) is real. +# --------------------------------------------------------------------------- + + +class _ScriptedClient: + """Stand-in for ``claude_agent_sdk.ClaudeSDKClient``. + + Streams a minimal but real Claude SDK message sequence (a couple of + streaming text deltas wrapped in ``StreamEvent`` + a terminal + ``ResultMessage``). A per-instance ``release`` event lets a test hold the + stream open to force genuine overlap between two concurrent runs sharing + one worker. + + Each instance records that it was constructed/connected so a test can prove + the **real** ``SessionWorker._run`` path executed (a fake worker never + constructs a ClaudeSDKClient at all). + """ + + def __init__(self, *, instances, options=None, fail=False, release=None): + self.options = options + self._fail = fail + self._release = release + self.connected = False + self.disconnected = False + self.query_calls = [] + instances.append(self) + + async def connect(self): + self.connected = True + + async def query(self, prompt, session_id="default"): + self.query_calls.append((prompt, session_id)) + + async def receive_response(self): + from claude_agent_sdk import ResultMessage + + # Optionally block so a peer run can be proven mid-stream on the SAME + # shared worker before this one completes. + if self._release is not None: + await self._release.wait() + + if self._fail: + raise RuntimeError("scripted client boom") + + msg_id_event = stream_event({"type": "message_start"}) + text_start = stream_event( + { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "hello "}, + } + ) + text_more = stream_event( + { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "world"}, + } + ) + msg_stop = stream_event({"type": "message_stop"}) + for ev in (msg_id_event, text_start, text_more, msg_stop): + yield ev + + yield ResultMessage( + subtype="success", + duration_ms=1, + duration_api_ms=1, + is_error=False, + num_turns=1, + session_id="sess", + total_cost_usd=0.0, + usage={}, + result="hello world", + ) + + async def disconnect(self): + self.disconnected = True + + async def interrupt(self): + pass + + +def _install_scripted_client(monkeypatch, instances, *, fail_when=None, release_when=None): + """Patch ``claude_agent_sdk.ClaudeSDKClient`` with a factory that produces + ``_ScriptedClient`` instances. + + ``fail_when`` / ``release_when`` are callables ``(index) -> bool`` keyed on + construction order, letting a test designate which worker's client fails or + blocks. (One worker per thread_id, so for a single shared thread the index + maps to run order.) + """ + import claude_agent_sdk + + counter = {"n": 0} + releases = [] + + def factory(options=None, **kwargs): + idx = counter["n"] + counter["n"] += 1 + release = None + if release_when is not None and release_when(idx): + release = asyncio.Event() + releases.append(release) + return _ScriptedClient( + instances=instances, + options=options, + fail=bool(fail_when and fail_when(idx)), + release=release, + ) + + monkeypatch.setattr(claude_agent_sdk, "ClaudeSDKClient", factory) + return releases + + +async def _drive(adapter, inp): + return [e async for e in adapter.run(inp)] + + +async def _wait_for(predicate, *, tries=400): + """Cooperatively yield until ``predicate()`` is truthy (or give up).""" + for _ in range(tries): + if predicate(): + return True + await asyncio.sleep(0) + return False + + +class TestRealWorkerConcurrency: + """Drives the REAL SessionWorker + adapter; only ClaudeSDKClient is faked. + + Same-thread runs are now SERIALIZED by the per-thread run-admission lock + (Fix 1), so two overlapping same-thread runs no longer co-exist in-flight + (the refcount never exceeds 1). These scenarios verify the real worker is + nonetheless REUSED across the serialized runs (not duplicated, not torn + down) and torn down cleanly afterward. + """ + + @pytest.mark.asyncio + async def test_scenario_a_two_overlapping_runs_serialized_on_one_real_worker( + self, make_input, monkeypatch + ): + # (a) Two overlapping run() invocations on the SAME thread_id are + # SERIALIZED: B's RUN_STARTED is emitted only after A's RUN_FINISHED. + # Both complete on the ONE shared REAL worker (reused, not duplicated), + # which drains to refcount 0 and survives throughout. + instances = [] + _install_scripted_client(monkeypatch, instances) + + adapter = ClaudeAgentAdapter(name="t") + inp = make_input( + thread_id="shared", messages=[{"id": "1", "role": "user", "content": "hi"}] + ) + + order = [] + + async def drive(marker): + evs = [] + async for e in adapter.run(inp): + evs.append(e) + if e.type in (EventType.RUN_STARTED, EventType.RUN_FINISHED): + order.append((marker, e.type)) + return evs + + t1 = asyncio.create_task(drive("A")) + await _wait_for(lambda: ("A", EventType.RUN_STARTED) in order) + t2 = asyncio.create_task(drive("B")) + + events1, events2 = await asyncio.gather(t1, t2) + + assert EventType.RUN_FINISHED in _types(events1) + assert EventType.RUN_FINISHED in _types(events2) + # Real translation layer ran: streamed text surfaced as AG-UI events. + assert EventType.TEXT_MESSAGE_CONTENT in _types(events1) + assert EventType.TEXT_MESSAGE_CONTENT in _types(events2) + + # SERIALIZED: A's RUN_FINISHED strictly precedes B's RUN_STARTED. + idx_a_fin = order.index(("A", EventType.RUN_FINISHED)) + idx_b_start = order.index(("B", EventType.RUN_STARTED)) + assert idx_a_fin < idx_b_start, f"runs not serialized: {order}" + + # ONE real worker served both runs (reused, not duplicated). + entry = adapter._workers["shared"] + assert isinstance(entry["worker"], SessionWorker) + assert entry["active_runs"] == 0 + assert entry["active"] is False + assert len(instances) == 1, "worker was duplicated instead of reused" + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_scenario_b_erroring_run_then_next_run_proceeds( + self, make_input, monkeypatch + ): + # (b) Two overlapping same-thread runs; the FIRST-admitted one raises + # mid-stream. Because runs are serialized, the second run only begins + # after the first releases its run-lock (on the error path). The errored + # run surfaces RUN_ERROR; the next run completes normally. + instances = [] + + import claude_agent_sdk + + class _SharedClient: + served = 0 + + def __init__(self, options=None, **kwargs): + self.options = options + self.connected = False + self.disconnected = False + instances.append(self) + + async def connect(self): + self.connected = True + + async def query(self, prompt, session_id="default"): + pass + + async def receive_response(self): + from claude_agent_sdk import ResultMessage + + served = _SharedClient.served + _SharedClient.served += 1 + if served == 0: + # First served query (A): raise mid-stream. + raise RuntimeError("scripted client boom") + yield # pragma: no cover + # Next query (B): complete normally. + yield stream_event({"type": "message_start"}) + yield stream_event( + { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "ok"}, + } + ) + yield stream_event({"type": "message_stop"}) + yield ResultMessage( + subtype="success", + duration_ms=1, + duration_api_ms=1, + is_error=False, + num_turns=1, + session_id="sess", + total_cost_usd=0.0, + usage={}, + result="ok", + ) + + async def disconnect(self): + self.disconnected = True + + async def interrupt(self): + pass + + monkeypatch.setattr(claude_agent_sdk, "ClaudeSDKClient", _SharedClient) + + adapter = ClaudeAgentAdapter(name="t") + inp = make_input( + thread_id="shared", messages=[{"id": "1", "role": "user", "content": "hi"}] + ) + + # A (admitted first, fails) and B (proceeds after A releases the lock). + t_a = asyncio.create_task(_drive(adapter, inp)) + await _wait_for( + lambda: (adapter._workers.get("shared") or {}).get("active_runs", 0) >= 1 + ) + t_b = asyncio.create_task(_drive(adapter, inp)) + + events_a, events_b = await asyncio.wait_for( + asyncio.gather(t_a, t_b), timeout=10.0 + ) + assert EventType.RUN_ERROR in _types(events_a) + assert EventType.RUN_FINISHED in _types(events_b) + assert EventType.RUN_ERROR not in _types(events_b) + + # End state: refcount 0, idle, evictable, no leak. + entry = adapter._workers["shared"] + assert isinstance(entry["worker"], SessionWorker) + assert entry["active_runs"] == 0 + assert entry["active"] is False + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_scenario_c_worker_cleanly_evictable_after_runs( + self, make_input, monkeypatch + ): + # (c) explicit: after two serialized same-thread runs finish, the shared + # real worker is refcount 0 and is actually torn down (stop() disconnects + # the client) by clear_session — no leak, no lingering background task. + instances = [] + _install_scripted_client(monkeypatch, instances) + + adapter = ClaudeAgentAdapter(name="t") + inp = make_input( + thread_id="shared", messages=[{"id": "1", "role": "user", "content": "hi"}] + ) + + t1 = asyncio.create_task(_drive(adapter, inp)) + t2 = asyncio.create_task(_drive(adapter, inp)) + await asyncio.gather(t1, t2) + + entry = adapter._workers["shared"] + worker = entry["worker"] + assert entry["active_runs"] == 0 + assert isinstance(worker, SessionWorker) + assert worker.is_alive() is True # idle but still alive until evicted + + # Cleanly evict: the real worker's background task stops and the real + # client is disconnected — proving full lifecycle teardown, not a fake. + await adapter.clear_session("shared") + assert "shared" not in adapter._workers + assert worker.is_alive() is False + assert instances[0].disconnected is True diff --git a/integrations/claude-agent-sdk/python/tests/test_handlers.py b/integrations/claude-agent-sdk/python/tests/test_handlers.py new file mode 100644 index 0000000000..2e1e50e402 --- /dev/null +++ b/integrations/claude-agent-sdk/python/tests/test_handlers.py @@ -0,0 +1,418 @@ +"""Tests for the Claude SDK stream block handlers. + +Exercises tool-use / tool-result block translation and the state-management +interception path. Handlers are async generators, so we collect events. +""" + +import json + +import pytest + +from ag_ui.core import EventType +from ag_ui_claude_sdk.config import STATE_MANAGEMENT_TOOL_FULL_NAME +from ag_ui_claude_sdk.handlers import ( + handle_tool_use_block, + handle_tool_result_block, +) + +from claude_agent_sdk import ToolUseBlock, ToolResultBlock + + +async def collect(agen): + return [e async for e in agen] + + +class _Msg: + """Stand-in parent message carrying parent_tool_use_id.""" + + def __init__(self, parent_tool_use_id=None): + self.parent_tool_use_id = parent_tool_use_id + + +class TestHandleToolUseBlock: + @pytest.mark.asyncio + async def test_regular_tool_emits_start_args_end(self): + block = ToolUseBlock(id="tc1", name="mcp__weather__get_weather", input={"city": "NYC"}) + state, gen = await handle_tool_use_block(block, _Msg(), "th", "run", None) + events = await collect(gen) + types = [e.type for e in events] + assert types == [ + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + ] + # Name is stripped of the MCP prefix + assert events[0].tool_call_name == "get_weather" + assert events[0].tool_call_id == "tc1" + assert json.loads(events[1].delta) == {"city": "NYC"} + + @pytest.mark.asyncio + async def test_tool_without_input_skips_args(self): + block = ToolUseBlock(id="tc2", name="ping", input={}) + _, gen = await handle_tool_use_block(block, _Msg(), "th", "run", None) + types = [e.type for e in await collect(gen)] + assert EventType.TOOL_CALL_ARGS not in types + assert types == [EventType.TOOL_CALL_START, EventType.TOOL_CALL_END] + + @pytest.mark.asyncio + async def test_missing_id_falls_back_to_generated_uuid(self): + # A ToolUseBlock with a falsy id must not crash: the handler falls back + # to a generated uuid. This guards against the `uuid` import living in + # the module docstring (NameError at the str(uuid.uuid4()) fallback). + block = ToolUseBlock(id="", name="ping", input={}) + _, gen = await handle_tool_use_block(block, _Msg(), "th", "run", None) + events = await collect(gen) + types = [e.type for e in events] + assert types == [EventType.TOOL_CALL_START, EventType.TOOL_CALL_END] + # A non-empty fallback id was generated (a uuid4 string). + assert events[0].tool_call_id + assert events[0].tool_call_id == events[1].tool_call_id + + @pytest.mark.asyncio + async def test_state_management_tool_emits_snapshot_and_merges(self): + block = ToolUseBlock( + id="tc3", + name=STATE_MANAGEMENT_TOOL_FULL_NAME, + input={"state_updates": {"count": 5}}, + ) + new_state, gen = await handle_tool_use_block( + block, _Msg(), "th", "run", {"count": 1, "name": "a"} + ) + events = await collect(gen) + # Only a STATE_SNAPSHOT, no TOOL_CALL_* events + assert [e.type for e in events] == [EventType.STATE_SNAPSHOT] + assert events[0].snapshot == {"count": 5, "name": "a"} + # The RETURNED state must equal the merged snapshot, not the pre-merge + # state. The adapter persists this dict on the non-streaming path, so a + # pre-merge return regresses thread state. + assert new_state == {"count": 5, "name": "a"} + + @pytest.mark.asyncio + async def test_state_management_tool_json_string_updates(self): + block = ToolUseBlock( + id="tc4", + name=STATE_MANAGEMENT_TOOL_FULL_NAME, + input={"state_updates": json.dumps({"count": 9})}, + ) + new_state, gen = await handle_tool_use_block( + block, _Msg(), "th", "run", {"count": 1} + ) + events = await collect(gen) + assert events[0].snapshot == {"count": 9} + # The returned state must equal the merged snapshot (pins the return on + # the JSON-string variant too). + assert new_state == {"count": 9} + + @pytest.mark.asyncio + async def test_state_management_invalid_json_emits_custom_error(self): + block = ToolUseBlock( + id="tc5", + name=STATE_MANAGEMENT_TOOL_FULL_NAME, + input={"state_updates": "{not valid json"}, + ) + _, gen = await handle_tool_use_block(block, _Msg(), "th", "run", {}) + events = await collect(gen) + types = [e.type for e in events] + # Invalid JSON emits ONLY a CUSTOM error event and returns early — no + # spurious STATE_SNAPSHOT with un-updated state (mirrors the streaming + # path in adapter.py). + assert types == [EventType.CUSTOM] + custom = events[0] + assert custom.name == "state_update_error" + assert "error" in custom.value + + + # ── Item 3: suppress no-op STATE_SNAPSHOT on the non-streaming path ── + @pytest.mark.asyncio + async def test_state_management_noop_update_suppresses_snapshot(self): + # When the merge does not change state, the non-streaming handler must + # NOT emit a STATE_SNAPSHOT — matching the streaming path, which only + # emits when the merged state actually changed. + block = ToolUseBlock( + id="tc-noop", + name=STATE_MANAGEMENT_TOOL_FULL_NAME, + input={"state_updates": {"count": 1}}, + ) + new_state, gen = await handle_tool_use_block( + block, _Msg(), "th", "run", {"count": 1} + ) + events = await collect(gen) + # No-op merge => no snapshot emitted. + assert [e.type for e in events] == [] + # Returned state is unchanged (still equal to prior). + assert new_state == {"count": 1} + + @pytest.mark.asyncio + async def test_state_management_real_change_still_emits_snapshot(self): + # A genuine change must still emit exactly one STATE_SNAPSHOT. + block = ToolUseBlock( + id="tc-change", + name=STATE_MANAGEMENT_TOOL_FULL_NAME, + input={"state_updates": {"count": 2}}, + ) + _, gen = await handle_tool_use_block(block, _Msg(), "th", "run", {"count": 1}) + events = await collect(gen) + assert [e.type for e in events] == [EventType.STATE_SNAPSHOT] + assert events[0].snapshot == {"count": 2} + + # ── Item 4: align state_updates extraction with the streaming path ── + @pytest.mark.asyncio + async def test_state_updates_key_absent_falls_back_to_whole_object(self): + # The streaming path (adapter.py) treats the whole parsed object as the + # updates when the "state_updates" key is absent. The non-streaming + # handler must behave identically instead of merging an empty {}. + block = ToolUseBlock( + id="tc-whole", + name=STATE_MANAGEMENT_TOOL_FULL_NAME, + input={"count": 7, "name": "z"}, + ) + new_state, gen = await handle_tool_use_block( + block, _Msg(), "th", "run", {"count": 1} + ) + events = await collect(gen) + assert [e.type for e in events] == [EventType.STATE_SNAPSHOT] + assert events[0].snapshot == {"count": 7, "name": "z"} + assert new_state == {"count": 7, "name": "z"} + + @pytest.mark.asyncio + async def test_state_updates_nested_json_string_value_reparsed(self): + # Streaming re-parses a state_updates value that is itself a JSON string. + # The non-streaming handler must do the same. + block = ToolUseBlock( + id="tc-nested", + name=STATE_MANAGEMENT_TOOL_FULL_NAME, + input={"state_updates": json.dumps({"count": 3})}, + ) + new_state, gen = await handle_tool_use_block( + block, _Msg(), "th", "run", {"count": 1} + ) + events = await collect(gen) + assert events[0].snapshot == {"count": 3} + assert new_state == {"count": 3} + + +class TestToolUseBlockParentMessageId: + @pytest.mark.asyncio + async def test_parent_message_id_uses_passed_assistant_message_id(self): + # The streaming path sets ToolCallStartEvent.parent_message_id to the + # current assistant message id. The non-streaming handler must mirror + # that — NOT the SDK's parent_tool_use_id (which lives on the message). + block = ToolUseBlock(id="tc1", name="get_weather", input={"city": "NYC"}) + msg = _Msg(parent_tool_use_id="SHOULD_NOT_BE_USED") + _, gen = await handle_tool_use_block( + block, msg, "th", "run", None, parent_message_id="assistant-msg-1" + ) + events = await collect(gen) + start = next(e for e in events if e.type == EventType.TOOL_CALL_START) + assert start.parent_message_id == "assistant-msg-1" + assert start.parent_message_id != "SHOULD_NOT_BE_USED" + + +class TestHandleToolResultBlock: + @pytest.mark.asyncio + async def test_emits_tool_call_result(self): + block = ToolResultBlock( + tool_use_id="tc1", + content=[{"type": "text", "text": '{"ok": true}'}], + ) + events = await collect(handle_tool_result_block(block, "th", "run")) + assert len(events) == 1 + assert events[0].type == EventType.TOOL_CALL_RESULT + assert events[0].tool_call_id == "tc1" + assert events[0].message_id == "tc1-result" + assert json.loads(events[0].content) == {"ok": True} + + @pytest.mark.asyncio + async def test_is_error_propagated_into_result_content(self): + # A failed tool result (is_error=True) must not look identical to a + # successful one. AG-UI's ToolCallResultEvent has no error field, so the + # error indication is surfaced inside the content envelope. + block = ToolResultBlock( + tool_use_id="tc1", + content=[{"type": "text", "text": "boom"}], + is_error=True, + ) + events = await collect(handle_tool_result_block(block, "th", "run")) + assert len(events) == 1 + payload = json.loads(events[0].content) + assert payload["error"] is True + assert payload["content"] == "boom" + + @pytest.mark.asyncio + async def test_is_error_with_json_object_content_is_single_encoded(self): + # When the tool result content is itself a JSON object, the error path + # must stay consistent with the success shape: a single-encoded JSON + # object carrying an "error": true marker — NOT a double-encoded string + # nested under "content". + block = ToolResultBlock( + tool_use_id="tc1", + content=[{"type": "text", "text": '{"detail": "nope", "code": 42}'}], + is_error=True, + ) + events = await collect(handle_tool_result_block(block, "th", "run")) + assert len(events) == 1 + payload = json.loads(events[0].content) + # Single-encoded object: the original fields are top-level dict members, + # not a re-escaped JSON string under "content". + assert payload["detail"] == "nope" + assert payload["code"] == 42 + assert payload["error"] is True + # Guard against the double-encode regression: "content" must not hold a + # stringified copy of the JSON object. + assert not isinstance(payload.get("content"), str) + + @pytest.mark.asyncio + async def test_is_error_with_surrogate_content_is_repaired(self): + # A split UTF-16 surrogate pair in error content must be repaired in the + # emitted payload. The old envelope ran json.dumps over a string that + # already contained surrogates escaped to literal "\ud83c" text — so + # fix_surrogates (a UTF-16 round-trip) could not repair it, AND the + # whole thing got double-encoded under "content". Use JSON-object + # content carrying the surrogate so both defects are exercised. + # + # chr(0xD83C)+chr(0xDF5D) is the lone-surrogate-pair form of 🍝 + # (U+1F35D), as produced when a JS String.slice splits the emoji across + # stream chunks. + split_pasta = chr(0xD83C) + chr(0xDF5D) + block = ToolResultBlock( + tool_use_id="tc1", + content=[{"type": "text", "text": json.dumps({"msg": split_pasta})}], + is_error=True, + ) + events = await collect(handle_tool_result_block(block, "th", "run")) + assert len(events) == 1 + payload = json.loads(events[0].content) + assert payload["error"] is True + # Single-encoded object: "msg" is a top-level field, not buried in a + # double-encoded "content" string. + assert "msg" in payload + assert not isinstance(payload.get("content"), str) + # The surrogate is repaired to the real codepoint, not left as a pair of + # lone surrogates that Pydantic would reject. + assert payload["msg"] == "\U0001f35d" + assert len(payload["msg"]) == 1 + + @pytest.mark.asyncio + async def test_success_result_has_no_error_envelope(self): + block = ToolResultBlock( + tool_use_id="tc1", + content=[{"type": "text", "text": '{"ok": true}'}], + is_error=False, + ) + events = await collect(handle_tool_result_block(block, "th", "run")) + # Successful result is the bare payload, not wrapped in an error envelope. + assert json.loads(events[0].content) == {"ok": True} + + @pytest.mark.asyncio + async def test_does_not_emit_tool_call_end(self): + # Regression guard: result handler must NOT re-emit TOOL_CALL_END + # (that caused "No active tool call" runtime errors). + block = ToolResultBlock(tool_use_id="tc1", content="plain") + events = await collect(handle_tool_result_block(block, "th", "run")) + assert all(e.type != EventType.TOOL_CALL_END for e in events) + + @pytest.mark.asyncio + async def test_no_tool_use_id_emits_nothing(self): + block = ToolResultBlock(tool_use_id="", content="x") + events = await collect(handle_tool_result_block(block, "th", "run")) + assert events == [] + + # ── Item 5: tool-result content encoding consistency ── + @pytest.mark.asyncio + async def test_list_text_block_and_bare_string_encode_identically(self): + # The SAME logical plain-text payload must reach the frontend with the + # SAME encoding regardless of whether the SDK delivered it as a + # list-of-text-blocks or as a bare string. Previously the list path + # emitted the text UNQUOTED while the bare-string path json.dumps-quoted + # it, so identical content arrived differently. + list_block = ToolResultBlock( + tool_use_id="tc1", + content=[{"type": "text", "text": "plain text"}], + ) + bare_block = ToolResultBlock(tool_use_id="tc2", content="plain text") + list_events = await collect(handle_tool_result_block(list_block, "th", "run")) + bare_events = await collect(handle_tool_result_block(bare_block, "th", "run")) + assert list_events[0].content == bare_events[0].content + + # ── Item 9: untested fallback branches (non-list / scalar / except) ── + @pytest.mark.asyncio + async def test_dict_content_fallback_is_json_encoded(self): + # content is a dict (not a list, not a string) -> json.dumps fallback. + block = ToolResultBlock(tool_use_id="tc1", content={"k": "v"}) + events = await collect(handle_tool_result_block(block, "th", "run")) + assert len(events) == 1 + assert json.loads(events[0].content) == {"k": "v"} + + @pytest.mark.asyncio + async def test_scalar_int_content_fallback(self): + # A bare non-string scalar -> json.dumps fallback. + block = ToolResultBlock(tool_use_id="tc1", content=42) + events = await collect(handle_tool_result_block(block, "th", "run")) + assert len(events) == 1 + assert events[0].content == "42" + + @pytest.mark.asyncio + async def test_empty_list_content_fallback(self): + # An empty list takes the `else` (non-truthy-len) branch -> json.dumps([]). + block = ToolResultBlock(tool_use_id="tc1", content=[]) + events = await collect(handle_tool_result_block(block, "th", "run")) + assert len(events) == 1 + assert events[0].content == "[]" + + @pytest.mark.asyncio + async def test_non_text_block_list_fallback(self): + # A list whose first block is NOT a text block -> json.dumps(content). + content = [{"type": "image", "data": "xyz"}] + block = ToolResultBlock(tool_use_id="tc1", content=content) + events = await collect(handle_tool_result_block(block, "th", "run")) + assert len(events) == 1 + assert json.loads(events[0].content) == content + + @pytest.mark.asyncio + async def test_unserializable_content_uses_str_fallback(self): + # Content that json.dumps cannot serialise must hit the + # `except (TypeError, ValueError) -> str(content)` fallback rather than + # crashing the handler. + class Unserializable: + def __repr__(self): + return "UNSER" + + block = ToolResultBlock(tool_use_id="tc1", content=Unserializable()) + events = await collect(handle_tool_result_block(block, "th", "run")) + assert len(events) == 1 + assert events[0].content == "UNSER" + + +class TestNestedToolResult: + # ── Item 8: parent_tool_use_id must be wired through ── + @pytest.mark.asyncio + async def test_parent_tool_use_id_surfaced_on_result(self): + # A nested/sub-agent tool result carries a parent_tool_use_id. The + # handler accepts it but historically never used it, so the documented + # nested-result behavior was inert. It must now be surfaced on the + # emitted event so consumers can attribute the result to its parent. + block = ToolResultBlock( + tool_use_id="child-tc", + content=[{"type": "text", "text": '{"ok": true}'}], + ) + events = await collect( + handle_tool_result_block(block, "th", "run", parent_tool_use_id="parent-tc") + ) + assert len(events) == 1 + ev = events[0] + # AG-UI's ToolCallResultEvent has no first-class parent field, so the + # parent linkage is surfaced via the protocol-standard raw_event escape + # hatch. Previously parent_tool_use_id was accepted but dropped. + assert ev.raw_event is not None + assert ev.raw_event.get("parent_tool_use_id") == "parent-tc" + + @pytest.mark.asyncio + async def test_no_parent_tool_use_id_leaves_raw_event_unset(self): + # Top-level (non-nested) results must NOT gain a spurious raw_event. + block = ToolResultBlock( + tool_use_id="tc1", + content=[{"type": "text", "text": '{"ok": true}'}], + ) + events = await collect(handle_tool_result_block(block, "th", "run")) + assert len(events) == 1 + assert events[0].raw_event is None diff --git a/integrations/claude-agent-sdk/python/tests/test_serialize_and_robustness.py b/integrations/claude-agent-sdk/python/tests/test_serialize_and_robustness.py new file mode 100644 index 0000000000..8fffed4a23 --- /dev/null +++ b/integrations/claude-agent-sdk/python/tests/test_serialize_and_robustness.py @@ -0,0 +1,1083 @@ +"""Tests for the run-admission serialization + robustness hardening. + +These cover four changes (see the reviewed Notion proposal): + + Fix 1 — SERIALIZE concurrent same-thread run() invocations behind a dedicated + per-thread run-admission lock (``_run_locks``), held from admission + (before ``worker.query()`` / before ``RUN_STARTED``) through + ``RUN_FINISHED`` and released on EVERY exit path. Different thread_ids + stay concurrent. + Fix 2 — ``query_timeout_seconds`` defaults to a generous 300s (was None → + unbounded hang on a dead/slow worker), still overridable. + Fix 3 — worker-death fan-out: ``SessionWorker`` signals a terminal + WorkerError + None sentinel to ALL in-flight output queues on fatal + worker death, so a queued/peer consumer cannot hang. + Fix 4 — ``_per_thread_result`` is per-run, keyed by (thread_id, run_id), so a + run's RUN_FINISHED.result reflects its OWN ResultMessage. + +The dedicated ``_run_locks`` MUST be distinct from ``_state_locks`` (which is +acquired mid-stream on the state-update-tool path); reusing it would self- +deadlock the instant the model emits a state-update tool call. Scenario (c) +exercises run-lock + inner state-lock together to prove no deadlock. +""" + +import asyncio + +import pytest + +from ag_ui.core import EventType +from ag_ui_claude_sdk.adapter import ClaudeAgentAdapter +from ag_ui_claude_sdk.config import STATE_MANAGEMENT_TOOL_FULL_NAME + +from .conftest import stream_event, aiter + + +def _types(events): + return [e.type for e in events] + + +async def _drive(adapter, inp): + return [e async for e in adapter.run(inp)] + + +async def _wait_for(predicate, *, tries=2000): + for _ in range(tries): + if predicate(): + return True + await asyncio.sleep(0) + return False + + +# --------------------------------------------------------------------------- +# Fake workers used to drive run() deterministically without an LLM. +# --------------------------------------------------------------------------- + + +class _GatedTextWorker: + """Worker whose query() streams a tiny text run, but only after a per-call + gate is released. Tracks the order in which RUN_STARTED-able streams begin so + a test can assert serialization ordering. + + A shared ``log`` list records (event, run_marker) tuples for ordering checks. + """ + + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + async def stop(self): + pass + + +def _make_text_stream(): + return [ + stream_event({"type": "message_start"}), + stream_event( + {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "hi"}} + ), + stream_event({"type": "message_stop"}), + ] + + +class TestSerializeSameThread: + @pytest.mark.asyncio + async def test_two_same_thread_runs_are_serialized(self, make_input, monkeypatch): + # (a) Two overlapping same-thread runs: B's RUN_STARTED must be emitted + # only AFTER A's RUN_FINISHED. The run-admission lock holds A's slot + # across its whole run; B waits at admission. + order = [] # records ("A"/"B", event_type) + a_gate = asyncio.Event() # released to let A's stream complete + + class _OrderedWorker: + calls = 0 + + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + idx = _OrderedWorker.calls + _OrderedWorker.calls += 1 + + async def _gen_first(): + # A: hold the stream open so, IF B were not serialized, B + # would be able to emit RUN_STARTED while A is mid-run. + await a_gate.wait() + for ev in _make_text_stream(): + yield ev + + async def _gen_second(): + for ev in _make_text_stream(): + yield ev + + return _gen_first() if idx == 0 else _gen_second() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _OrderedWorker) + + inp_a = make_input(thread_id="shared", run_id="A", + messages=[{"id": "1", "role": "user", "content": "hi"}]) + inp_b = make_input(thread_id="shared", run_id="B", + messages=[{"id": "2", "role": "user", "content": "yo"}]) + + async def drive(inp, marker): + async for e in adapter.run(inp): + if e.type in (EventType.RUN_STARTED, EventType.RUN_FINISHED): + order.append((marker, e.type)) + + t_a = asyncio.create_task(drive(inp_a, "A")) + # Ensure A has acquired the run-lock and emitted RUN_STARTED first. + await _wait_for(lambda: ("A", EventType.RUN_STARTED) in order) + t_b = asyncio.create_task(drive(inp_b, "B")) + + # Give B ample scheduling opportunity; while A holds the run-lock, B must + # NOT have emitted RUN_STARTED yet. + for _ in range(50): + await asyncio.sleep(0) + assert ("B", EventType.RUN_STARTED) not in order, ( + "B's RUN_STARTED was emitted before A finished — runs are not serialized" + ) + + # Release A; it finishes, releasing the run-lock so B can proceed. + a_gate.set() + await asyncio.gather(t_a, t_b) + + # Both completed. + assert ("A", EventType.RUN_FINISHED) in order + assert ("B", EventType.RUN_FINISHED) in order + # Ordering: A RUN_FINISHED strictly precedes B RUN_STARTED. + idx_a_fin = order.index(("A", EventType.RUN_FINISHED)) + idx_b_start = order.index(("B", EventType.RUN_STARTED)) + assert idx_a_fin < idx_b_start, f"not serialized: {order}" + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_run_lock_not_orphaned_by_eviction_in_release_acquire_window( + self, make_input, monkeypatch + ): + # (a2) ORPHAN REGRESSION (Fix 1): the run-admission lock must NOT be + # coupled to worker eviction. Reproduce the hole: + # 1. Run A admits, holds the run-lock L1, runs on a fresh worker. + # 2. Run B parks on ``L1.acquire()`` (waiter on L1). + # 3. A finishes and releases L1 — but B has not yet woken. The worker + # is now idle (active_runs==0) and thus TTL-evictable. + # 4. Eviction fires (worker_ttl_seconds=0). If eviction POPS + # ``_run_locks[thread_id]`` (the bug), L1 is orphaned: B is still a + # waiter on it, but a later run D will ``setdefault`` a FRESH lock + # L2 and run on its own brand-new worker. + # 5. D and B then hold DIFFERENT locks → they run CONCURRENTLY on the + # same thread_id. Serialization defeated. + # With the fix (lock NOT popped + identity re-validation after acquire), + # B and D share the SAME current lock entry, so they serialize: their two + # runs never overlap (refcount on the shared worker never exceeds 1, and + # RUN_STARTED events never interleave). + order = [] # (marker, event_type) for RUN_STARTED / RUN_FINISHED + a_gate = asyncio.Event() # release A's stream so A can finish + b_gate = asyncio.Event() # hold B's stream open so B is mid-flight + # when D arrives (so an orphan → overlap) + b_proceeded = asyncio.Event() # set when B wakes from acquire() + max_overlap = {"n": 0} + # True concurrency gauge: number of runs that have emitted RUN_STARTED + # but not yet RUN_FINISHED, counted across ALL drive() coroutines (not + # tied to a single _workers slot, which two distinct workers can overwrite). + live_runs = {"n": 0, "max": 0} + + class _OrphanWorker: + calls = 0 + + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + idx = _OrphanWorker.calls + _OrphanWorker.calls += 1 + + async def _gen_a(): + # A (idx 0): hold open until released, so B can park on the + # run-lock and we can fire eviction in the release→acquire + # window. + await a_gate.wait() + for ev in _make_text_stream(): + yield ev + + async def _gen_b(): + # B (idx 1): hold open until released, so B is still mid-flight + # when D arrives. If B's lock was orphaned by eviction, D will + # acquire a FRESH lock and run concurrently with B → the + # serialization violation this test is designed to catch. + await b_gate.wait() + for ev in _make_text_stream(): + yield ev + + async def _gen_other(): + for ev in _make_text_stream(): + yield ev + + if idx == 0: + return _gen_a() + if idx == 1: + return _gen_b() + return _gen_other() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t", worker_ttl_seconds=0.0) + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _OrphanWorker) + + inp_a = make_input(thread_id="shared", run_id="A", + messages=[{"id": "1", "role": "user", "content": "hi"}]) + inp_b = make_input(thread_id="shared", run_id="B", + messages=[{"id": "2", "role": "user", "content": "yo"}]) + inp_d = make_input(thread_id="shared", run_id="D", + messages=[{"id": "3", "role": "user", "content": "sup"}]) + + def _record_overlap(): + entry = adapter._workers.get("shared") + if entry: + max_overlap["n"] = max(max_overlap["n"], entry.get("active_runs", 0)) + + async def drive(inp, marker, evict_after=False): + async for e in adapter.run(inp): + _record_overlap() + if e.type == EventType.RUN_STARTED: + live_runs["n"] += 1 + live_runs["max"] = max(live_runs["max"], live_runs["n"]) + order.append((marker, e.type)) + if marker == "B": + b_proceeded.set() + elif e.type == EventType.RUN_FINISHED: + live_runs["n"] -= 1 + order.append((marker, e.type)) + # CRITICAL: fire eviction in the SAME coroutine step in which A's + # run() generator was exhausted — A's ``finally`` has just run + # ``run_lock.release()``, scheduling B's parked acquire to wake on the + # NEXT loop iteration, but we have not yielded control yet. So B is + # still a waiter on L1 when eviction runs. With the bug, eviction pops + # L1 here → B is orphaned on a lock no longer in ``_run_locks``. + if evict_after: + adapter._evict_workers() + + # 1+2: A admits and holds L1; B parks on L1.acquire(). + t_a = asyncio.create_task(drive(inp_a, "A", evict_after=True)) + await _wait_for(lambda: ("A", EventType.RUN_STARTED) in order) + l1 = adapter._run_locks["shared"] + t_b = asyncio.create_task(drive(inp_b, "B")) + # Let B reach the parked acquire() on L1. + await _wait_for(lambda: l1.locked() and len(l1._waiters or []) >= 1) + + # 3: release A; A finishes, releases L1, and (in A's own coroutine step, + # before B wakes) fires eviction (evict_after=True). The now-idle worker + # is popped; with the BUG L1 is popped too, orphaning B's wait. + a_gate.set() + # B wakes, acquires (its now-orphaned, under the bug) lock, emits + # RUN_STARTED, and blocks in its gated stream — still in-flight. + await _wait_for(lambda: b_proceeded.is_set()) + + # 5: D arrives WHILE B is still mid-flight. With the bug, ``_run_locks`` + # was emptied by eviction, so D ``setdefault``s a FRESH lock + fresh + # worker and runs immediately — concurrently with B. With the fix, the + # lock entry survived (B still holds the current entry), so D parks until + # B releases. + t_d = asyncio.create_task(drive(inp_d, "D")) + # Give D ample opportunity to (incorrectly) start before B is released. + for _ in range(100): + await asyncio.sleep(0) + + # Now release B; everything drains. + b_gate.set() + await asyncio.wait_for(asyncio.gather(t_a, t_b, t_d), timeout=10.0) + + # SERIALIZATION INVARIANT: never were two same-thread runs simultaneously + # in-flight (RUN_STARTED-but-not-yet-RUN_FINISHED). Counted across all + # drive() coroutines so it catches B and D running on DISTINCT workers + # (the orphan symptom: each gets its own worker, so the per-entry refcount + # can't see the overlap, but the run-lock was supposed to prevent it). + assert live_runs["max"] <= 1, ( + f"run-lock orphaned: {live_runs['max']} same-thread runs were " + f"concurrently in-flight (B and D overlapped). order={order}" + ) + # All three completed. + for m in ("A", "B", "D"): + assert (m, EventType.RUN_FINISHED) in order, f"{m} did not finish: {order}" + # B and D never interleave their RUN_STARTED/RUN_FINISHED: one fully + # precedes the other. + b_fin = order.index(("B", EventType.RUN_FINISHED)) + d_start = order.index(("D", EventType.RUN_STARTED)) + b_start = order.index(("B", EventType.RUN_STARTED)) + d_fin = order.index(("D", EventType.RUN_FINISHED)) + assert b_fin < d_start or d_fin < b_start, ( + f"B and D interleaved — not serialized: {order}" + ) + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_run_admission_revalidate_retry_relooops_on_swapped_lock( + self, make_input, monkeypatch + ): + # (a3) RETRY-BRANCH COVERAGE (Fix 1): the run-admission loop in ``run()`` + # + # while True: + # run_lock = self._run_locks.setdefault(thread_id, Lock()) + # await run_lock.acquire() + # if self._run_locks.get(thread_id) is run_lock: + # break + # run_lock.release() # <-- this RETRY branch + # + # is defensive: eviction no longer pops ``_run_locks``, so in production + # the identity check passes on the first pass and the ``release()`` + + # re-loop branch never executes (the suite stays green even if that + # branch is deleted and replaced with a plain ``break``). This white-box + # test FORCES the retry branch purely test-side: monkeypatch + # ``asyncio.Lock.acquire`` so the FIRST acquire against the adapter's + # run-lock swaps ``_run_locks[thread_id]`` to a DIFFERENT live lock before + # returning. The identity check then fails, the run must ``release()`` the + # stale lock and re-loop onto the now-current entry. We assert the run + # ends up holding the CURRENT ``_run_locks[thread_id]`` (i.e. it re-looped + # rather than running on a stale lock) and completes correctly. + # + # Red-green: if the RETRY branch is removed (left as a plain ``break``), + # the run keeps the stale L1 while the live entry is L2, so the final + # ``adapter._run_locks[thread_id] is acquired_lock`` assertion FAILS. + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr( + "ag_ui_claude_sdk.adapter.SessionWorker", _GatedTextWorker + ) + + def _query(self, prompt, session_id="default"): + async def _gen(): + for ev in _make_text_stream(): + yield ev + return _gen() + + _GatedTextWorker.query = _query + + thread_id = "swap" + # ``acquired_locks`` records, in order, every Lock object the run + # actually acquires; the live entry is read at assert time. + acquired_locks = [] + swapped = {"done": False} + + real_acquire = asyncio.Lock.acquire + + async def _acquire(self): + result = await real_acquire(self) + # Only react to the run-admission lock for our thread, and only the + # FIRST time: swap the live entry to a brand-new (unlocked) lock so + # the identity re-validation fails and the run must re-loop. + if ( + not swapped["done"] + and adapter._run_locks.get(thread_id) is self + ): + swapped["done"] = True + adapter._run_locks[thread_id] = asyncio.Lock() + acquired_locks.append(self) + return result + + monkeypatch.setattr(asyncio.Lock, "acquire", _acquire) + + inp = make_input( + thread_id=thread_id, run_id="R", + messages=[{"id": "1", "role": "user", "content": "hi"}], + ) + events = await _drive(adapter, inp) + + # The swap fired (so the retry branch was actually exercised), and the + # run acquired at least two distinct lock objects (stale L1, then the + # live L2) — proof it re-looped. + assert swapped["done"], "the lock swap never fired; retry branch untested" + assert len(acquired_locks) >= 2, ( + f"run did not re-acquire after swap: acquired={acquired_locks}" + ) + # The run released the stale lock and ended holding the CURRENT entry. + live_lock = adapter._run_locks[thread_id] + assert acquired_locks[-1] is live_lock, ( + "run is not holding the current _run_locks entry — it failed to " + "re-loop onto the swapped-in lock (retry branch broken)" + ) + # The stale first lock was released (not left orphaned/locked). + stale_lock = acquired_locks[0] + assert stale_lock is not live_lock, "no swap occurred; test is inert" + assert not stale_lock.locked(), "stale run-lock was not released on retry" + # And the run completed correctly end-to-end. + assert EventType.RUN_STARTED in _types(events) + assert EventType.RUN_FINISHED in _types(events) + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_different_threads_run_concurrently(self, make_input, monkeypatch): + # (b) Two DIFFERENT-thread runs must still overlap (lock is per-thread). + both_started = asyncio.Event() + started = {"n": 0} + release = asyncio.Event() + + class _ConcurrentWorker: + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + async def _gen(): + started["n"] += 1 + if started["n"] >= 2: + both_started.set() + # Hold until both have started — proving genuine overlap. If + # the lock were global (not per-thread), the second run could + # never start and this would deadlock/time out. + await release.wait() + for ev in _make_text_stream(): + yield ev + + return _gen() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _ConcurrentWorker) + + inp1 = make_input(thread_id="t1", run_id="r1", + messages=[{"id": "1", "role": "user", "content": "hi"}]) + inp2 = make_input(thread_id="t2", run_id="r2", + messages=[{"id": "2", "role": "user", "content": "yo"}]) + + t1 = asyncio.create_task(_drive(adapter, inp1)) + t2 = asyncio.create_task(_drive(adapter, inp2)) + + overlapped = await _wait_for(both_started.is_set) + assert overlapped, "different-thread runs did not overlap — lock is not per-thread" + + release.set() + e1, e2 = await asyncio.gather(t1, t2) + assert EventType.RUN_FINISHED in _types(e1) + assert EventType.RUN_FINISHED in _types(e2) + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_state_update_tool_does_not_deadlock_with_run_lock(self, make_input, monkeypatch): + # (c) A run whose stream includes a state-update tool call must NOT + # deadlock: the run-lock (outer) and state-lock (inner, acquired mid- + # stream at adapter.py state-management path) are DISTINCT locks. If the + # run incorrectly reused _state_locks for admission, this would self- + # deadlock the instant the state-update tool fires. + class _StateToolWorker: + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + async def _gen(): + yield stream_event({"type": "message_start"}) + yield stream_event({ + "type": "content_block_start", + "content_block": { + "type": "tool_use", + "id": "tc1", + "name": STATE_MANAGEMENT_TOOL_FULL_NAME, + }, + }) + yield stream_event({ + "type": "content_block_delta", + "delta": { + "type": "input_json_delta", + "partial_json": '{"state_updates": {"count": 7}}', + }, + }) + yield stream_event({"type": "content_block_stop"}) + yield stream_event({"type": "message_stop"}) + + return _gen() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _StateToolWorker) + inp = make_input(thread_id="sd", run_id="r1", state={"count": 0}, + messages=[{"id": "1", "role": "user", "content": "hi"}]) + + # Must complete (no deadlock) within a generous bound. + events = await asyncio.wait_for(_drive(adapter, inp), timeout=5.0) + assert EventType.RUN_FINISHED in _types(events) + # State-update tool path actually ran (mid-stream state-lock acquired). + assert EventType.STATE_SNAPSHOT in _types(events) + assert adapter._per_thread_state["sd"] == {"count": 7} + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_run_lock_released_on_error_path(self, make_input, monkeypatch): + # (d) A run that raises must still release the run-lock so a subsequent + # same-thread run can proceed (not hang on a never-released lock). + class _FailThenSucceedWorker: + calls = 0 + + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + idx = _FailThenSucceedWorker.calls + _FailThenSucceedWorker.calls += 1 + + async def _fail(): + raise RuntimeError("boom") + yield # pragma: no cover + + async def _ok(): + for ev in _make_text_stream(): + yield ev + + return _fail() if idx == 0 else _ok() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _FailThenSucceedWorker) + + inp1 = make_input(thread_id="errthread", run_id="r1", + messages=[{"id": "1", "role": "user", "content": "hi"}]) + events1 = await asyncio.wait_for(_drive(adapter, inp1), timeout=5.0) + assert EventType.RUN_ERROR in _types(events1) + + # The run-lock must have been released — a second same-thread run runs. + inp2 = make_input(thread_id="errthread", run_id="r2", + messages=[{"id": "2", "role": "user", "content": "yo"}]) + events2 = await asyncio.wait_for(_drive(adapter, inp2), timeout=5.0) + assert EventType.RUN_FINISHED in _types(events2) + + await adapter.shutdown() + + +class TestQueryTimeoutDefault: + def test_default_query_timeout_is_non_none(self): + # Fix 2: constructed with no query_timeout_seconds → a non-None default + # (300s) so a dead/slow worker cannot hang a run forever. + adapter = ClaudeAgentAdapter(name="t") + assert adapter._query_timeout_seconds is not None + assert adapter._query_timeout_seconds == 300 + + def test_query_timeout_override_still_honored(self): + adapter = ClaudeAgentAdapter(name="t", query_timeout_seconds=12.0) + assert adapter._query_timeout_seconds == 12.0 + # Explicit None still disables it. + adapter2 = ClaudeAgentAdapter(name="t", query_timeout_seconds=None) + assert adapter2._query_timeout_seconds is None + + @pytest.mark.asyncio + async def test_unresponsive_worker_times_out_not_hang(self, make_input, monkeypatch): + # A worker that never yields must surface RUN_ERROR (timeout), not hang. + # Use a short override to keep the test fast. + class _HangingWorker: + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + async def _gen(): + await asyncio.sleep(3600) # never responds within the test + yield # pragma: no cover + + return _gen() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t", query_timeout_seconds=0.05) + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _HangingWorker) + inp = make_input(thread_id="slow", run_id="r1", + messages=[{"id": "1", "role": "user", "content": "hi"}]) + events = await asyncio.wait_for(_drive(adapter, inp), timeout=5.0) + types = _types(events) + assert EventType.RUN_ERROR in types + assert EventType.RUN_FINISHED not in types + + await adapter.shutdown() + + +class TestPerRunResult: + # Fix 4 keys ``_per_run_result`` by ``(thread_id, run_id)`` rather than a + # bare per-thread slot. Under run-admission serialization (Fix 1) same-thread + # runs are sequential, so a bare per-thread slot would NOT actually bleed + # across runs at RUN_FINISHED time — which means the two ordering-only tests + # below (``..._reflects_own_result_message`` / + # ``..._serialized_runs_each_get_own_result``) are DEFENSE-IN-DEPTH: they + # would still pass against a thread-keyed implementation. The dedicated + # ``test_result_dict_is_run_keyed_not_thread_keyed`` below is the LOAD-BEARING + # guard: it inspects ``_per_run_result`` directly and fails if the result is + # stored under a bare ``thread_id`` key instead of the ``(thread_id, run_id)`` + # tuple — i.e. it genuinely guards the keying that Fix 4 introduced. + @pytest.mark.asyncio + async def test_result_dict_is_run_keyed_not_thread_keyed(self, make_input, monkeypatch): + # LOAD-BEARING keying guard. Pause run A mid-stream, AFTER its + # ResultMessage has been recorded into ``_per_run_result`` but BEFORE A + # emits RUN_FINISHED (and its ``finally`` drops the slot). Then assert the + # live entry is keyed by the (thread_id, run_id) TUPLE — never by the bare + # thread_id. A thread-keyed implementation (the regression Fix 4 guards + # against) would fail this directly. + from claude_agent_sdk import ResultMessage + + after_result_gate = asyncio.Event() # release A's stream after ResultMessage + + class _PausingResultWorker: + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + async def _gen(): + yield stream_event({"type": "message_start"}) + yield stream_event({ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "hi"}, + }) + yield stream_event({"type": "message_stop"}) + yield ResultMessage( + subtype="success", + duration_ms=7, + duration_api_ms=1, + is_error=False, + num_turns=1, + session_id="sess", + total_cost_usd=0.0, + usage={}, + result="hi", + ) + # Suspend HERE: the adapter has recorded the result under this + # run's key, but has not yet exhausted the stream / emitted + # RUN_FINISHED / popped the slot. + await after_result_gate.wait() + + return _gen() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _PausingResultWorker) + + inp = make_input(thread_id="kt", run_id="RUNX", + messages=[{"id": "1", "role": "user", "content": "hi"}]) + + events = [] + + async def drive(): + async for e in adapter.run(inp): + events.append(e) + + t = asyncio.create_task(drive()) + # Wait until A's ResultMessage has been recorded into _per_run_result. + await _wait_for(lambda: adapter._per_run_result.get(("kt", "RUNX")) is not None) + + # LOAD-BEARING ASSERTIONS — these fail against a thread-keyed store. + # 1. The entry exists under the (thread_id, run_id) tuple key. + assert ("kt", "RUNX") in adapter._per_run_result + assert adapter._per_run_result[("kt", "RUNX")]["duration_ms"] == 7 + # 2. Every live key is a (thread_id, run_id) tuple — never a bare string + # thread_id (which is what a thread-keyed regression would produce). + for k in adapter._per_run_result: + assert isinstance(k, tuple) and len(k) == 2, ( + f"_per_run_result key is not (thread_id, run_id): {k!r}" + ) + assert "kt" not in adapter._per_run_result, ( + "result stored under bare thread_id — keying regressed to per-thread" + ) + + after_result_gate.set() + await asyncio.wait_for(t, timeout=5.0) + fin = next(e for e in events if e.type == EventType.RUN_FINISHED) + assert fin.result["duration_ms"] == 7 + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_run_finished_result_reflects_own_result_message(self, make_input, monkeypatch): + # Fix 4 (defense-in-depth, ordering): RUN_FINISHED.result reflects THIS + # run's own ResultMessage. (Sequential under serialization, so this would + # also pass thread-keyed; the load-bearing guard is + # ``test_result_dict_is_run_keyed_not_thread_keyed``.) + from claude_agent_sdk import ResultMessage + + class _ResultWorker: + calls = 0 + + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + idx = _ResultWorker.calls + _ResultWorker.calls += 1 + + async def _gen(): + yield stream_event({"type": "message_start"}) + yield stream_event({ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "hi"}, + }) + yield stream_event({"type": "message_stop"}) + yield ResultMessage( + subtype="success", + duration_ms=idx, # distinct per run + duration_api_ms=1, + is_error=False, + num_turns=idx + 1, + session_id="sess", + total_cost_usd=0.0, + usage={}, + result="hi", + ) + + return _gen() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _ResultWorker) + + inp1 = make_input(thread_id="shared", run_id="r1", + messages=[{"id": "1", "role": "user", "content": "hi"}]) + events1 = await _drive(adapter, inp1) + fin1 = next(e for e in events1 if e.type == EventType.RUN_FINISHED) + assert fin1.result is not None + assert fin1.result["duration_ms"] == 0 + assert fin1.result["num_turns"] == 1 + + inp2 = make_input(thread_id="shared", run_id="r2", + messages=[{"id": "2", "role": "user", "content": "yo"}]) + events2 = await _drive(adapter, inp2) + fin2 = next(e for e in events2 if e.type == EventType.RUN_FINISHED) + assert fin2.result is not None + # Run 2 gets its OWN result, not run 1's. + assert fin2.result["duration_ms"] == 1 + assert fin2.result["num_turns"] == 2 + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_two_serialized_runs_each_get_own_result(self, make_input, monkeypatch): + # Two serialized same-thread runs each carry their own ResultMessage even + # when launched overlapping (serialize keeps them ordered; result must + # not bleed across). + from claude_agent_sdk import ResultMessage + + class _SeqResultWorker: + calls = 0 + + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + idx = _SeqResultWorker.calls + _SeqResultWorker.calls += 1 + + async def _gen(): + yield stream_event({"type": "message_start"}) + yield stream_event({ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "x"}, + }) + yield stream_event({"type": "message_stop"}) + yield ResultMessage( + subtype="success", + duration_ms=100 + idx, + duration_api_ms=1, + is_error=False, + num_turns=1, + session_id="sess", + total_cost_usd=0.0, + usage={}, + result="x", + ) + + return _gen() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _SeqResultWorker) + + inp_a = make_input(thread_id="shared", run_id="A", + messages=[{"id": "1", "role": "user", "content": "hi"}]) + inp_b = make_input(thread_id="shared", run_id="B", + messages=[{"id": "2", "role": "user", "content": "yo"}]) + + t_a = asyncio.create_task(_drive(adapter, inp_a)) + t_b = asyncio.create_task(_drive(adapter, inp_b)) + events_a, events_b = await asyncio.gather(t_a, t_b) + + fin_a = next(e for e in events_a if e.type == EventType.RUN_FINISHED) + fin_b = next(e for e in events_b if e.type == EventType.RUN_FINISHED) + # Each run has a distinct, own result (the two calls produced 100 / 101). + assert {fin_a.result["duration_ms"], fin_b.result["duration_ms"]} == {100, 101} + + await adapter.shutdown() + + +class TestSequentialStateReset: + @pytest.mark.asyncio + async def test_run2_fresh_state_replaces_run1(self, make_input, monkeypatch): + # Regression guard: run 1 then run 2 (sequential) on the same thread, + # where run 2 sends fresh input_data.state. Run 2's state must REPLACE + # run 1's (documented reset). Serialize must not turn the per-run re-seed + # into "inherit/ignore". + class _NoopWorker: + def __init__(self, *a, **kw): + pass + + async def start(self): + pass + + def is_alive(self): + return True + + def query(self, prompt, session_id="default"): + async def _gen(): + for ev in _make_text_stream(): + yield ev + + return _gen() + + async def stop(self): + pass + + adapter = ClaudeAgentAdapter(name="t") + monkeypatch.setattr("ag_ui_claude_sdk.adapter.SessionWorker", _NoopWorker) + + inp1 = make_input(thread_id="shared", run_id="r1", state={"count": 1}, + messages=[{"id": "1", "role": "user", "content": "hi"}]) + await _drive(adapter, inp1) + assert adapter._per_thread_state["shared"] == {"count": 1} + + inp2 = make_input(thread_id="shared", run_id="r2", state={"other": 99}, + messages=[{"id": "2", "role": "user", "content": "yo"}]) + await _drive(adapter, inp2) + # Fresh state from run 2 REPLACED run 1's (reset semantics preserved). + assert adapter._per_thread_state["shared"] == {"other": 99} + + await adapter.shutdown() + + +class TestWorkerDeathFanout: + @pytest.mark.asyncio + async def test_waiting_consumer_gets_terminal_signal_on_worker_death(self): + # Fix 3: SessionWorker must fan out WorkerError + None to ALL in-flight + # output queues on fatal worker death, so a queued/peer consumer does not + # hang. Drive the REAL SessionWorker with a scripted ClaudeSDKClient that + # dies in connect() AFTER queries have been enqueued — the fatal-error + # branch must terminate every registered consumer. + import claude_agent_sdk + from ag_ui_claude_sdk.session import SessionWorker + + connect_gate = asyncio.Event() + + class _DyingClient: + def __init__(self, options=None, **kwargs): + self.options = options + + async def connect(self): + # Wait until consumers have enqueued their queries, THEN die. + await connect_gate.wait() + raise RuntimeError("client connect boom") + + async def query(self, prompt, session_id="default"): # pragma: no cover + pass + + async def receive_response(self): # pragma: no cover + if False: + yield None + + async def disconnect(self): + pass + + async def interrupt(self): + pass + + orig = claude_agent_sdk.ClaudeSDKClient + claude_agent_sdk.ClaudeSDKClient = _DyingClient + try: + worker = SessionWorker("th", options=None) + await worker.start() + + # Enqueue TWO queries while the worker is still blocked in connect(). + # Both register their output queues; on worker death BOTH must get a + # terminal signal (without the fan-out, the second hangs forever). + async def consume(): + got_error = False + try: + async for _ in worker.query("p", session_id="th"): + pass + except Exception: + got_error = True + return got_error + + c1 = asyncio.create_task(consume()) + c2 = asyncio.create_task(consume()) + + # Let both queries land on the input queue before the worker dies. + await _wait_for(lambda: worker._input_queue.qsize() >= 2) + connect_gate.set() + + # Both consumers must terminate (error or clean end) — neither hangs. + results = await asyncio.wait_for(asyncio.gather(c1, c2), timeout=5.0) + assert all(r is True for r in results), ( + "a waiting consumer did not receive a terminal error on worker death" + ) + finally: + claude_agent_sdk.ClaudeSDKClient = orig + await worker.stop() + + @pytest.mark.asyncio + async def test_in_flight_consumer_gets_terminal_error_on_worker_cancellation(self): + # Fix 3 — cancellation path: ``_on_task_done`` has a branch for the worker task exiting + # WITHOUT a fatal exception — e.g. cancelled / terminated mid-flight while + # a query is still being serviced. That branch must fan out a terminal + # RuntimeError("...terminated while a query was still in flight") + the + # None sentinel to every in-flight output queue, so the waiting consumer + # gets a raised error rather than hanging forever. (The existing + # ``..._on_worker_death`` test only covers the FATAL connect()-raises + # path; this covers the cancelled/no-exception path.) + import claude_agent_sdk + from ag_ui_claude_sdk.session import SessionWorker + + in_connect = asyncio.Event() # set once connect() is entered + block_forever = asyncio.Event() # never set: keeps connect() pending + + class _BlockingConnectClient: + def __init__(self, options=None, **kwargs): + self.options = options + + async def connect(self): + # Block in connect so the enqueued query is registered as + # in-flight but NEVER dequeued/serviced. Cancelling the worker + # here raises CancelledError (a BaseException, NOT caught by the + # fatal ``except Exception`` branch), so ``_run`` exits WITHOUT a + # fatal exception while the query's output queue is still + # registered — exactly the no-exception path of _on_task_done. + in_connect.set() + await block_forever.wait() + + async def query(self, prompt, session_id="default"): # pragma: no cover + pass + + async def receive_response(self): # pragma: no cover + if False: + yield None + + async def disconnect(self): + pass + + async def interrupt(self): + pass + + orig = claude_agent_sdk.ClaudeSDKClient + claude_agent_sdk.ClaudeSDKClient = _BlockingConnectClient + worker = SessionWorker("th", options=None) + try: + await worker.start() + + terminal_error = {"exc": None} + + async def consume(): + try: + async for _ in worker.query("p", session_id="th"): + pass + except Exception as e: # noqa: BLE001 — capture the terminal error + terminal_error["exc"] = e + + c = asyncio.create_task(consume()) + + # The query is enqueued + its output queue registered as in-flight, + # while the worker is blocked in connect() (query never dequeued). + await _wait_for( + lambda: in_connect.is_set() and len(worker._inflight_queues) == 1 + ) + + # Cancel the worker task while it sits in connect(). CancelledError is + # a BaseException, so ``_run``'s ``except Exception`` fatal fan-out is + # NOT taken; the task ends with no fatal exception while the consumer's + # queue is still registered. The done-callback's no-exception branch + # must terminate that consumer. + worker._task.cancel() + + # The consumer must terminate with a raised terminal error — not hang. + await asyncio.wait_for(c, timeout=5.0) + assert terminal_error["exc"] is not None, ( + "in-flight consumer hung instead of receiving a terminal error " + "on worker cancellation" + ) + assert "terminated while a query was still in flight" in str( + terminal_error["exc"] + ), f"unexpected terminal error: {terminal_error['exc']!r}" + finally: + claude_agent_sdk.ClaudeSDKClient = orig + block_forever.set() + # The worker task was cancelled above; awaiting it via stop() would + # re-raise CancelledError. Just await the already-cancelled task, + # suppressing the cancellation, to clean up without masking the test. + from contextlib import suppress + if worker._task is not None: + with suppress(asyncio.CancelledError): + await worker._task diff --git a/integrations/claude-agent-sdk/python/tests/test_utils.py b/integrations/claude-agent-sdk/python/tests/test_utils.py new file mode 100644 index 0000000000..8a3eb24fd0 --- /dev/null +++ b/integrations/claude-agent-sdk/python/tests/test_utils.py @@ -0,0 +1,312 @@ +"""Unit tests for the pure helper utilities in ag_ui_claude_sdk.utils. + +These functions carry the load-bearing translation logic (tool-name +normalisation, surrogate repair, message/state shaping) and have no external +dependencies, so they are tested directly with plain data. +""" + +import json + +import pytest + +from ag_ui.core import RunAgentInput, AssistantMessage as AguiAssistantMessage +from ag_ui_claude_sdk.config import ( + STATE_MANAGEMENT_TOOL_NAME, + STATE_MANAGEMENT_TOOL_FULL_NAME, +) +from ag_ui_claude_sdk.utils import ( + fix_surrogates, + fix_surrogates_deep, + extract_tool_names, + strip_mcp_prefix, + process_messages, + build_state_context_addendum, + apply_forwarded_props, + _is_state_management_tool, + build_agui_assistant_message, + build_agui_tool_message, +) + + +class TestStripMcpPrefix: + def test_strips_server_prefix(self): + assert strip_mcp_prefix("mcp__weather__get_weather") == "get_weather" + + def test_strips_ag_ui_prefix(self): + assert strip_mcp_prefix("mcp__ag_ui__generate_haiku") == "generate_haiku" + + def test_unprefixed_unchanged(self): + assert strip_mcp_prefix("local_tool") == "local_tool" + + def test_preserves_double_underscore_in_tool_name(self): + # mcp__server__tool__with__underscores -> tool__with__underscores + assert strip_mcp_prefix("mcp__srv__a__b") == "a__b" + + def test_too_few_parts_unchanged(self): + assert strip_mcp_prefix("mcp__only") == "mcp__only" + + +class TestExtractToolNames: + def test_dict_tools(self): + tools = [{"name": "a"}, {"name": "b"}] + assert extract_tool_names(tools) == ["a", "b"] + + def test_object_tools(self): + class T: + def __init__(self, name): + self.name = name + + assert extract_tool_names([T("x"), T("y")]) == ["x", "y"] + + def test_skips_nameless(self): + assert extract_tool_names([{"description": "no name"}, {"name": "ok"}]) == ["ok"] + + def test_empty(self): + assert extract_tool_names([]) == [] + + +class TestFixSurrogates: + def test_plain_text_unchanged(self): + assert fix_surrogates("hello world") == "hello world" + + def test_reassembles_surrogate_pair(self): + # U+1F35D (🍝) as a *split* UTF-16 surrogate pair: a high surrogate + # (U+D83C) followed by a low surrogate (U+DF5D). This is the genuinely + # broken shape produced when a JS String.slice() splits the codepoint. + # A normal "🍝" literal carries no surrogates and would not exercise + # the repair path at all. + broken = "\ud83c\udf5d" + assert "\ud83c" in broken and "\udf5d" in broken # sanity: lone surrogates present + fixed = fix_surrogates(broken) + # Reassembled into the single real codepoint U+1F35D. + assert fixed == chr(0x1F35D) + assert fixed == "🍝" + # Round-trips to valid UTF-8 (the original `broken` cannot). + assert fixed.encode("utf-8").decode("utf-8") == "🍝" + + def test_lone_surrogate_uses_fallback(self): + # An *unpaired* high surrogate cannot be reassembled into a valid + # codepoint, so the "surrogatepass" round-trip succeeds in re-creating + # the same lone surrogate; the result must still be UTF-8 encodable + # without raising (Pydantic-serialisable). We assert the function + # returns a string and that string encodes cleanly to UTF-8. + broken = "a\ud83cb" # lone high surrogate between two ASCII chars + assert "\ud83c" in broken + fixed = fix_surrogates(broken) + assert isinstance(fixed, str) + # Must not raise — the whole point of the repair is UTF-8 safety. + fixed.encode("utf-8") + + def test_deep_fixes_nested_structure(self): + broken = "\ud83c\udf5d" # split surrogate pair for U+1F35D + data = {"a": broken, "b": [broken, {"c": broken}]} + fixed = fix_surrogates_deep(data) + assert fixed["a"] == "🍝" + assert fixed["b"][0] == "🍝" + assert fixed["b"][1]["c"] == "🍝" + + def test_deep_preserves_non_strings(self): + data = {"n": 1, "f": 1.5, "b": True, "none": None} + assert fix_surrogates_deep(data) == data + + +class TestIsStateManagementTool: + def test_short_name(self): + assert _is_state_management_tool(STATE_MANAGEMENT_TOOL_NAME) is True + + def test_full_prefixed_name(self): + assert _is_state_management_tool(STATE_MANAGEMENT_TOOL_FULL_NAME) is True + + def test_other_tool(self): + assert _is_state_management_tool("get_weather") is False + + +class TestProcessMessages: + def test_extracts_last_user_message(self, make_input): + inp = make_input( + messages=[ + {"id": "1", "role": "user", "content": "first"}, + {"id": "2", "role": "user", "content": "latest"}, + ] + ) + user_msg, pending = process_messages(inp) + assert user_msg == "latest" + assert pending is False + + def test_detects_pending_tool_result(self, make_input): + from ag_ui.core import ToolMessage + + inp = make_input( + messages=[ + ToolMessage(id="t1", role="tool", content="result", tool_call_id="tc1"), + ] + ) + user_msg, pending = process_messages(inp) + assert pending is True + + def test_empty_messages(self, make_input): + inp = make_input(messages=[]) + user_msg, pending = process_messages(inp) + assert user_msg == "" + assert pending is False + + +class TestBuildStateContextAddendum: + def test_empty_when_nothing(self, make_input): + inp = make_input() + assert build_state_context_addendum(inp) == "" + + def test_includes_state_json(self, make_input): + inp = make_input(state={"count": 3}) + addendum = build_state_context_addendum(inp) + assert "Current Shared State" in addendum + assert "ag_ui_update_state" in addendum + assert '"count": 3' in addendum + + def test_includes_context(self, make_input): + from ag_ui.core import Context + + inp = make_input(context=[Context(description="page", value="/home")]) + addendum = build_state_context_addendum(inp) + assert "Context from the application" in addendum + assert "page" in addendum + assert "/home" in addendum + + +class TestApplyForwardedProps: + def test_applies_whitelisted_key(self): + result = apply_forwarded_props({"model": "claude-x"}, {}, {"model"}) + assert result["model"] == "claude-x" + + def test_ignores_non_whitelisted(self): + result = apply_forwarded_props({"evil": "x"}, {}, {"model"}) + assert "evil" not in result + + def test_ignores_none_value(self): + result = apply_forwarded_props({"model": None}, {}, {"model"}) + assert "model" not in result + + def test_non_dict_returns_unchanged(self): + base = {"a": 1} + assert apply_forwarded_props(None, base, {"model"}) is base + + +class _Block: + """A content block exposing the ``.type`` attribute that + build_agui_assistant_message keys off of.""" + + def __init__(self, type, **kw): + self.type = type + for k, v in kw.items(): + setattr(self, k, v) + + +class TestBuildAguiAssistantMessage: + def test_text_only(self): + class Msg: + content = [_Block("text", text="Hello")] + + msg = build_agui_assistant_message(Msg(), "m1") + assert msg is not None + assert msg.content == "Hello" + assert msg.id == "m1" + assert msg.tool_calls is None + + def test_tool_use_block(self): + class Msg: + content = [_Block("tool_use", id="tc1", name="mcp__ag_ui__search", input={"q": "x"})] + + msg = build_agui_assistant_message(Msg(), "m2") + assert msg is not None + assert msg.tool_calls is not None + assert len(msg.tool_calls) == 1 + # MCP prefix stripped for client matching + assert msg.tool_calls[0].function.name == "search" + assert json.loads(msg.tool_calls[0].function.arguments) == {"q": "x"} + + def test_skips_state_management_tool(self): + class Msg: + content = [ + _Block( + "tool_use", + id="tc1", + name=STATE_MANAGEMENT_TOOL_FULL_NAME, + input={"state_updates": {"x": 1}}, + ) + ] + + # Only the internal state tool -> nothing user-visible -> None + assert build_agui_assistant_message(Msg(), "m3") is None + + def test_reasoning_only_returns_none(self): + class Msg: + content = [] + + assert build_agui_assistant_message(Msg(), "m4") is None + + def test_real_sdk_blocks_build_assistant_message(self): + """Real Claude SDK TextBlock/ToolUseBlock build a proper message. + + The real Claude SDK ``TextBlock``/``ToolUseBlock`` dataclasses do NOT + expose a ``.type`` attribute. build_agui_assistant_message now + dispatches via ``isinstance`` against the real SDK block classes, so a + genuine ``TextBlock`` produces a populated AG-UI assistant message + instead of being silently dropped. + """ + from claude_agent_sdk.types import TextBlock, ToolUseBlock + + class Msg: + content = [ + TextBlock(text="Hello"), + ToolUseBlock(id="tc1", name="mcp__ag_ui__search", input={"q": "x"}), + ] + + msg = build_agui_assistant_message(Msg(), "m5") + assert msg is not None + assert msg.content == "Hello" + assert msg.id == "m5" + assert msg.tool_calls is not None + assert len(msg.tool_calls) == 1 + assert msg.tool_calls[0].function.name == "search" + assert json.loads(msg.tool_calls[0].function.arguments) == {"q": "x"} + + +class TestBuildAguiToolMessage: + def test_extracts_text_block_json(self): + content = [{"type": "text", "text": '{"temp": 72}'}] + msg = build_agui_tool_message("tc1", content) + assert msg.role == "tool" + assert msg.tool_call_id == "tc1" + assert msg.id == "tc1-result" + assert json.loads(msg.content) == {"temp": 72} + + def test_plain_text_passthrough(self): + content = [{"type": "text", "text": "not json"}] + msg = build_agui_tool_message("tc1", content) + assert msg.content == "not json" + + def test_none_content(self): + msg = build_agui_tool_message("tc1", None) + assert msg.content == "" + + def test_bare_string_not_double_quoted(self): + # A bare-string (non-JSON) result must be passed through unquoted, NOT + # json.dumps-quoted into '"plain"'. (Item 5 encoding symmetry) + msg = build_agui_tool_message("tc1", "plain") + assert msg.content == "plain" + + def test_bare_string_matches_list_text_block(self): + # The MESSAGES_SNAPSHOT builder must encode a logical tool result the + # SAME way regardless of whether the SDK delivered it as a bare string + # or as a list of text blocks — mirroring the TOOL_CALL_RESULT path's + # canonical normalization (Item 5). Otherwise the same result renders + # differently depending on transport shape. + for raw in ("not json", '{"temp": 72}', "[1, 2, 3]", "42"): + bare = build_agui_tool_message("tc1", raw) + listed = build_agui_tool_message( + "tc1", [{"type": "text", "text": raw}] + ) + assert bare.content == listed.content, ( + f"asymmetric encoding for {raw!r}: " + f"bare={bare.content!r} list={listed.content!r}" + ) diff --git a/integrations/claude-agent-sdk/python/uv.lock b/integrations/claude-agent-sdk/python/uv.lock index 351d8a1b2e..4c6b6e3baf 100644 --- a/integrations/claude-agent-sdk/python/uv.lock +++ b/integrations/claude-agent-sdk/python/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "ag-ui-claude-sdk" -version = "0.1.0" +version = "0.1.5" source = { editable = "." } dependencies = [ { name = "ag-ui-protocol" }, @@ -15,7 +15,7 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ { name = "httpx" }, { name = "pytest" }, @@ -24,28 +24,31 @@ dev = [ [package.metadata] requires-dist = [ - { name = "ag-ui-protocol", specifier = ">=0.1.0" }, + { name = "ag-ui-protocol", specifier = ">=0.1.15" }, { name = "anthropic", specifier = ">=0.68.0" }, { name = "claude-agent-sdk", specifier = ">=0.1.12" }, { name = "fastapi", specifier = ">=0.100.0" }, - { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.23.0" }, ] -provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.24.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, +] [[package]] name = "ag-ui-protocol" -version = "0.1.10" +version = "0.1.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/bb/5a5ec893eea5805fb9a3db76a9888c3429710dfb6f24bbb37568f2cf7320/ag_ui_protocol-0.1.10.tar.gz", hash = "sha256:3213991c6b2eb24bb1a8c362ee270c16705a07a4c5962267a083d0959ed894f4", size = 6945, upload-time = "2025-11-06T15:17:17.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720, upload-time = "2026-06-02T17:26:15.627Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/78/eb55fabaab41abc53f52c0918a9a8c0f747807e5306273f51120fd695957/ag_ui_protocol-0.1.10-py3-none-any.whl", hash = "sha256:c81e6981f30aabdf97a7ee312bfd4df0cd38e718d9fc10019c7d438128b93ab5", size = 7889, upload-time = "2025-11-06T15:17:15.325Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0a/bcad8116eb058e4b4a305e3fc37ebd7efc879deeb86b854f1c5b8b6e97dd/ag_ui_protocol-0.1.19-py3-none-any.whl", hash = "sha256:898843b1410d378824da0c6a776486288b9c5828689d0bf563118868e37f390f", size = 13490, upload-time = "2026-06-02T17:26:16.313Z" }, ] [[package]] diff --git a/integrations/claude-agent-sdk/typescript/LICENSE b/integrations/claude-agent-sdk/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/claude-agent-sdk/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/claude-agent-sdk/typescript/package.json b/integrations/claude-agent-sdk/typescript/package.json index 504ae7a619..4c8b6ee72b 100644 --- a/integrations/claude-agent-sdk/typescript/package.json +++ b/integrations/claude-agent-sdk/typescript/package.json @@ -1,6 +1,7 @@ { "name": "@ag-ui/claude-agent-sdk", "version": "0.0.3", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/integrations/community/cloudflare-agents/typescript/LICENSE b/integrations/community/cloudflare-agents/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/community/cloudflare-agents/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/community/spring-ai/typescript/LICENSE b/integrations/community/spring-ai/typescript/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/integrations/community/spring-ai/typescript/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/integrations/community/spring-ai/typescript/package.json b/integrations/community/spring-ai/typescript/package.json index 974f8e5d46..35ca6f8436 100644 --- a/integrations/community/spring-ai/typescript/package.json +++ b/integrations/community/spring-ai/typescript/package.json @@ -2,6 +2,7 @@ "name": "@ag-ui/spring-ai", "author": "Pascal Wilbrink", "version": "0.0.2", + "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/integrations/crew-ai/python/LICENSE b/integrations/crew-ai/python/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/crew-ai/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/crew-ai/python/pyproject.toml b/integrations/crew-ai/python/pyproject.toml index af50639f7d..193f57cc9d 100644 --- a/integrations/crew-ai/python/pyproject.toml +++ b/integrations/crew-ai/python/pyproject.toml @@ -2,6 +2,7 @@ name = "ag-ui-crewai" version = "0.2.0" description = "Implementation of the AG-UI protocol for CrewAI" +license = "MIT" authors = ["Markus Ecker "] readme = "README.md" exclude = [ diff --git a/integrations/crew-ai/typescript/LICENSE b/integrations/crew-ai/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/crew-ai/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/crew-ai/typescript/package.json b/integrations/crew-ai/typescript/package.json index 5c40d72184..160448513b 100644 --- a/integrations/crew-ai/typescript/package.json +++ b/integrations/crew-ai/typescript/package.json @@ -2,6 +2,7 @@ "name": "@ag-ui/crewai", "author": "Markus Ecker ", "version": "0.0.3", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/integrations/langchain/typescript/LICENSE b/integrations/langchain/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/langchain/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/langchain/typescript/package.json b/integrations/langchain/typescript/package.json index 606f55b53c..08589a6f9c 100644 --- a/integrations/langchain/typescript/package.json +++ b/integrations/langchain/typescript/package.json @@ -5,7 +5,7 @@ "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" }, - "license": "Apache-2.0", + "license": "MIT", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -31,17 +31,18 @@ "unlink:global": "pnpm unlink --global" }, "dependencies": { - "@langchain/core": "^0.3.80", "rxjs": "7.8.1" }, "peerDependencies": { "@ag-ui/core": ">=0.0.42", "@ag-ui/client": ">=0.0.42", + "@langchain/core": ">=0.3.0 <2.0.0", "zod": "^3.25.67" }, "devDependencies": { "@ag-ui/core": "workspace:*", "@ag-ui/client": "workspace:*", + "@langchain/core": "^0.3.80", "@types/node": "^20.11.19", "@vitest/coverage-istanbul": "^4.0.18", "publint": "^0.3.12", @@ -52,8 +53,8 @@ }, "exports": { ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.js" + "require": "./dist/index.js", + "import": "./dist/index.mjs" }, "./package.json": "./package.json" } diff --git a/integrations/langgraph/python/LICENSE b/integrations/langgraph/python/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/langgraph/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/langgraph/python/ag_ui_langgraph/__init__.py b/integrations/langgraph/python/ag_ui_langgraph/__init__.py index 52a8c135ee..4f2728bba5 100644 --- a/integrations/langgraph/python/ag_ui_langgraph/__init__.py +++ b/integrations/langgraph/python/ag_ui_langgraph/__init__.py @@ -19,9 +19,21 @@ from .utils import json_safe_stringify, make_json_safe from .endpoint import add_langgraph_fastapi_endpoint from .middlewares.state_streaming import StateStreamingMiddleware, StateItem +from .a2ui_tool import ( + get_a2ui_tools, + A2UIToolParams, + A2UIGuidelines, + A2UI_OPERATIONS_KEY, + BASIC_CATALOG_ID, +) __all__ = [ "LangGraphAgent", + "get_a2ui_tools", + "A2UIToolParams", + "A2UIGuidelines", + "A2UI_OPERATIONS_KEY", + "BASIC_CATALOG_ID", "LangGraphEventTypes", "CustomEventNames", "State", diff --git a/integrations/langgraph/python/ag_ui_langgraph/a2ui_tool.py b/integrations/langgraph/python/ag_ui_langgraph/a2ui_tool.py new file mode 100644 index 0000000000..e6a38d911f --- /dev/null +++ b/integrations/langgraph/python/ag_ui_langgraph/a2ui_tool.py @@ -0,0 +1,216 @@ +""" +A2UI subagent tool factory for LangGraph agents. + +Thin adapter over ``ag-ui-a2ui-toolkit`` — the heavy lifting (op builders, +prompt assembly, history walkers, output envelope) lives in the toolkit so +each new framework adapter (ADK, Mastra, Strands, …) only owns the +framework-specific glue: tool decorator, runtime state access, model +binding + invoke. + +Streaming: the subagent's ``render_a2ui`` call must STREAM to the AG-UI wire so +the a2ui middleware paints the surface progressively (the "building" skeleton +keys off the inner tool-call's arg deltas, not the final result). On LangGraph +this is FREE: the subagent runs ``model.astream`` inside the graph, so its +nested ``render_a2ui`` tool-call arg deltas surface natively as +``OnChatModelStream`` events, which the generic ``agent.py`` / ``agent.ts`` +translator already turns into inner TOOL_CALL_START/ARGS/END. So this adapter +does NOT emit any A2UI-specific custom events — it just streams the subagent and +hands the accumulated args to the recovery loop. (Frameworks whose SDK does NOT +surface a nested model stream as wire events — e.g. Strands — own that explicit +push in their own adapter; LangGraph never needs it.) + +Example usage in a chat node:: + + from ag_ui_langgraph import get_a2ui_tools + + a2ui = get_a2ui_tools({"model": ChatOpenAI(model="gpt-4o")}) + + model_with_tools = chat_model.bind_tools( + [*state["tools"], a2ui], + parallel_tool_calls=False, + ) +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Optional + +from langchain.tools import tool, ToolRuntime +from langchain_core.messages import SystemMessage + +from ag_ui_a2ui_toolkit import ( + A2UI_OPERATIONS_KEY, + A2UIGuidelines, + A2UIToolParams, + BASIC_CATALOG_ID, + RENDER_A2UI_TOOL_DEF, + build_a2ui_envelope, + prepare_a2ui_request, + resolve_a2ui_tool_params, + wrap_error_envelope, + run_a2ui_generation_with_recovery, +) + +logger = logging.getLogger("ag_ui_langgraph") + +#: Name of the render tool the A2UI middleware injects (and the subagent binds). +RENDER_A2UI_TOOL_NAME: str = RENDER_A2UI_TOOL_DEF["function"]["name"] + + +# Re-export the toolkit constants/types for callers that previously imported +# them from this package — keeps the public surface stable and lets consumers +# type the shared params object + its guidelines without depending on the +# toolkit package directly. +__all__ = [ + "get_a2ui_tools", + "A2UI_OPERATIONS_KEY", + "A2UIToolParams", + "A2UIGuidelines", + "BASIC_CATALOG_ID", +] + + +async def _stream_render_subagent( + model_with_tool: Any, + prompt: str, + messages: list, +) -> Optional[dict]: + """Run the structured-output subagent once and return the captured + ``render_a2ui`` args — or ``None`` if the model produced no call. + + Uses ``astream`` (not ``invoke``) so the nested ``render_a2ui`` tool-call + arg deltas surface natively as the graph's ``OnChatModelStream`` events — + which the generic ``agent.py`` / ``agent.ts`` translator already turns into + inner TOOL_CALL_START/ARGS/END, painting the surface progressively. This + adapter emits NO A2UI-specific events: it merely consumes the stream to + accumulate the final structured args for the recovery loop. + """ + accumulated = None + async for chunk in model_with_tool.astream( + [SystemMessage(content=prompt), *messages] + ): + # Accumulate the streamed AIMessageChunks so the final parsed tool_calls + # reconstruct even when each frame carries only an incremental arg + # fragment. (Surfacing the deltas on the wire is langgraph's job, via + # the OnChatModelStream events this astream emits.) + accumulated = chunk if accumulated is None else accumulated + chunk + + if accumulated is None: + return None + tool_calls = getattr(accumulated, "tool_calls", None) or [] + for call in tool_calls: + call_name = call.get("name") if isinstance(call, dict) else None + if call_name in (None, RENDER_A2UI_TOOL_NAME): + raw_args = call.get("args") if isinstance(call, dict) else None + return raw_args if isinstance(raw_args, dict) else {} + return None + + +def get_a2ui_tools(params: A2UIToolParams): + """Build a LangGraph tool that delegates A2UI surface generation to a subagent. + + The returned tool is decorated with ``@langchain.tools.tool`` and is + ready to bind into a chat model alongside any other tools. + + Args: + params: Shared ``A2UIToolParams`` (``model`` + behavior knobs). The + toolkit owns the shape and fills defaults via + ``resolve_a2ui_tool_params``. Every framework adapter takes this + exact params type — only the body below is LangGraph-specific, so a + new knob added to ``A2UIToolParams`` reaches this adapter with no + signature change. + + Returns: + A LangGraph tool callable suitable for ``bind_tools(...)``. + """ + # Shared: normalize knobs + fill canonical defaults so this adapter never + # re-implements default logic. A new params field + its default lives + # entirely in the toolkit. + cfg = resolve_a2ui_tool_params(params) + model = cfg["model"] + guidelines = cfg["guidelines"] + default_surface_id = cfg["default_surface_id"] + default_catalog_id = cfg["default_catalog_id"] + catalog = cfg["catalog"] + recovery = cfg["recovery"] + on_a2ui_attempt = cfg["on_a2ui_attempt"] + + @tool(cfg["tool_name"], description=cfg["tool_description"]) + async def generate_a2ui( + runtime: ToolRuntime[Any], + intent: str = "create", + target_surface_id: Optional[str] = None, + changes: Optional[str] = None, + ) -> str: + """Generate or edit an A2UI surface. + + Args: + intent: Either ``"create"`` to render a new surface, or ``"update"`` + to modify a surface previously rendered in this conversation. + target_surface_id: Required when ``intent="update"``. The surface + id of the prior render to modify. + changes: Optional natural-language description of the changes to + apply when ``intent="update"``. + """ + # Defensive: a custom state schema may not preseed ``messages``, and + # ``state["messages"]`` would then raise KeyError mid-tool — mirror the + # TS adapter's `state.messages ?? []` graceful-degrade. + messages = runtime.state.get("messages", [])[:-1] + + # Shared: decide create/update, find prior surface, build the prompt. + prep = prepare_a2ui_request( + intent=intent, + target_surface_id=target_surface_id, + changes=changes, + messages=messages, + state=runtime.state, + guidelines=guidelines, + ) + if prep.get("error"): + return wrap_error_envelope(prep["error"]) + + # Glue: bind the structured-output tool. + model_with_tool = model.bind_tools( + [RENDER_A2UI_TOOL_DEF], tool_choice="render_a2ui" + ) + + async def _invoke_subagent(prompt, _attempt): + return await _stream_render_subagent(model_with_tool, prompt, messages) + + def _build_envelope(args): + return build_a2ui_envelope( + args=args, + is_update=prep["is_update"], + target_surface_id=target_surface_id, + prior=prep["prior"], + default_surface_id=default_surface_id, + default_catalog_id=default_catalog_id, + ) + + # Shared: validate->retry loop (mirrors the TS adapter). On each retry the + # prompt is re-augmented with the prior attempt's structured errors; only a + # validated surface is committed (the middleware gate suppresses any + # unvalidated attempt, so a rejected one never paints). Returns a structured + # hard-failure envelope once the attempt cap is hit. + # + # The recovery loop is synchronous and calls ``invoke_subagent`` (here the + # async streaming subagent) per attempt. Run it in a worker thread so its + # blocking ``asyncio.run`` doesn't collide with THIS running event loop. + # The subagent's astream still emits OnChatModelStream on the run, so the + # surface paints progressively without this adapter emitting anything. + result = await asyncio.to_thread( + run_a2ui_generation_with_recovery, + base_prompt=prep["prompt"], + catalog=catalog, + config=recovery, + invoke_subagent=lambda prompt, attempt: asyncio.run( + _invoke_subagent(prompt, attempt) + ), + build_envelope=_build_envelope, + on_attempt=on_a2ui_attempt, + ) + return result["envelope"] + + return generate_a2ui diff --git a/integrations/langgraph/python/ag_ui_langgraph/agent.py b/integrations/langgraph/python/ag_ui_langgraph/agent.py index cb2b42ea9f..be12065031 100644 --- a/integrations/langgraph/python/ag_ui_langgraph/agent.py +++ b/integrations/langgraph/python/ag_ui_langgraph/agent.py @@ -16,6 +16,7 @@ from langchain_core.messages import BaseMessage, SystemMessage, ToolMessage from langchain_core.runnables import RunnableConfig, ensure_config +from langchain_core.runnables.config import merge_configs from langchain_core.messages import AIMessage, HumanMessage from langgraph.types import Command @@ -72,6 +73,7 @@ ReasoningEncryptedValueEvent, ) from ag_ui.encoder import EventEncoder +from ag_ui_a2ui_toolkit import split_a2ui_schema_context ProcessedEvents = Union[ TextMessageStartEvent, @@ -303,7 +305,13 @@ async def _handle_stream_events(self, input: RunAgentInput) -> AsyncGenerator[Pr event_type = event.get("event") event_run_id = event.get("run_id") if isinstance(event_run_id, str) and event_run_id: - self.active_run["id"] = event_run_id + # LangGraph's internal chain run_id. Track it separately + # rather than overwriting active_run["id"] (the + # client-supplied run_id from RunAgentInput): clobbering it + # made RUN_FINISHED carry the chain UUID while RUN_STARTED + # carried the client id, so the two disagreed (#1582). The + # client id is what the protocol must echo back. + self.active_run["langgraph_run_id"] = event_run_id elif event_run_id is not None: # Shape mismatch: some upstream emitted a non-string run_id. # Keep the existing id rather than corrupting it. @@ -645,12 +653,22 @@ async def prepare_regenerate_stream( # pylint: disable=too-many-arguments as_node=next_nodes[0] if next_nodes else "__start__", ) + # ``fork`` only carries the checkpoint-level configurable keys + # (``thread_id``, ``checkpoint_id``, ``checkpoint_ns``). Pass it + # alone and runtime settings from the caller's config -- notably + # ``recursion_limit`` and ``callbacks`` -- are silently dropped, + # so LangGraph stamps the default ``recursion_limit=25`` and any + # tracing / observability callbacks are lost. Merge the caller's + # config underneath the fork so checkpoint keys still win but + # everything else is preserved. Fixes #1749. + merged_config = merge_configs(config, fork) + stream_input = self.langgraph_default_merge_state(time_travel_checkpoint.values, [message_checkpoint], input) subgraphs_stream_enabled = input.forwarded_props.get('stream_subgraphs', True) if input.forwarded_props else True kwargs = self.get_stream_kwargs( input=stream_input, - config=fork, + config=merged_config, subgraphs=bool(subgraphs_stream_enabled), version="v2", ) @@ -871,17 +889,10 @@ def langgraph_default_merge_state(self, state: State, messages: List[BaseMessage # The A2UI schema goes into state["ag-ui"]["a2ui_schema"] so agents # can read it directly from state (e.g., for the generate_a2ui tool), # instead of it being dumped into the system prompt with all other context. - A2UI_SCHEMA_CONTEXT_DESCRIPTION = "A2UI Component Schema \u2014 available components for generating UI surfaces. Use these component names and props when creating A2UI operations." - - all_context = input.context or [] - a2ui_schema_value = None - regular_context = [] - for entry in all_context: - desc = entry.get("description", "") if isinstance(entry, dict) else getattr(entry, "description", "") - if desc == A2UI_SCHEMA_CONTEXT_DESCRIPTION: - a2ui_schema_value = entry.get("value", "") if isinstance(entry, dict) else getattr(entry, "value", "") - else: - regular_context.append(entry) + # The split (constant + matcher) lives in the shared a2ui toolkit so the + # LangGraph and Strands adapters agree on it. Covered by + # test_a2ui_schema_context_routed_into_ag_ui_state. + a2ui_schema_value, regular_context = split_a2ui_schema_context(input.context) ag_ui_state: dict = { "tools": unique_tools, @@ -890,6 +901,21 @@ def langgraph_default_merge_state(self, state: State, messages: List[BaseMessage if a2ui_schema_value is not None: ag_ui_state["a2ui_schema"] = a2ui_schema_value + # Surface the A2UI tool-injection flag (set by the A2UI middleware via + # forwardedProps.injectA2UITool) into ag-ui state so graphs/tools can + # read it directly from state. It is written here whenever the merged + # state is built (start/continue runs) and then persists in the + # checkpoint, so resumed runs still see it. forwarded_props keys are + # snake-cased in run() (camel_to_snake turns "injectA2UITool" into + # "inject_a2_u_i_tool" — pinned by test_camel_to_snake_key_contract), + # so check the converted key first and fall back to the raw camelCase + # form for safety. + forwarded = input.forwarded_props or {} + if "inject_a2_u_i_tool" in forwarded: + ag_ui_state["inject_a2ui_tool"] = forwarded["inject_a2_u_i_tool"] + elif "injectA2UITool" in forwarded: + ag_ui_state["inject_a2ui_tool"] = forwarded["injectA2UITool"] + return { **state, "messages": new_messages, @@ -1110,6 +1136,59 @@ def _chunk_get(c: Any, key: str, default: Any = None) -> Any: ) ) + if tool_call_data and tool_call_data.get("name") and message_content is not None: + text_stream_id = None + if current_stream and current_stream.get("id") and not current_stream.get("tool_call_id"): + text_stream_id = current_stream["id"] + elif message_content != "": + text_stream_id = chunk_id + if should_emit_messages: + yield self._dispatch_event( + TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + role="assistant", + message_id=text_stream_id, + raw_event=event, + ) + ) + + if text_stream_id and should_emit_messages: + if message_content != "": + yield self._dispatch_event( + TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=text_stream_id, + delta=message_content, + raw_event=event, + ) + ) + yield self._dispatch_event( + TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=text_stream_id, raw_event=event) + ) + + if text_stream_id: + self.messages_in_process[self.active_run["id"]] = None + current_stream = None + has_current_stream = False + is_message_end_event = False + is_tool_call_start_event = True + is_tool_call_args_event = False + is_tool_call_end_event = False + self.active_run["has_function_streaming"] = True + + if is_message_end_event and tool_call_data and tool_call_data.get("name"): + yield self._dispatch_event( + TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=current_stream["id"], raw_event=event) + ) + self.messages_in_process[self.active_run["id"]] = None + current_stream = None + has_current_stream = False + is_message_end_event = False + is_tool_call_start_event = True + is_tool_call_args_event = False + is_tool_call_end_event = False + self.active_run["has_function_streaming"] = True + if is_tool_call_end_event: yield self._dispatch_event( ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=current_stream["tool_call_id"], raw_event=event) @@ -1307,7 +1386,7 @@ def _chunk_get(c: Any, key: str, default: Any = None) -> Any: type=EventType.TOOL_CALL_START, tool_call_id=tool_msg.tool_call_id, tool_call_name=tool_msg.name or event.get("name", ""), - parent_message_id=tool_msg.id, + parent_message_id=str(tool_msg.id or tool_msg.tool_call_id), raw_event=event, ) ) @@ -1332,7 +1411,8 @@ def _chunk_get(c: Any, key: str, default: Any = None) -> Any: ToolCallResultEvent( type=EventType.TOOL_CALL_RESULT, tool_call_id=tool_msg.tool_call_id, - message_id=str(uuid.uuid4()), + # Match ToolMessage.id (or tool_call_id) so MESSAGES_SNAPSHOT merge works. + message_id=str(tool_msg.id or tool_msg.tool_call_id), content=normalize_tool_content(tool_msg.content), role="tool" ) @@ -1360,7 +1440,7 @@ def _chunk_get(c: Any, key: str, default: Any = None) -> Any: type=EventType.TOOL_CALL_START, tool_call_id=tool_call_output.tool_call_id, tool_call_name=tool_call_output.name or event.get("name", ""), - parent_message_id=tool_call_output.id, + parent_message_id=str(tool_call_output.id or tool_call_output.tool_call_id), raw_event=event, ) ) @@ -1385,7 +1465,8 @@ def _chunk_get(c: Any, key: str, default: Any = None) -> Any: ToolCallResultEvent( type=EventType.TOOL_CALL_RESULT, tool_call_id=tool_call_output.tool_call_id, - message_id=str(uuid.uuid4()), + # Match ToolMessage.id (or tool_call_id) so MESSAGES_SNAPSHOT merge works. + message_id=str(tool_call_output.id or tool_call_output.tool_call_id), content=normalize_tool_content(tool_call_output.content), role="tool" ) @@ -1420,6 +1501,17 @@ def handle_reasoning_event(self, reasoning_data: LangGraphReasoning) -> Generato ) return + # A text-less chunk is still meaningful when it carries the provider's + # canonical reasoning id (the `response.output_item.added` / + # `…summary_part.added` chunks): stash the id so the first text delta + # opens the reasoning message under it, WITHOUT opening a message here + # — a summary-less (store=true) reasoning item must keep rendering + # nothing. + if not reasoning_data["text"]: + if reasoning_data.get("id"): + self.active_run["pending_reasoning_id"] = reasoning_data["id"] + return + reasoning_step_index = reasoning_data.get("index", 0) if (self.active_run.get("reasoning_process") and @@ -1443,7 +1535,17 @@ def handle_reasoning_event(self, reasoning_data: LangGraphReasoning) -> Generato self.active_run["reasoning_process"] = None if not self.active_run.get("reasoning_process"): - message_id = str(uuid.uuid4()) + # Prefer the provider's canonical reasoning id (e.g. OpenAI + # ``rs_…``) when the stream carried one: the snapshot converter + # (_reasoning_block_to_agui_message) re-emits this same reasoning + # under that id, and only a matching id lets the client reconcile + # the streamed copy with the snapshot copy instead of rendering + # both. + message_id = ( + reasoning_data.get("id") + or self.active_run.pop("pending_reasoning_id", None) + or str(uuid.uuid4()) + ) yield self._dispatch_event( ReasoningStartEvent( type=EventType.REASONING_START, @@ -1609,9 +1711,14 @@ def get_stream_kwargs( version=version, ) - # Only add context if supported + # LangGraph may expose context either as a named parameter or through + # **kwargs, depending on the installed version. sig = inspect.signature(self.graph.astream_events) - if 'context' in sig.parameters: + accepts_context = ( + 'context' in sig.parameters + or any(param.kind == inspect.Parameter.VAR_KEYWORD for param in sig.parameters.values()) + ) + if accepts_context: base_context = {} if isinstance(config, dict) and 'configurable' in config and isinstance(config['configurable'], dict): base_context.update(config['configurable']) diff --git a/integrations/langgraph/python/ag_ui_langgraph/types.py b/integrations/langgraph/python/ag_ui_langgraph/types.py index 06582486f9..da93104499 100644 --- a/integrations/langgraph/python/ag_ui_langgraph/types.py +++ b/integrations/langgraph/python/ag_ui_langgraph/types.py @@ -46,6 +46,10 @@ class CustomEventNames(str, Enum): RunMetadata = TypedDict("RunMetadata", { # Identification "id": str, + # LangGraph's internal chain run_id, tracked separately so it never + # overwrites the client-supplied "id" used for the protocol RUN_STARTED / + # RUN_FINISHED events (#1582). + "langgraph_run_id": NotRequired[Optional[str]], "thread_id": NotRequired[Optional[str]], # Run mode/flow "mode": NotRequired[Literal["start", "continue"]], @@ -111,4 +115,9 @@ class LangGraphPlatformActionExecutionMessage(BaseLangGraphPlatformMessage): "text": str, "index": int, "signature": NotRequired[Optional[str]], + # The provider's canonical id for the reasoning item (e.g. OpenAI + # ``rs_…``), when the stream carries one. Used as the AG-UI reasoning + # message id so the streamed message reconciles with the snapshot copy + # emitted by ``_reasoning_block_to_agui_message`` under the same id. + "id": NotRequired[Optional[str]], }) diff --git a/integrations/langgraph/python/ag_ui_langgraph/utils.py b/integrations/langgraph/python/ag_ui_langgraph/utils.py index e57c09b925..228bc38159 100644 --- a/integrations/langgraph/python/ag_ui_langgraph/utils.py +++ b/integrations/langgraph/python/ag_ui_langgraph/utils.py @@ -16,6 +16,7 @@ AssistantMessage as AGUIAssistantMessage, SystemMessage as AGUISystemMessage, ToolMessage as AGUIToolMessage, + ReasoningMessage as AGUIReasoningMessage, ToolCall as AGUIToolCall, FunctionCall as AGUIFunctionCall, TextInputContent, @@ -112,6 +113,79 @@ def convert_langchain_multimodal_to_agui(content: List[Dict[str, Any]]) -> List[ )) return agui_content +def _reasoning_block_summary_text(block: Dict[str, Any]) -> str: + """Extract the human-readable reasoning text from a LangChain reasoning + content block (OpenAI Responses ``responses/v1`` shape).""" + summary = block.get("summary") + if isinstance(summary, list): + parts = [ + s.get("text", "") + for s in summary + if isinstance(s, dict) and s.get("text") + ] + if parts: + # Join multi-part summaries with a newline so the parts stay + # legible instead of being mashed together ("A\nB", not "AB"). + return "\n".join(parts) + # Fallbacks for non-OpenAI shapes that still carry a flat text field. + for key in ("reasoning", "text"): + val = block.get(key) + if isinstance(val, str) and val: + return val + return "" + + +def _reasoning_block_to_agui_message( + block: Dict[str, Any], assistant_id: str, index: int = 0 +) -> "AGUIReasoningMessage | None": + """Turn a LangChain reasoning content block into an AG-UI + ReasoningMessage, preserving the block id (so it round-trips back to the + provider as the same reasoning item) and any encrypted content (needed when + the provider is run statelessly with ``store=False``). + + Returns ``None`` for a block with neither text nor encrypted content — there + is nothing the client could render or round-trip. + """ + text = _reasoning_block_summary_text(block) + encrypted = block.get("encrypted_content") + block_id = block.get("id") + # The provider id (e.g. OpenAI ``rs_…``) is the round-trip handle: under + # ``store=True`` the summary/encrypted content are empty and the id alone is + # what lets the next request reference the stored reasoning. So emit whenever + # we have an id, text, or encrypted content; only a wholly empty block is + # dropped (nothing to render or round-trip). + if not block_id and not text and not encrypted: + return None + # Fall back to a deterministic id derived from the owning assistant message + # when the provider didn't supply one. Include the block index so multiple + # id-less reasoning blocks on one message don't collide on the same id. + block_id = block_id or f"{assistant_id}-reasoning-{index}" + return AGUIReasoningMessage( + id=str(block_id), + role="reasoning", + content=text, + encrypted_value=encrypted, + ) + + +def _agui_reasoning_message_to_block(message: AGUIReasoningMessage) -> Dict[str, Any]: + """Rebuild the LangChain reasoning content block from an AG-UI + ReasoningMessage so it can be re-attached to the adjacent assistant message + (the inverse of :func:`_reasoning_block_to_agui_message`).""" + block: Dict[str, Any] = { + "type": "reasoning", + "id": message.id, + "summary": ( + [{"type": "summary_text", "text": message.content}] + if message.content + else [] + ), + } + if getattr(message, "encrypted_value", None): + block["encrypted_content"] = message.encrypted_value + return block + + def langchain_messages_to_agui(messages: List[BaseMessage]) -> List[AGUIMessage]: agui_messages: List[AGUIMessage] = [] for message in messages: @@ -129,6 +203,19 @@ def langchain_messages_to_agui(messages: List[BaseMessage]) -> List[AGUIMessage] name=message.name, )) elif isinstance(message, AIMessage): + # Surface reasoning content blocks as standalone + # ReasoningMessages placed BEFORE the assistant message (matching + # streaming-event ordering), so a client with no persistent + # checkpoint can round-trip them back to the model. + if isinstance(message.content, list): + for index, block in enumerate(message.content): + if isinstance(block, dict) and block.get("type") == "reasoning": + reasoning_msg = _reasoning_block_to_agui_message( + block, str(message.id), index + ) + if reasoning_msg is not None: + agui_messages.append(reasoning_msg) + tool_calls = None if message.tool_calls: tool_calls = [ @@ -184,6 +271,16 @@ def _media_source_to_url(source: Union[InputContentDataSource, InputContentUrlSo return None +def _attach_input_metadata( + content_block: Dict[str, Any], + item: AGUIContentItem, +) -> Dict[str, Any]: + metadata = getattr(item, "metadata", None) + if metadata is not None: + content_block["metadata"] = metadata + return content_block + + def convert_agui_multimodal_to_langchain(content: List[AGUIContentItem]) -> List[Dict[str, Any]]: """Convert AG-UI multimodal content to LangChain's multimodal format. @@ -195,17 +292,17 @@ def convert_agui_multimodal_to_langchain(content: List[AGUIContentItem]) -> List langchain_content: List[Dict[str, Any]] = [] for item in content: if isinstance(item, TextInputContent): - langchain_content.append({ + langchain_content.append(_attach_input_metadata({ "type": "text", "text": item.text - }) + }, item)) elif isinstance(item, _MEDIA_CONTENT_TYPES): url = _media_source_to_url(item.source) if url: - langchain_content.append({ + langchain_content.append(_attach_input_metadata({ "type": "image_url", "image_url": {"url": url} - }) + }, item)) else: logger.warning("Dropping %s content: source could not be converted to URL", type(item).__name__) elif isinstance(item, BinaryInputContent): @@ -227,23 +324,38 @@ def convert_agui_multimodal_to_langchain(content: List[AGUIContentItem]) -> List ) continue - langchain_content.append(content_dict) + langchain_content.append(_attach_input_metadata(content_dict, item)) return langchain_content def agui_messages_to_langchain(messages: List[AGUIMessage]) -> List[BaseMessage]: langchain_messages = [] + # Reasoning AG-UI messages are display-only at the AG-UI layer, but + # at the LangChain layer reasoning lives as a content block ON the assistant + # AIMessage. To round-trip reasoning without loss (so a stateless client can + # hand the model back its own chain-of-thought), buffer each reasoning message and + # re-attach it as a content block on the assistant message that follows it + # (matching the order reasoning is streamed: reasoning first, then text). + # Developer messages stay dropped — they are configured on the agent itself. + # + # Reasoning that is NOT immediately followed by an assistant message (a + # trailing reasoning message, or one followed by a user/tool/system message) + # is intentionally discarded: there is no assistant to attach it to, and + # re-materializing it as a standalone message causes exponential message + # duplication and tool-call loops under the add_messages reducer. The + # snapshot side (langchain_messages_to_agui) only ever emits reasoning + # immediately before its assistant, so this drop never affects a real + # round-trip — only hand-crafted/ partial inputs. + pending_reasoning: list = [] for message in messages: role = message.role - # Reasoning + developer AG-UI messages are display-only / handled - # elsewhere; their content is already represented in adjacent AIMessage - # content blocks (reasoning) or in the agent's configured system prompt - # (developer). Re-materializing them as standalone LangChain messages - # duplicates context on every turn and can drive the model into a - # tool-call loop. - if role in ("reasoning", "developer"): + if role == "reasoning": + pending_reasoning.append(_agui_reasoning_message_to_block(message)) + continue + if role == "developer": continue if role == "user": + pending_reasoning = [] # Handle multimodal content if isinstance(message.content, str): content = message.content @@ -267,19 +379,29 @@ def agui_messages_to_langchain(messages: List[AGUIMessage]) -> List[BaseMessage] "args": json.loads(tc.function.arguments) if hasattr(tc, "function") and tc.function.arguments else {}, "type": "tool_call", }) + # Fold any buffered reasoning blocks onto this assistant message. + if pending_reasoning: + content = list(pending_reasoning) + if message.content: + content.append({"type": "text", "text": message.content}) + pending_reasoning = [] + else: + content = message.content or "" langchain_messages.append(AIMessage( id=message.id, - content=message.content or "", + content=content, tool_calls=tool_calls, name=message.name, )) elif role == "system": + pending_reasoning = [] langchain_messages.append(SystemMessage( id=message.id, content=message.content, name=message.name, )) elif role == "tool": + pending_reasoning = [] langchain_messages.append(ToolMessage( id=message.id, content=message.content, @@ -347,16 +469,40 @@ def resolve_reasoning_content(chunk: Any) -> LangGraphReasoning | None: return result # OpenAI Responses API v1 format: { type: "reasoning", summary: [{ text: "..." }] } - if block_type == "reasoning" and block.get("summary"): + # + # The reasoning item's canonical id (OpenAI ``rs_…``) only travels on + # text-less chunks: the `response.output_item.added` chunk + # ({ id, summary: [] }) and — depending on the langchain-openai + # version — the `…summary_part.added` chunk ({ id, summary: + # [{ text: "" }] }). The `…summary_text.delta` chunks carry text but + # no id. Surface the id carriers (instead of dropping them for having + # no text) so the streamed reasoning message can adopt the canonical + # id — the id the snapshot converter + # (_reasoning_block_to_agui_message) emits for the same block; + # handle_reasoning_event stashes the id without opening a message, so + # summary-less (store=true) items still render nothing. Only the + # first summary part takes the id: later parts belong to the same + # item, and reusing its id would mint two messages with one id. + if block_type == "reasoning" and isinstance(block.get("summary"), list): summaries = block["summary"] - if summaries and isinstance(summaries, list) and summaries[0]: + if not summaries and block.get("id"): + return LangGraphReasoning( + type="text", + text="", + index=block.get("index", 0), + id=str(block["id"]), + ) + if summaries and isinstance(summaries[0], dict): data = summaries[0] - if data.get("text"): - return LangGraphReasoning( + if data.get("text") or block.get("id"): + result = LangGraphReasoning( type="text", - text=data["text"], + text=data.get("text") or "", index=data.get("index", 0) ) + if block.get("id") and data.get("index", 0) == 0: + result["id"] = str(block["id"]) + return result # Bedrock Converse API format: { type: "reasoning_content", reasoning_content: { type: "text", text: "..." } } if block_type == "reasoning_content" and isinstance(block.get("reasoning_content"), dict): diff --git a/integrations/langgraph/python/examples/agents/a2ui_dynamic_schema/agent.py b/integrations/langgraph/python/examples/agents/a2ui_dynamic_schema/agent.py index 0c635f6a36..a73fcde488 100644 --- a/integrations/langgraph/python/examples/agents/a2ui_dynamic_schema/agent.py +++ b/integrations/langgraph/python/examples/agents/a2ui_dynamic_schema/agent.py @@ -6,73 +6,18 @@ middleware detects in the TOOL_CALL_RESULT and renders automatically. """ -import json import os -from typing import Any, List +import sys -from langchain.tools import tool, ToolRuntime -from langchain_core.messages import SystemMessage -from langchain_core.tools import tool as lc_tool -from langchain_core.runnables import RunnableConfig +from langchain.agents import create_agent from langchain_openai import ChatOpenAI -from langgraph.graph import StateGraph, END, MessagesState -from langgraph.prebuilt import ToolNode - -from copilotkit import a2ui - - -@lc_tool -def render_a2ui( - surfaceId: str, - catalogId: str, - components: list[dict], - data: dict | None = None, -) -> str: - """Render a dynamic A2UI v0.9 surface. - - Args: - surfaceId: Unique surface identifier. - catalogId: The catalog ID (use "https://a2ui.org/demos/dojo/custom_catalog.json"). - components: A2UI v0.9 component array (flat format). The root - component must have id "root". - data: Optional initial data model for the surface (e.g. form values, - list items for data-bound components). - """ - return "rendered" - - -def _build_context_prompt(state: dict) -> str: - """Build the A2UI generation prompt from client-provided context entries. - - The frontend sends generation guidelines, design guidelines, and the - component schema as separate context entries. The LangGraph integration - also extracts the schema into state["ag-ui"]["a2ui_schema"]. - """ - ag_ui = state.get("ag-ui", {}) - parts: list[str] = [] - - # Include all context entries (generation guidelines, design guidelines, etc.) - # Entries may be Pydantic Context objects or plain dicts. - for entry in ag_ui.get("context", []): - desc = entry.description - value = entry.value - if desc: - parts.append(f"## {desc}\n{value}\n") - else: - parts.append(f"{value}\n") - - # Include A2UI component schema (separated out by the LangGraph integration) - a2ui_schema = ag_ui.get("a2ui_schema") - if a2ui_schema: - parts.append(f"## Available Components\n{a2ui_schema}\n") - - return "\n".join(parts) - +from ag_ui_langgraph import get_a2ui_tools CUSTOM_CATALOG_ID = "https://a2ui.org/demos/dojo/dynamic_catalog.json" -# Local composition guide — tells the secondary LLM how to use our -# pre-made domain components (HotelCard, ProductCard, TeamMemberCard). +# Project-specific composition rules — tells the subagent how to use the +# pre-made domain components (HotelCard, ProductCard, TeamMemberCard) shipped +# in the dojo's dynamic catalog. COMPOSITION_GUIDE = """ ## Available Pre-made Components @@ -111,66 +56,17 @@ def _build_context_prompt(state: dict) -> str: - Generate 3-4 realistic items with diverse data """ +base_model = ChatOpenAI(model="gpt-4o") -@tool() -def generate_a2ui(runtime: ToolRuntime[Any]) -> str: - """Generate dynamic A2UI components based on the conversation. - - A secondary LLM designs the UI schema and data. The result is - returned as an a2ui_operations container for the middleware to detect. - """ - # The last message is this tool call (generate_a2ui) so we remove it, - # as it is not yet balanced with a tool call response. - messages = runtime.state["messages"][:-1] - - # Build prompt from client-provided context + local composition guide - prompt = _build_context_prompt(runtime.state) + "\n" + COMPOSITION_GUIDE - - model = ChatOpenAI(model="gpt-4.1") - model_with_tool = model.bind_tools( - [render_a2ui], - tool_choice="render_a2ui", +TOOLS = [ + get_a2ui_tools( + { + "model": base_model, + "default_catalog_id": CUSTOM_CATALOG_ID, + "guidelines": {"composition_guide": COMPOSITION_GUIDE}, + } ) - - response = model_with_tool.invoke( - [SystemMessage(content=prompt), *messages], - ) - - # Extract the render_a2ui tool call arguments - if not response.tool_calls: - return json.dumps({"error": "LLM did not call render_a2ui"}) - - tool_call = response.tool_calls[0] - args = tool_call["args"] - - surface_id = args.get("surfaceId", "dynamic-surface") - catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID) - components = args.get("components", []) - data = args.get("data", {}) - - # Wrap as v0.9 a2ui_operations so the middleware detects it - ops = [ - a2ui.create_surface(surface_id, catalog_id=catalog_id), - a2ui.update_components(surface_id, components), - ] - if data: - ops.append(a2ui.update_data_model(surface_id, data)) - - result = a2ui.render(operations=ops) - return result - - -TOOLS = [generate_a2ui] - - -class AgentState(MessagesState): - tools: List[Any] - copilotkit: dict # CopilotKit context (actions, etc.) - -# LangGraph requires state keys declared in the schema. -# "ag-ui" uses a hyphen which isn't valid as a Python identifier, -# so we patch it into the annotations directly. -AgentState.__annotations__["ag-ui"] = dict +] SYSTEM_PROMPT = """You are a helpful assistant that creates rich visual UI on the fly. @@ -180,37 +76,25 @@ class AgentState(MessagesState): IMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.""" -async def chat_node(state: AgentState, config: RunnableConfig): - model = ChatOpenAI(model="gpt-4o") - model = model.bind_tools(TOOLS, parallel_tool_calls=False) - - response = await model.ainvoke([ - SystemMessage(content=SYSTEM_PROMPT), - *state["messages"], - ], config) - - return {"messages": [response]} - - -def route_after_chat(state: AgentState): - last_message = state["messages"][-1] - if hasattr(last_message, "tool_calls") and last_message.tool_calls: - return "tool_node" - return END - - -workflow = StateGraph(AgentState) -workflow.add_node("chat_node", chat_node) -workflow.add_node("tool_node", ToolNode(tools=TOOLS)) -workflow.set_entry_point("chat_node") -workflow.add_conditional_edges("chat_node", route_after_chat) -workflow.add_edge("tool_node", "chat_node") - +# Converted from a manual StateGraph + ToolNode to create_agent to isolate the +# graph-shape variable in the A2UI-streaming investigation. The same +# get_a2ui_tools tool is bound directly (NOT auto-injected via +# CopilotKitMiddleware), so the ONLY difference vs the prior version is +# StateGraph -> create_agent. is_fast_api = os.environ.get("LANGGRAPH_FAST_API", "false").lower() == "true" if is_fast_api: from langgraph.checkpoint.memory import MemorySaver - memory = MemorySaver() - graph = workflow.compile(checkpointer=memory) + + graph = create_agent( + model=base_model, + tools=TOOLS, + system_prompt=SYSTEM_PROMPT, + checkpointer=MemorySaver(), + ) else: - graph = workflow.compile() + graph = create_agent( + model=base_model, + tools=TOOLS, + system_prompt=SYSTEM_PROMPT, + ) diff --git a/integrations/langgraph/python/examples/agents/dojo.py b/integrations/langgraph/python/examples/agents/dojo.py index a87fc9f186..9b8892de3e 100644 --- a/integrations/langgraph/python/examples/agents/dojo.py +++ b/integrations/langgraph/python/examples/agents/dojo.py @@ -83,7 +83,7 @@ description="Fixed-schema A2UI flight search (no streaming).", graph=a2ui_fixed_schema_graph, ), -"a2ui_dynamic_schema": LangGraphAgent( + "a2ui_dynamic_schema": LangGraphAgent( name="a2ui_dynamic_schema", description="Dynamic A2UI with LLM-generated UI schema.", graph=a2ui_dynamic_schema_graph, @@ -154,4 +154,4 @@ def main(): """Run the uvicorn server.""" port = int(os.getenv("PORT", "8000")) - uvicorn.run("agents.dojo:app", host="0.0.0.0", port=port, reload=True) + uvicorn.run("agents.dojo:app", host="0.0.0.0", port=port, reload=True, reload_dirs=[".", "../ag_ui_langgraph"]) diff --git a/integrations/langgraph/python/examples/pyproject.toml b/integrations/langgraph/python/examples/pyproject.toml index 0fe9f9b29e..4c3c668b28 100644 --- a/integrations/langgraph/python/examples/pyproject.toml +++ b/integrations/langgraph/python/examples/pyproject.toml @@ -18,10 +18,12 @@ dependencies = [ "langchain-google-genai>=2.1.12", "langchain-openai>=1.0.1", "langgraph>=1.1.3,<2", - # Pin: 0.7.97 requires DATABASE_URI at import time, breaking in-memory dev server - "langgraph-api>=0.7.70,<0.7.97", - "ag-ui-langgraph", - "ag-ui-protocol", + # No upper cap: the deploy base image pins grpcio 1.80, which only + # langgraph-api>=0.9.0 satisfies. The DATABASE_URI-at-import regression that + # once justified a <0.7.97 cap is resolved by 0.9.0 (imports clean inmem). + "langgraph-api>=0.7.70", + "ag-ui-langgraph>=0.0.37", + "ag-ui-protocol>=0.1.18", "python-dotenv>=1.0.0", "fastapi>=0.115.12", ] @@ -29,10 +31,6 @@ dependencies = [ [tool.uv] override-dependencies = ["langgraph>=1.1.3,<2"] -[tool.uv.sources] -ag-ui-langgraph = { path = "../" } -ag-ui-protocol = { path = "../../../../sdks/python" } - [project.scripts] dev = "agents.dojo:main" diff --git a/integrations/langgraph/python/examples/uv.lock b/integrations/langgraph/python/examples/uv.lock index 69396b1d64..440bdba7cc 100644 --- a/integrations/langgraph/python/examples/uv.lock +++ b/integrations/langgraph/python/examples/uv.lock @@ -9,47 +9,48 @@ resolution-markers = [ [manifest] overrides = [{ name = "langgraph", specifier = ">=1.1.3,<2" }] +[[package]] +name = "ag-ui-a2ui-toolkit" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/ce/85f3960a83d962e5690bc0f27a3baf3bf1602edc2b0603085928c964ea14/ag_ui_a2ui_toolkit-0.0.4.tar.gz", hash = "sha256:172e2724e53df8173685a3fb896a6e5175eea06e1dc166c715db110ba4beba76", size = 18960, upload-time = "2026-06-17T13:34:28.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/7a/acf85b01cd996bd011b71e181fd9f3daff5396fc3b7d78ba9445bfc08ecf/ag_ui_a2ui_toolkit-0.0.4-py3-none-any.whl", hash = "sha256:236fc511e1ec2399bcda0c14a109b3fb0a0c3e3988c18ef1918745b1c1535e30", size = 21315, upload-time = "2026-06-17T13:34:29.505Z" }, +] + [[package]] name = "ag-ui-langgraph" -version = "0.0.30" -source = { directory = "../" } +version = "0.0.41" +source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ag-ui-a2ui-toolkit" }, { name = "ag-ui-protocol" }, { name = "langchain" }, { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/7e/2a/e94bf83b81a540c59718bb8f848641bd5afa2ebc17b191b00010a0b86837/ag_ui_langgraph-0.0.41.tar.gz", hash = "sha256:3ea1fcb49b147d9532b0a90f2a5554d6ffd0d9365590fa2557ba16a881aeeb7a", size = 316557, upload-time = "2026-06-09T06:18:20.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/dd/61220366e161d8f3eabe598bb955bd07254f518e5163ebd63de4769c850c/ag_ui_langgraph-0.0.41-py3-none-any.whl", hash = "sha256:ff5f4c3d03305ff51329543ff61bd686a3e0b5c3c4ea071b3575f328d13936ae", size = 33345, upload-time = "2026-06-09T06:18:19.117Z" }, +] [package.optional-dependencies] fastapi = [ { name = "fastapi" }, ] -[package.metadata] -requires-dist = [ - { name = "ag-ui-protocol", specifier = ">=0.1.10" }, - { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.115.12" }, - { name = "langchain", specifier = ">=1.2.0" }, - { name = "langchain-core", specifier = ">=0.3.0" }, - { name = "langgraph", specifier = ">=0.3.25,<2" }, - { name = "pydantic", specifier = ">=2.0.0" }, -] -provides-extras = ["fastapi"] - -[package.metadata.requires-dev] -dev = [{ name = "fastapi", specifier = ">=0.115.12" }] - [[package]] name = "ag-ui-protocol" -version = "0.1.15" -source = { directory = "../../../../sdks/python" } +version = "0.1.18" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] - -[package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.11.2" }] +sdist = { url = "https://files.pythonhosted.org/packages/4c/d7/5711eada86da9bd7684e58645653a1693ef20b66cc3efbb1deeafef80f8d/ag_ui_protocol-0.1.18.tar.gz", hash = "sha256:b37c672c3fd6bac12b316c39f45ad9db9f137bbb885489c79f268507029a22ff", size = 9937, upload-time = "2026-04-21T20:44:59.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/74/913c9b8fc566c6da650aecbddf25a5d8186b54138df265eb9eb546f56141/ag_ui_protocol-0.1.18-py3-none-any.whl", hash = "sha256:d151c0f0a34160647f1571163f7185746f4326b15a56d1560de5082a7a0e7a12", size = 12607, upload-time = "2026-04-21T20:45:00.097Z" }, +] [[package]] name = "aiohappyeyeballs" @@ -1135,8 +1136,8 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "ag-ui-langgraph", directory = "../" }, - { name = "ag-ui-protocol", directory = "../../../../sdks/python" }, + { name = "ag-ui-langgraph", specifier = ">=0.0.37" }, + { name = "ag-ui-protocol", specifier = ">=0.1.18" }, { name = "copilotkit", specifier = "==0.1.86" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "fastapi", specifier = ">=0.115.12" }, @@ -1147,7 +1148,7 @@ requires-dist = [ { name = "langchain-google-genai", specifier = ">=2.1.12" }, { name = "langchain-openai", specifier = ">=1.0.1" }, { name = "langgraph", specifier = ">=1.1.3,<2" }, - { name = "langgraph-api", specifier = ">=0.7.70,<0.7.97" }, + { name = "langgraph-api", specifier = ">=0.7.70" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "uvicorn", specifier = ">=0.34.0" }, ] diff --git a/integrations/langgraph/python/pyproject.toml b/integrations/langgraph/python/pyproject.toml index 10a09c8bb3..fd72554e43 100644 --- a/integrations/langgraph/python/pyproject.toml +++ b/integrations/langgraph/python/pyproject.toml @@ -1,7 +1,9 @@ [project] name = "ag-ui-langgraph" -version = "0.0.36" +version = "0.0.42" description = "Implementation of the AG-UI protocol for LangGraph." +license = "MIT" +license-files = ["LICENSE"] authors = [ { name = "Ran Shem Tov", email = "ran@copilotkit.ai" } ] @@ -9,6 +11,7 @@ readme = "README.md" requires-python = ">=3.10,<3.15" dependencies = [ "ag-ui-protocol>=0.1.15", + "ag-ui-a2ui-toolkit>=0.0.4", "langchain>=1.2.0", "langchain-core>=0.3.0", "langgraph>=0.3.25,<2", diff --git a/integrations/langgraph/python/tests/test_a2ui_tool.py b/integrations/langgraph/python/tests/test_a2ui_tool.py new file mode 100644 index 0000000000..3b2787b862 --- /dev/null +++ b/integrations/langgraph/python/tests/test_a2ui_tool.py @@ -0,0 +1,205 @@ +"""Integration tests for the LangGraph A2UI tool factory (``get_a2ui_tools``). + +These run in the ``langgraph-python`` unit job, which builds the LOCAL adapter +and (via the adapter's ``[tool.uv.sources]`` path) the LOCAL toolkit — so they +exercise the real in-repo code. The dojo e2e suite can't cover this: it installs +the PUBLISHED ``ag-ui-langgraph`` (the langgraph-cloud build rejects local path +deps that escape the examples root), so the new single-arg ``A2UIToolParams`` / +``guidelines`` surface has no e2e coverage until it ships. This file is that +coverage. + +A lightweight fake chat model STREAMS a fixed ``render_a2ui`` tool call as +several ``AIMessageChunk``s (mirroring how a real provider streams tool-call arg +fragments). The tests assert both the emitted operations envelope and that the +generation/design/composition guidance reaches the subagent — and, critically, +that the inner render call is surfaced as PROGRESSIVE TOOL_CALL_ARGS deltas (the +parity fix), not one bulk paint at the end. +""" + +from __future__ import annotations + +import asyncio +import json +import unittest + +from langchain_core.messages import AIMessageChunk +from langchain_core.messages.tool import tool_call_chunk + +from ag_ui_langgraph import get_a2ui_tools +from ag_ui_langgraph.a2ui_tool import _stream_render_subagent +from ag_ui_a2ui_toolkit import ( + A2UI_OPERATIONS_KEY, + DEFAULT_DESIGN_GUIDELINES, + DEFAULT_GENERATION_GUIDELINES, +) + + +# A structurally-valid render_a2ui result (root present, child resolves, no +# cycle) so the toolkit's recovery/validation commits on the first attempt. +VALID_ARGS = { + "surfaceId": "s1", + "components": [ + {"id": "root", "component": "Column", "children": ["t"]}, + {"id": "t", "component": "Text", "text": "hi"}, + ], + "data": {}, +} + + +def _arg_chunks(args: dict, parts: int = 3) -> list[str]: + """Split the JSON of ``args`` into ``parts`` non-empty fragments, the way a + provider streams tool-call arg deltas.""" + text = json.dumps(args) + size = max(1, len(text) // parts) + chunks = [text[i : i + size] for i in range(0, len(text), size)] + return chunks or [text] + + +class _StreamingBoundModel: + """What ``model.bind_tools(...)`` returns — records the system prompt it is + streamed with and replays a fixed ``render_a2ui`` tool call as several + ``AIMessageChunk``s (one per arg fragment), like a real streaming provider.""" + + def __init__(self, parent: "FakeModel"): + self._parent = parent + + async def astream(self, messages): + # The adapter streams with [SystemMessage(prompt), *history]; capture the + # system prompt so tests can assert what guidance the subagent saw. + self._parent.captured_prompts.append(messages[0].content) + fragments = _arg_chunks(self._parent.args) + call_id = "call-1" + for index, fragment in enumerate(fragments): + yield AIMessageChunk( + content="", + tool_call_chunks=[ + tool_call_chunk( + # Name + id only on the first fragment, mirroring how + # providers stamp them once at the start of the call. + name="render_a2ui" if index == 0 else None, + args=fragment, + id=call_id if index == 0 else None, + index=0, + ) + ], + ) + + +class FakeModel: + """Minimal chat-model stand-in: only ``bind_tools`` + ``astream`` are used.""" + + def __init__(self, args): + self.args = args + self.captured_prompts: list[str] = [] + + def bind_tools(self, tools, tool_choice=None): + return _StreamingBoundModel(self) + + +class FakeRuntime: + """Stand-in for LangGraph's ``ToolRuntime`` — the tool reads ``state`` and + ``config`` (the latter forwarded to ``adispatch_custom_event``).""" + + def __init__(self, state, config=None): + self.state = state + self.config = config + + +def _invoke_tool(tool, runtime, **kwargs) -> str: + """Drive the tool's async coroutine directly with a stub runtime, bypassing + the graph's runtime injection. Runs to completion on a fresh event loop.""" + return asyncio.run(tool.coroutine(runtime, **kwargs)) + + +class TestGetA2UITools(unittest.TestCase): + def _make(self, guidelines=None, tool_name=None): + model = FakeModel(VALID_ARGS) + params = {"model": model, "default_catalog_id": "cat://custom"} + if guidelines is not None: + params["guidelines"] = guidelines + if tool_name is not None: + params["tool_name"] = tool_name + return get_a2ui_tools(params), model + + def test_single_arg_params_produces_operations_envelope(self): + # Guards the exact regression that broke CI: the factory must accept a + # single A2UIToolParams dict (model inside) and drive a render. + tool, _model = self._make() + envelope = _invoke_tool( + tool, FakeRuntime({"messages": []}), intent="create" + ) + parsed = json.loads(envelope) + ops = parsed[A2UI_OPERATIONS_KEY] + self.assertTrue(any("createSurface" in o for o in ops)) + self.assertTrue(any("updateComponents" in o for o in ops)) + # Catalog ownership stays with the host (from params), never the model. + create = next(o for o in ops if "createSurface" in o) + self.assertEqual(create["createSurface"]["catalogId"], "cat://custom") + + def test_default_guidelines_reach_the_subagent_prompt(self): + # No guidelines passed → the built-in generation + design defaults must + # be injected into the subagent system prompt (OSS-248 re-enable). + tool, model = self._make() + _invoke_tool(tool, FakeRuntime({"messages": []}), intent="create") + prompt = model.captured_prompts[0] + self.assertIn(DEFAULT_GENERATION_GUIDELINES, prompt) + self.assertIn("## Design Guidelines", prompt) + self.assertIn(DEFAULT_DESIGN_GUIDELINES, prompt) + + def test_composition_guide_and_overrides_flow_through(self): + tool, model = self._make( + guidelines={ + "generation_guidelines": "CUSTOM_GEN", + "composition_guide": "COMPMARK", + } + ) + _invoke_tool(tool, FakeRuntime({"messages": []}), intent="create") + prompt = model.captured_prompts[0] + # Per-field override replaces generation; design keeps its default. + self.assertIn("CUSTOM_GEN", prompt) + self.assertNotIn(DEFAULT_GENERATION_GUIDELINES, prompt) + self.assertIn(DEFAULT_DESIGN_GUIDELINES, prompt) + self.assertIn("COMPMARK", prompt) + + def test_tool_name_resolves(self): + default_tool, _ = self._make() + self.assertEqual(default_tool.name, "generate_a2ui") + custom_tool, _ = self._make(tool_name="render_ui") + self.assertEqual(custom_tool.name, "render_ui") + + +class TestStreamRenderSubagent(unittest.TestCase): + """The subagent STREAMS the model (``astream``) so the nested render_a2ui + tool-call arg deltas surface natively as the graph's OnChatModelStream + events — which the generic agent.py / agent.ts translator paints + progressively. This adapter emits nothing itself; it just accumulates the + streamed chunks and returns the final render args for the recovery loop. + Verify that multi-chunk accumulation reconstructs the full surface.""" + + def test_accumulates_streamed_chunks_into_final_args(self): + model = FakeModel(VALID_ARGS) + # _stream_render_subagent expects an already-bound model (bind_tools is + # done by the factory); the fake's bound model ignores the tool def and + # replays the render call as several partial AIMessageChunk fragments. + bound = model.bind_tools([]) + captured = asyncio.run(_stream_render_subagent(bound, "PROMPT", [])) + # The chunk fragments merged back into the full structured args. + self.assertEqual(captured, VALID_ARGS) + + def test_returns_none_when_no_render_call(self): + # A stream that produces no render_a2ui call -> None, which the recovery + # loop records as a failed attempt (retry / hard-failure envelope). + model = FakeModel(VALID_ARGS) + bound = model.bind_tools([]) + + async def _empty_astream(_messages): + if False: # pragma: no cover - generator with no yields + yield None + + bound.astream = _empty_astream + captured = asyncio.run(_stream_render_subagent(bound, "PROMPT", [])) + self.assertIsNone(captured) + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/langgraph/python/tests/test_get_stream_kwargs.py b/integrations/langgraph/python/tests/test_get_stream_kwargs.py new file mode 100644 index 0000000000..97be5b2f6e --- /dev/null +++ b/integrations/langgraph/python/tests/test_get_stream_kwargs.py @@ -0,0 +1,67 @@ +import unittest + +from ag_ui_langgraph.agent import LangGraphAgent + + +class _GraphWithNamedContext: + nodes = {} + + def astream_events(self, input, subgraphs=False, version="v2", context=None): + raise NotImplementedError + + +class _GraphWithKwargs: + nodes = {} + + def astream_events(self, *args, **kwargs): + raise NotImplementedError + + +class _GraphWithoutContext: + nodes = {} + + def astream_events(self, input, subgraphs=False, version="v2"): + raise NotImplementedError + + +class GetStreamKwargsTest(unittest.TestCase): + def test_merges_context_for_named_context_parameter(self): + agent = LangGraphAgent(name="test", graph=_GraphWithNamedContext()) + + kwargs = agent.get_stream_kwargs( + input={"messages": []}, + config={"configurable": {"thread_id": "t-1", "tenant": "from-config"}}, + context={"tenant": "from-context", "locale": "en"}, + ) + + self.assertEqual( + kwargs["context"], + {"thread_id": "t-1", "tenant": "from-context", "locale": "en"}, + ) + + def test_merges_context_for_kwargs_signature(self): + agent = LangGraphAgent(name="test", graph=_GraphWithKwargs()) + + kwargs = agent.get_stream_kwargs( + input={"messages": []}, + config={"configurable": {"thread_id": "t-2"}}, + context={"locale": "en"}, + ) + + self.assertEqual(kwargs["context"], {"thread_id": "t-2", "locale": "en"}) + + def test_omits_context_for_older_signature(self): + agent = LangGraphAgent(name="test", graph=_GraphWithoutContext()) + + kwargs = agent.get_stream_kwargs( + input={"messages": []}, + config={"configurable": {"thread_id": "t-3"}}, + context={"locale": "en"}, + ) + + self.assertNotIn("context", kwargs) + self.assertEqual(kwargs["config"], {"configurable": {"thread_id": "t-3"}}) + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/langgraph/python/tests/test_message_conversion.py b/integrations/langgraph/python/tests/test_message_conversion.py index 9628a84584..6a5f1b4f57 100644 --- a/integrations/langgraph/python/tests/test_message_conversion.py +++ b/integrations/langgraph/python/tests/test_message_conversion.py @@ -134,11 +134,12 @@ def test_multiple_messages_ordering(self): assert isinstance(result[1], AIMessage) assert isinstance(result[2], HumanMessage) - def test_reasoning_messages_dropped(self): - # Reasoning content is already represented inside the assistant - # AIMessage's content blocks at the LangChain layer; emitting a - # separate LangGraph message would duplicate context on the next turn - # and can drive the model into a tool-call loop. + def test_reasoning_messages_folded_into_assistant(self): + # Reasoning belongs as a content block ON the assistant AIMessage at the + # LangChain layer. It is not emitted as a standalone LangChain + # message — that would duplicate context and can drive a tool-call loop — + # but it must not be dropped either, or the model loses its + # chain-of-thought on a stateless round-trip. msgs = [ AGUIUserMessage(id="u1", role="user", content="Hi"), AGUIReasoningMessage(id="r1", role="reasoning", content="thinking..."), @@ -148,6 +149,13 @@ def test_reasoning_messages_dropped(self): assert len(result) == 2 assert isinstance(result[0], HumanMessage) assert isinstance(result[1], AIMessage) + # Reasoning is folded onto the assistant, not dropped. + reasoning_blocks = [ + b for b in result[1].content + if isinstance(b, dict) and b.get("type") == "reasoning" + ] + assert len(reasoning_blocks) == 1 + assert reasoning_blocks[0]["id"] == "r1" def test_developer_messages_dropped(self): # Developer prompts are configured on the agent itself, not round-tripped. @@ -349,3 +357,223 @@ def test_agui_assistant_message_no_tool_calls_converts(self): result = agui_messages_to_langchain([msg]) assert isinstance(result[0], AIMessage) assert result[0].tool_calls == [] + + +class TestReasoningRoundTrip: + """Reasoning must survive AG-UI <-> LangChain conversion losslessly. + + An OpenAI reasoning model (Responses API) emits reasoning as a + content block on the assistant AIMessage. AG-UI carries it as a separate + ``role:"reasoning"`` message. Without a lossless converter pair, a stateless + round-trip (no checkpoint to retain the block) drops the reasoning, so the + model loses its own chain-of-thought on the next turn. + """ + + def test_reasoning_message_reattached_to_adjacent_assistant(self): + """AG-UI -> LangChain: a reasoning message is folded into the following + assistant AIMessage as a content block (not dropped, not a standalone + message).""" + msgs = [ + AGUIUserMessage(id="u1", role="user", content="Hi"), + AGUIReasoningMessage( + id="rs_abc", role="reasoning", content="step 1; step 2", + encrypted_value="ENC123", + ), + AGUIAssistantMessage(id="a1", role="assistant", content="Hello"), + ] + result = agui_messages_to_langchain(msgs) + + # No standalone reasoning message — it's folded into the assistant. + assert len(result) == 2 + assert isinstance(result[0], HumanMessage) + assert isinstance(result[1], AIMessage) + + content = result[1].content + assert isinstance(content, list), "assistant content should be a block list" + reasoning_blocks = [ + b for b in content if isinstance(b, dict) and b.get("type") == "reasoning" + ] + assert len(reasoning_blocks) == 1 + rb = reasoning_blocks[0] + assert rb["id"] == "rs_abc" + assert rb.get("encrypted_content") == "ENC123" + summary_text = " ".join( + s.get("text", "") for s in rb.get("summary", []) if isinstance(s, dict) + ) + assert "step 1" in summary_text + # The assistant's own text is preserved alongside the reasoning block. + text_blocks = [ + b for b in content + if isinstance(b, dict) and b.get("type") == "text" and b.get("text") == "Hello" + ] + assert len(text_blocks) == 1 + + def test_ai_reasoning_block_emitted_as_reasoning_message(self): + """LangChain -> AG-UI: a reasoning content block becomes a ReasoningMessage + placed before the assistant message, carrying the block id + encrypted + content so it is stable across snapshots.""" + msg = AIMessage( + id="a1", + content=[ + { + "type": "reasoning", + "id": "rs_abc", + "summary": [{"type": "summary_text", "text": "step 1; step 2"}], + "encrypted_content": "ENC123", + }, + {"type": "text", "text": "Hello"}, + ], + ) + result = langchain_messages_to_agui([msg]) + + assert len(result) == 2 + reasoning, assistant = result[0], result[1] + assert reasoning.role == "reasoning" + assert reasoning.id == "rs_abc" + assert reasoning.content == "step 1; step 2" + assert reasoning.encrypted_value == "ENC123" + assert assistant.role == "assistant" + assert assistant.content == "Hello" + + def test_reasoning_block_with_only_id_is_preserved(self): + """Real OpenAI Responses (store=True) persists the reasoning block as + just an ``rs_`` id with empty summary/content. The id is the round-trip + handle, so it must still be surfaced and re-attached.""" + msg = AIMessage( + id="a1", + content=[ + {"type": "reasoning", "id": "rs_only", "summary": [], "content": []}, + {"type": "text", "text": "Done."}, + ], + ) + agui = langchain_messages_to_agui([msg]) + reasoning_msgs = [m for m in agui if m.role == "reasoning"] + assert len(reasoning_msgs) == 1 + assert reasoning_msgs[0].id == "rs_only" + + back = agui_messages_to_langchain(agui) + blocks = [ + b for b in back[0].content + if isinstance(b, dict) and b.get("type") == "reasoning" + ] + assert len(blocks) == 1 + assert blocks[0]["id"] == "rs_only" + + def test_reasoning_round_trips_losslessly(self): + """langchain -> agui -> langchain preserves the reasoning block id and + encrypted content on the assistant AIMessage.""" + original = AIMessage( + id="a1", + content=[ + { + "type": "reasoning", + "id": "rs_abc", + "summary": [{"type": "summary_text", "text": "because X implies Y"}], + "encrypted_content": "ENC123", + }, + {"type": "text", "text": "The answer is 42."}, + ], + ) + agui = langchain_messages_to_agui([original]) + back = agui_messages_to_langchain(agui) + + assert len(back) == 1 + assert isinstance(back[0], AIMessage) + reasoning_blocks = [ + b for b in back[0].content + if isinstance(b, dict) and b.get("type") == "reasoning" + ] + assert len(reasoning_blocks) == 1 + assert reasoning_blocks[0]["id"] == "rs_abc" + assert reasoning_blocks[0].get("encrypted_content") == "ENC123" + # The summary text (the human-readable chain-of-thought) must survive too, + # not just the id/encrypted handle. + summary_text = "".join( + s.get("text", "") for s in reasoning_blocks[0].get("summary", []) + if isinstance(s, dict) + ) + assert "because X implies Y" in summary_text + # The assistant's own text block survives alongside the reasoning. + assert any( + isinstance(b, dict) and b.get("type") == "text" + and b.get("text") == "The answer is 42." + for b in back[0].content + ) + + def test_multipart_summary_text_survives_round_trip(self): + """A reasoning block with multiple summary parts keeps every part's text + on the round-trip (joined, not dropped).""" + original = AIMessage( + id="a1", + content=[ + { + "type": "reasoning", + "id": "rs_multi", + "summary": [ + {"type": "summary_text", "text": "first part"}, + {"type": "summary_text", "text": "second part"}, + ], + }, + {"type": "text", "text": "Answer."}, + ], + ) + back = agui_messages_to_langchain(langchain_messages_to_agui([original])) + block = next( + b for b in back[0].content + if isinstance(b, dict) and b.get("type") == "reasoning" + ) + text = "".join( + s.get("text", "") for s in block.get("summary", []) if isinstance(s, dict) + ) + assert "first part" in text + assert "second part" in text + + def test_multiple_idless_reasoning_blocks_get_distinct_ids(self): + """Two reasoning blocks on one message that lack a provider id must not + collapse onto a single shared fallback id.""" + msg = AIMessage( + id="a1", + content=[ + {"type": "reasoning", "summary": [{"text": "alpha"}]}, + {"type": "reasoning", "summary": [{"text": "beta"}]}, + {"type": "text", "text": "Done."}, + ], + ) + reasoning_msgs = [m for m in langchain_messages_to_agui([msg]) if m.role == "reasoning"] + assert len(reasoning_msgs) == 2 + assert reasoning_msgs[0].id != reasoning_msgs[1].id + + def test_two_reasoning_blocks_fold_onto_one_assistant(self): + """Two reasoning messages buffered before a single assistant both fold + onto it (exercises multi-block accumulation, not just one).""" + msgs = [ + AGUIReasoningMessage(id="rs_1", role="reasoning", content="first"), + AGUIReasoningMessage(id="rs_2", role="reasoning", content="second"), + AGUIAssistantMessage(id="a1", role="assistant", content="Hello"), + ] + result = agui_messages_to_langchain(msgs) + assert len(result) == 1 + reasoning_ids = [ + b["id"] for b in result[0].content + if isinstance(b, dict) and b.get("type") == "reasoning" + ] + assert reasoning_ids == ["rs_1", "rs_2"] + + def test_orphan_reasoning_without_following_assistant_is_dropped(self): + """Reasoning not immediately followed by an assistant has no message to + attach to; it is intentionally dropped rather than materialized as a + standalone message (which would loop under add_messages). This locks in + that deliberate behavior.""" + # Trailing reasoning (no following assistant). + trailing = agui_messages_to_langchain([ + AGUIUserMessage(id="u1", role="user", content="Hi"), + AGUIReasoningMessage(id="rs_x", role="reasoning", content="orphan"), + ]) + assert [type(m).__name__ for m in trailing] == ["HumanMessage"] + + # Reasoning followed by a non-assistant message. + followed_by_user = agui_messages_to_langchain([ + AGUIReasoningMessage(id="rs_y", role="reasoning", content="orphan"), + AGUIUserMessage(id="u1", role="user", content="Hi"), + ]) + assert [type(m).__name__ for m in followed_by_user] == ["HumanMessage"] diff --git a/integrations/langgraph/python/tests/test_multimodal.py b/integrations/langgraph/python/tests/test_multimodal.py index 704b106a6f..8b23cbddd2 100644 --- a/integrations/langgraph/python/tests/test_multimodal.py +++ b/integrations/langgraph/python/tests/test_multimodal.py @@ -162,6 +162,36 @@ def test_agui_image_data_source_to_langchain(self): "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA" ) + def test_agui_input_metadata_to_langchain(self): + """Test preserving AG-UI InputContent metadata in LangChain blocks.""" + content_list = [ + TextInputContent( + type="text", + text="Describe this image", + metadata={"source": "prompt"}, + ), + ImageInputContent( + type="image", + source=InputContentUrlSource( + type="url", + value="https://example.com/photo.jpg", + ), + metadata={"provider_hint": "vision"}, + ), + BinaryInputContent( + type="binary", + mime_type="image/png", + url="https://example.com/legacy.png", + metadata={"legacy": True}, + ), + ] + + lc_content = convert_agui_multimodal_to_langchain(content_list) + + self.assertEqual(lc_content[0]["metadata"], {"source": "prompt"}) + self.assertEqual(lc_content[1]["metadata"], {"provider_hint": "vision"}) + self.assertEqual(lc_content[2]["metadata"], {"legacy": True}) + # ── AudioInputContent ─────────────────────────────────────────────── def test_agui_audio_url_source_to_langchain(self): diff --git a/integrations/langgraph/python/tests/test_nested_tool_end_dedup.py b/integrations/langgraph/python/tests/test_nested_tool_end_dedup.py index 7f48902808..2e2c46d03a 100644 --- a/integrations/langgraph/python/tests/test_nested_tool_end_dedup.py +++ b/integrations/langgraph/python/tests/test_nested_tool_end_dedup.py @@ -67,6 +67,22 @@ def _ai_chunk(*, name="", args="", tool_call_id="tc1", chunk_id="ai-msg-1"): return chunk +def _text_chunk(content, *, chunk_id="ai-text-1"): + chunk = AIMessageChunk(content=content, id=chunk_id) + chunk.response_metadata = {} + chunk.tool_call_chunks = [] + return chunk + + +def _text_and_tool_start_chunk(content, *, name, tool_call_id, chunk_id="ai-text-1"): + chunk = AIMessageChunk(content=content, id=chunk_id) + chunk.response_metadata = {} + chunk.tool_call_chunks = [ + {"name": name, "args": "", "id": tool_call_id, "index": 0} + ] + return chunk + + def _event(event_type, *, node="model", data=None, name=None): return { "event": event_type, @@ -87,6 +103,22 @@ def _stream_start(name, tool_call_id, node="model"): ) +def _stream_text(content, *, chunk_id="ai-text-1", node="model"): + return _event( + "on_chat_model_stream", + node=node, + data={"chunk": _text_chunk(content, chunk_id=chunk_id)}, + ) + + +def _stream_text_and_start(content, name, tool_call_id, *, chunk_id="ai-text-1", node="model"): + return _event( + "on_chat_model_stream", + node=node, + data={"chunk": _text_and_tool_start_chunk(content, name=name, tool_call_id=tool_call_id, chunk_id=chunk_id)}, + ) + + def _stream_args(args_delta, tool_call_id, node="model"): return _event( "on_chat_model_stream", @@ -290,5 +322,78 @@ def test_parallel_unstreamed_tool_emits_start_args_end_at_on_tool_end(self): self.assertIn("from_on_tool_end", u_args[0]) +class TestTextToToolCallTransition(unittest.TestCase): + def test_tool_start_after_text_chunk_is_not_dropped(self): + tool_call_id = "tc-search" + + dispatched = asyncio.run( + _run_stream( + [ + _stream_text("I will check.", chunk_id="msg-text"), + _stream_start("search", tool_call_id), + _stream_args('{"q":"weather"}', tool_call_id), + _stream_end(), + ] + ) + ) + + event_types = [ev.type for ev in dispatched] + self.assertIn(EventType.TEXT_MESSAGE_START, event_types) + self.assertIn(EventType.TEXT_MESSAGE_CONTENT, event_types) + text_end_index = event_types.index(EventType.TEXT_MESSAGE_END) + tool_start_index = next( + index + for index, ev in enumerate(dispatched) + if ev.type == EventType.TOOL_CALL_START and ev.tool_call_id == tool_call_id + ) + + self.assertLess(text_end_index, tool_start_index) + text_content = [ + ev.delta + for ev in dispatched + if ev.type == EventType.TEXT_MESSAGE_CONTENT + ] + self.assertEqual(text_content, ["I will check."]) + starts, args_payloads, ends, _ = _filter_tool_events(dispatched, tool_call_id) + self.assertEqual(starts, 1) + self.assertEqual("".join(args_payloads), '{"q":"weather"}') + self.assertEqual(ends, 1) + + def test_tool_start_chunk_preserves_trailing_text(self): + tool_call_id = "tc-search" + + dispatched = asyncio.run( + _run_stream( + [ + _stream_text("I will", chunk_id="msg-text"), + _stream_text_and_start(" check.", "search", tool_call_id, chunk_id="msg-text"), + _stream_args('{"q":"weather"}', tool_call_id), + _stream_end(), + ] + ) + ) + + text_content = [ + ev.delta + for ev in dispatched + if ev.type == EventType.TEXT_MESSAGE_CONTENT + ] + self.assertEqual(text_content, ["I will", " check."]) + + event_types = [ev.type for ev in dispatched] + text_end_index = event_types.index(EventType.TEXT_MESSAGE_END) + tool_start_index = next( + index + for index, ev in enumerate(dispatched) + if ev.type == EventType.TOOL_CALL_START and ev.tool_call_id == tool_call_id + ) + self.assertLess(text_end_index, tool_start_index) + + starts, args_payloads, ends, _ = _filter_tool_events(dispatched, tool_call_id) + self.assertEqual(starts, 1) + self.assertEqual("".join(args_payloads), '{"q":"weather"}') + self.assertEqual(ends, 1) + + if __name__ == "__main__": unittest.main() diff --git a/integrations/langgraph/python/tests/test_on_tool_end_non_toolmessage.py b/integrations/langgraph/python/tests/test_on_tool_end_non_toolmessage.py new file mode 100644 index 0000000000..42bcc78f21 --- /dev/null +++ b/integrations/langgraph/python/tests/test_on_tool_end_non_toolmessage.py @@ -0,0 +1,136 @@ +"""Regression test for #1072. + +`_handle_single_event` crashed with +``AttributeError: 'list' object has no attribute 'tool_call_id'`` when a +LangGraph ``on_tool_end`` event delivered an ``output`` that was neither a +``Command`` nor a ``ToolMessage`` (e.g. a plain list). The non-Command branch +read ``tool_call_output.tool_call_id`` unconditionally. + +The fix guards the non-Command branch with an ``isinstance(..., ToolMessage)`` +check, logging and skipping anything else. This test drives an ``on_tool_end`` +event whose output is a list and asserts the stream completes without raising +and emits no TOOL_CALL_* events for it. +""" + +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from langchain_core.messages import ToolMessage + +from ag_ui_langgraph.agent import LangGraphAgent +from ag_ui.core import EventType, RunAgentInput + + +def _make_agent(): + from langgraph.graph.state import CompiledStateGraph + + graph = MagicMock(spec=CompiledStateGraph) + graph.config_specs = [] + graph.nodes = {} + initial_state = MagicMock() + initial_state.values = {"messages": [], "copilotkit": {}} + initial_state.tasks = [] + initial_state.next = [] + initial_state.metadata = {"writes": {}} + graph.aget_state = AsyncMock(return_value=initial_state) + return LangGraphAgent(name="test", graph=graph) + + +def _on_tool_end(output, *, tool_name="search", input_args=None): + return { + "event": "on_tool_end", + "run_id": "run1", + "metadata": {"langgraph_node": "tools"}, + "data": {"output": output, "input": input_args or {}}, + "name": tool_name, + "parent_ids": [], + "tags": [], + } + + +async def _run_stream(events): + agent = _make_agent() + dispatched = [] + original_dispatch = agent._dispatch_event + + def capturing_dispatch(ev): + result = original_dispatch(ev) + dispatched.append(ev) + return result + + agent._dispatch_event = capturing_dispatch + + async def fake_stream(): + for ev in events: + yield ev + + final_state = MagicMock() + final_state.values = {"messages": [], "copilotkit": {}} + final_state.tasks = [] + final_state.next = [] + final_state.metadata = {"writes": {}} + + mock_prepared = { + "state": {"messages": [], "copilotkit": {}}, + "stream": fake_stream(), + "config": {"configurable": {"thread_id": "t1"}}, + } + + def fake_snapshot(state): + if isinstance(state, dict): + return state + return getattr(state, "values", {}) or {} + + with patch.object(agent, "prepare_stream", AsyncMock(return_value=mock_prepared)), patch.object( + agent.graph, "aget_state", AsyncMock(return_value=final_state) + ), patch.object(agent, "get_state_snapshot", side_effect=fake_snapshot): + input_data = RunAgentInput( + thread_id="t1", + run_id="run1", + messages=[], + state={}, + tools=[], + context=[], + forwarded_props={}, + ) + async for _ in agent._handle_stream_events(input_data): + pass + + return dispatched + + +class TestOnToolEndNonToolMessage(unittest.TestCase): + def test_list_output_does_not_crash_and_emits_no_tool_events(self): + # The reported crash: output is a list, not a ToolMessage/Command. + list_output = [ToolMessage(content="ok", tool_call_id="tc1", name="search")] + dispatched = asyncio.run(_run_stream([_on_tool_end(list_output)])) + + tool_events = [ + ev + for ev in dispatched + if ev.type + in ( + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.TOOL_CALL_RESULT, + ) + ] + self.assertEqual( + tool_events, [], "non-ToolMessage OnToolEnd output must be skipped, not dispatched" + ) + + def test_toolmessage_output_still_emits_tool_events(self): + # Guard must not regress the normal path. + msg = ToolMessage(content="ok", tool_call_id="tc1", name="search") + dispatched = asyncio.run(_run_stream([_on_tool_end(msg)])) + + starts = [ev for ev in dispatched if ev.type == EventType.TOOL_CALL_START] + results = [ev for ev in dispatched if ev.type == EventType.TOOL_CALL_RESULT] + self.assertEqual(len(starts), 1) + self.assertEqual(len(results), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/langgraph/python/tests/test_oss28_sse_drop_recovery.py b/integrations/langgraph/python/tests/test_oss28_sse_drop_recovery.py new file mode 100644 index 0000000000..7ba08554de --- /dev/null +++ b/integrations/langgraph/python/tests/test_oss28_sse_drop_recovery.py @@ -0,0 +1,202 @@ +"""Repro for OSS-28 / GitHub #1278. + +Bug: "Conversation permanently broken when SSE stream drops before +MESSAGES_SNAPSHOT is emitted -- every subsequent turn raises ValueError." + +Scenario from the issue: + 1. A turn completes server-side; the checkpoint now holds N messages. + 2. The SSE stream drops before MESSAGES_SNAPSHOT is delivered, so the + client never learns the real (checkpoint) message IDs. + 3. On the next turn the client sends its known messages plus a NEW user + message carrying a freshly generated UUID that was never persisted. + 4. ``len(checkpoint) > len(incoming)`` -> the old code routed into the + regenerate path, which called ``get_checkpoint_before_message(fresh_uuid)``, + walked all history, found nothing, and raised + ``ValueError: Message ID not found in history`` -> 500 -> the client + still never gets a MESSAGES_SNAPSHOT -> every later turn crashes the + same way -> the thread is permanently broken. + +These tests pin the post-fix behavior: + + * ``test_sse_drop_does_not_enter_regenerate_or_raise`` -- the recovery: the + fresh-UUID count mismatch must NOT enter the regenerate path and must + fall through to a normal continuation stream (no ValueError). This is the + fix introduced by the regenerate guard ``last_user_id in checkpoint_ids``. + + * ``test_underlying_landmine_still_raises_for_unknown_id`` -- documents that + the crash *site* still exists: calling regenerate with an id absent from + history still raises. The guard is load-bearing precisely because it stops + the SSE-drop case from ever reaching here. + + * ``test_genuine_edit_still_regenerates`` -- guard rails: a real edit (last + user id IS in the checkpoint) must still take the regenerate path, so the + fix did not disable legitimate regeneration. +""" + +import unittest +from unittest.mock import AsyncMock, MagicMock + +from langchain_core.messages import AIMessage, HumanMessage + +from ag_ui.core import UserMessage + +from tests._helpers import make_agent + + +def _make_state(messages): + state = MagicMock() + state.values = {"messages": messages} + state.tasks = [] + state.next = [] + state.metadata = {"writes": {}} + return state + + +def _make_input(messages, thread_id="t1", forwarded_props=None): + inp = MagicMock() + inp.thread_id = thread_id + inp.messages = messages + inp.state = {} + inp.tools = [] + inp.context = [] + inp.run_id = "run-1" + inp.forwarded_props = forwarded_props or {} + return inp + + +async def _empty_stream(): + if False: + yield None + + +async def _async_iter(items): + for item in items: + yield item + + +class TestOSS28SSEDropRecovery(unittest.IsolatedAsyncioTestCase): + async def test_sse_drop_does_not_enter_regenerate_or_raise(self): + """The core OSS-28 repro: after an SSE drop the client resends with a + fresh UUID the server never persisted. The checkpoint legitimately has + more messages than the client sent, but this must be treated as a + continuation -- NOT a regeneration -- and must not raise.""" + agent = make_agent() + agent.active_run = {"id": "run-1", "mode": "start"} + + # Server finished the previous turn: checkpoint has Human + AI. + checkpoint_messages = [ + HumanMessage(id="h1", content="first question"), + AIMessage(id="ai1", content="first answer"), + ] + state = _make_state(checkpoint_messages) + + # Client never received MESSAGES_SNAPSHOT, so on the next turn it only + # sends the brand-new user message with a freshly generated UUID that + # is NOT in the checkpoint. len(checkpoint)=2 > len(incoming)=1. + frontend_messages = [ + UserMessage(id="fresh-uuid-never-persisted", role="user", content="second question"), + ] + inp = _make_input(frontend_messages, forwarded_props={}) + + # Spy: regenerate must NOT be taken. If it raises we also catch the bug. + agent.prepare_regenerate_stream = AsyncMock( + side_effect=AssertionError("SSE-drop recovery must not enter regenerate") + ) + agent.graph.astream_events.return_value = _empty_stream() + config = {"configurable": {"thread_id": "t1"}} + + result = await agent.prepare_stream(inp, state, config) + + agent.prepare_regenerate_stream.assert_not_awaited() + self.assertIsNotNone(result.get("stream")) + # The new turn must actually reach the stream, not be silently dropped: + # the merged state carries the fresh-UUID message. + streamed_ids = { + getattr(m, "id", None) for m in result["state"].get("messages", []) + } + self.assertIn("fresh-uuid-never-persisted", streamed_ids) + + async def test_count_mismatch_all_incoming_in_checkpoint_is_continuation(self): + """The motivating non-regeneration case: the client is behind (never + received ai1) and resends only [h1] while the checkpoint holds + [h1, ai1]. The count mismatches (2 > 1), but every incoming id is + already in the checkpoint, so is_continuation short-circuits before the + last-user-id check. A regression flipping issubset or dropping the + truthiness precondition would wrongly regenerate here.""" + agent = make_agent() + agent.active_run = {"id": "run-1", "mode": "start"} + + checkpoint_messages = [ + HumanMessage(id="h1", content="first question"), + AIMessage(id="ai1", content="first answer"), + ] + state = _make_state(checkpoint_messages) + + frontend_messages = [ + UserMessage(id="h1", role="user", content="first question"), + ] + inp = _make_input(frontend_messages, forwarded_props={}) + + agent.prepare_regenerate_stream = AsyncMock( + side_effect=AssertionError("a continuation must not enter regenerate") + ) + agent.graph.astream_events.return_value = _empty_stream() + config = {"configurable": {"thread_id": "t1"}} + + result = await agent.prepare_stream(inp, state, config) + + agent.prepare_regenerate_stream.assert_not_awaited() + self.assertIsNotNone(result.get("stream")) + + async def test_underlying_landmine_still_raises_for_unknown_id(self): + """The crash site is unchanged: regenerating against an id absent from + history still raises 'not found in history'. This is why the guard in + prepare_stream (which the test above exercises) is load-bearing.""" + agent = make_agent() + + snapshot = MagicMock() + snapshot.values = {"messages": [HumanMessage(id="h1", content="real")]} + agent.graph.aget_state_history = lambda cfg: _async_iter([snapshot]) + + with self.assertRaisesRegex(ValueError, "not found in history"): + await agent.get_checkpoint_before_message( + "fresh-uuid-never-persisted", "t1" + ) + + async def test_genuine_edit_still_regenerates(self): + """Guard rail: a true edit/regenerate (last user id IS in the + checkpoint) must still take the regenerate path. The OSS-28 fix must + not disable legitimate regeneration.""" + agent = make_agent() + agent.active_run = {"id": "run-1", "mode": "start"} + + checkpoint_messages = [ + HumanMessage(id="h1", content="original"), + AIMessage(id="ai1", content="answer"), + HumanMessage(id="h2", content="regenerate from here"), + AIMessage(id="ai2", content="second answer"), + ] + state = _make_state(checkpoint_messages) + + # Client edits an earlier turn: an incoming id (h-edited) is NOT in the + # checkpoint (so this is not a plain continuation), while the LAST user + # id (h2) IS in the checkpoint -- the genuine regenerate signal. + frontend_messages = [ + UserMessage(id="h1", role="user", content="original"), + UserMessage(id="h-edited", role="user", content="edited earlier turn"), + UserMessage(id="h2", role="user", content="regenerate from here"), + ] + inp = _make_input(frontend_messages, forwarded_props={}) + + prepared = {"stream": "regen", "state": {}, "config": {}} + agent.prepare_regenerate_stream = AsyncMock(return_value=prepared) + config = {"configurable": {"thread_id": "t1"}} + + result = await agent.prepare_stream(inp, state, config) + + agent.prepare_regenerate_stream.assert_awaited_once() + self.assertIs(result, prepared) + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/langgraph/python/tests/test_predict_state_e2e.py b/integrations/langgraph/python/tests/test_predict_state_e2e.py index c12c2600ff..2c942d762e 100644 --- a/integrations/langgraph/python/tests/test_predict_state_e2e.py +++ b/integrations/langgraph/python/tests/test_predict_state_e2e.py @@ -408,5 +408,109 @@ async def test_predict_state_custom_event_not_emitted_for_untracked_tool(self): self.assertEqual(len(predict_state_events), 0) +class TestToolCallResultMessageId(unittest.IsolatedAsyncioTestCase): + """message_id on TOOL_CALL_RESULT must use ToolMessage.id (or tool_call_id + as fallback) so the streamed event matches the MESSAGES_SNAPSHOT id-based merge.""" + + async def test_direct_tool_end_uses_tool_call_id_when_id_absent(self): + """Non-Command OnToolEnd with ToolMessage.id=None falls back to tool_call_id.""" + events = [ + _event("on_chain_start", node="model"), + _tool_end_event("my_tool", tool_call_id="tc_abc"), + _chain_end_event("tools", output={"messages": []}), + ] + dispatched = await _run_stream(events) + results = [ + ev for ev in dispatched + if getattr(ev, "type", None) == EventType.TOOL_CALL_RESULT + ] + self.assertEqual(len(results), 1) + self.assertEqual(results[0].message_id, "tc_abc") + self.assertEqual(results[0].tool_call_id, "tc_abc") + + async def test_direct_tool_end_uses_tool_message_id_when_present(self): + """Non-Command OnToolEnd with ToolMessage.id set uses that id.""" + from langchain_core.messages import ToolMessage + ev = _event( + "on_tool_end", + node="tools", + data={ + "output": ToolMessage( + content="Done.", + tool_call_id="tc_abc", + name="my_tool", + id="msg_explicit_id", + ), + "input": {}, + }, + ) + events = [ + _event("on_chain_start", node="model"), + ev, + _chain_end_event("tools", output={"messages": []}), + ] + dispatched = await _run_stream(events) + results = [ + ev for ev in dispatched + if getattr(ev, "type", None) == EventType.TOOL_CALL_RESULT + ] + self.assertEqual(len(results), 1) + self.assertEqual(results[0].message_id, "msg_explicit_id") + self.assertEqual(results[0].tool_call_id, "tc_abc") + + async def test_command_tool_end_uses_tool_call_id_when_id_absent(self): + """Command-style OnToolEnd with ToolMessage.id=None falls back to tool_call_id.""" + events = [ + _event("on_chain_start", node="model"), + _command_tool_end_event("my_tool", tool_call_id="tc_xyz"), + _chain_end_event("tools", output={"messages": []}), + ] + dispatched = await _run_stream(events) + results = [ + ev for ev in dispatched + if getattr(ev, "type", None) == EventType.TOOL_CALL_RESULT + ] + self.assertEqual(len(results), 1) + self.assertEqual(results[0].message_id, "tc_xyz") + self.assertEqual(results[0].tool_call_id, "tc_xyz") + + async def test_command_tool_end_uses_tool_message_id_when_present(self): + """Command-style OnToolEnd with ToolMessage.id set uses that id.""" + from langchain_core.messages import ToolMessage + from langgraph.types import Command + ev = _event( + "on_tool_end", + node="tools", + data={ + "output": Command( + update={ + "messages": [ + ToolMessage( + content="Done.", + tool_call_id="tc_xyz", + name="my_tool", + id="msg_cmd_id", + ) + ], + }, + ), + "input": {}, + }, + ) + events = [ + _event("on_chain_start", node="model"), + ev, + _chain_end_event("tools", output={"messages": []}), + ] + dispatched = await _run_stream(events) + results = [ + ev for ev in dispatched + if getattr(ev, "type", None) == EventType.TOOL_CALL_RESULT + ] + self.assertEqual(len(results), 1) + self.assertEqual(results[0].message_id, "msg_cmd_id") + self.assertEqual(results[0].tool_call_id, "tc_xyz") + + if __name__ == "__main__": unittest.main() diff --git a/integrations/langgraph/python/tests/test_prepare_regenerate_stream_config.py b/integrations/langgraph/python/tests/test_prepare_regenerate_stream_config.py new file mode 100644 index 0000000000..194ede32c7 --- /dev/null +++ b/integrations/langgraph/python/tests/test_prepare_regenerate_stream_config.py @@ -0,0 +1,140 @@ +"""Tests for prepare_regenerate_stream runtime-config preservation — fixes #1749. + +The bug: ``prepare_regenerate_stream`` passes ``config=fork`` (the return +value of ``graph.aupdate_state``) straight into ``get_stream_kwargs`` and +on to ``astream_events``. The ``fork`` value only contains checkpoint +keys (``thread_id``, ``checkpoint_id``, ``checkpoint_ns``); runtime +settings from the caller's config -- notably ``recursion_limit`` and +``callbacks`` -- are silently discarded, and LangGraph stamps the +default ``recursion_limit=25``. + +The fix merges the caller's config underneath the fork via +``merge_configs`` so checkpoint keys still win but runtime settings +survive the round trip. +""" + +import unittest +from unittest.mock import AsyncMock, MagicMock + +from langchain_core.messages import HumanMessage + +from tests._helpers import make_agent + + +def _make_input(thread_id="t1", forwarded_props=None): + inp = MagicMock() + inp.thread_id = thread_id + inp.tools = [] + inp.forwarded_props = forwarded_props or {} + return inp + + +def _fork_only_config(): + """Mirror what ``graph.aupdate_state`` actually returns: a config + with only checkpoint-level ``configurable`` keys, no runtime keys.""" + return { + "configurable": { + "thread_id": "t1", + "checkpoint_id": "cp-after-fork", + "checkpoint_ns": "", + } + } + + +def _checkpoint_snapshot(): + snapshot = MagicMock() + snapshot.config = {"configurable": {"thread_id": "t1", "checkpoint_id": "cp-before"}} + snapshot.values = {"messages": [HumanMessage(id="h1", content="hi")]} + snapshot.next = ("agent",) + return snapshot + + +class TestPrepareRegenerateStreamPreservesRuntimeConfig(unittest.IsolatedAsyncioTestCase): + """Regression tests: runtime config keys must survive regeneration.""" + + async def test_recursion_limit_survives(self): + """The caller sets ``recursion_limit=100``; after regeneration + the value handed to ``astream_events`` must still be 100, not + LangGraph's default of 25.""" + agent = make_agent() + agent.get_checkpoint_before_message = AsyncMock(return_value=_checkpoint_snapshot()) + agent.graph.aupdate_state = AsyncMock(return_value=_fork_only_config()) + + captured = {} + + def _capture(**kwargs): + captured.update(kwargs) + return MagicMock() + + agent.graph.astream_events = _capture + agent.langgraph_default_merge_state = MagicMock(return_value={"messages": []}) + + caller_config = { + "recursion_limit": 100, + "configurable": {"thread_id": "t1"}, + } + message = HumanMessage(id="h1", content="hi") + + await agent.prepare_regenerate_stream(_make_input(), message, caller_config) + + self.assertIn("config", captured) + self.assertEqual(captured["config"].get("recursion_limit"), 100) + + async def test_callbacks_survive(self): + agent = make_agent() + agent.get_checkpoint_before_message = AsyncMock(return_value=_checkpoint_snapshot()) + agent.graph.aupdate_state = AsyncMock(return_value=_fork_only_config()) + + captured = {} + + def _capture(**kwargs): + captured.update(kwargs) + return MagicMock() + + agent.graph.astream_events = _capture + agent.langgraph_default_merge_state = MagicMock(return_value={"messages": []}) + + sentinel_callback = MagicMock(name="tracing-handler") + caller_config = { + "callbacks": [sentinel_callback], + "configurable": {"thread_id": "t1"}, + } + message = HumanMessage(id="h1", content="hi") + + await agent.prepare_regenerate_stream(_make_input(), message, caller_config) + + callbacks = captured["config"].get("callbacks") or [] + self.assertIn(sentinel_callback, callbacks) + + async def test_checkpoint_keys_still_win_for_thread_id(self): + """The fork's checkpoint id must override anything the caller + config carried under ``configurable``; otherwise the time-travel + replay would target the wrong checkpoint.""" + agent = make_agent() + agent.get_checkpoint_before_message = AsyncMock(return_value=_checkpoint_snapshot()) + fork = _fork_only_config() + agent.graph.aupdate_state = AsyncMock(return_value=fork) + + captured = {} + + def _capture(**kwargs): + captured.update(kwargs) + return MagicMock() + + agent.graph.astream_events = _capture + agent.langgraph_default_merge_state = MagicMock(return_value={"messages": []}) + + caller_config = { + "recursion_limit": 50, + "configurable": { + "thread_id": "t1", + "checkpoint_id": "OLD-DO-NOT-USE", + }, + } + message = HumanMessage(id="h1", content="hi") + + await agent.prepare_regenerate_stream(_make_input(), message, caller_config) + + configurable = captured["config"]["configurable"] + self.assertEqual(configurable["checkpoint_id"], "cp-after-fork") + self.assertEqual(captured["config"]["recursion_limit"], 50) diff --git a/integrations/langgraph/python/tests/test_reasoning_canonical_id.py b/integrations/langgraph/python/tests/test_reasoning_canonical_id.py new file mode 100644 index 0000000000..d223fbdb1c --- /dev/null +++ b/integrations/langgraph/python/tests/test_reasoning_canonical_id.py @@ -0,0 +1,184 @@ +"""The streamed reasoning message must adopt the provider's canonical +reasoning id when the stream carries one. + +Since 2111267 the snapshot converter (``_reasoning_block_to_agui_message``) +emits checkpointed reasoning under the provider's canonical block id (OpenAI +``rs_…``). If the streaming path mints a fresh ``uuid4`` instead, the client +can never reconcile the streamed copy with the snapshot copy and renders the +same reasoning twice (the langgraph-python dojo e2e strict-mode failure). + +With ``use_responses_api=True``, the canonical id only travels on text-less +chunks — the ``response.output_item.added`` chunk (``{id, summary: []}``, +observed on the LangGraph Platform wire) and, depending on the +langchain-openai version, the ``…summary_part.added`` chunk (``{id, summary: +[{text: ""}]}``). The ``…summary_text.delta`` chunks carry text but no id. +These tests pin that: + + * ``resolve_reasoning_content`` surfaces the id-carrier chunks (instead of + dropping them for having no text) and extracts the block id, + * ``handle_reasoning_event`` stashes the id from a text-less chunk WITHOUT + emitting anything (summary-less store=true items must keep rendering + nothing) and opens the reasoning message under the stashed id when the + first text delta arrives, + * id-less providers keep the uuid fallback, and non-first summary parts + never reuse the item id. +""" + +import unittest + +from ag_ui.core import EventType + +from ag_ui_langgraph.utils import resolve_reasoning_content +from tests._helpers import make_agent, _record_dispatch + + +class FakeChunk: + def __init__(self, content=None, additional_kwargs=None): + self.content = content or [] + self.additional_kwargs = additional_kwargs or {} + + +class TestResolveReasoningContentCanonicalId(unittest.TestCase): + def test_summary_part_added_chunk_carries_id(self): + """`response.reasoning_summary_part.added` shape: empty text, id set. + + Must be surfaced (not dropped) so the id can seed REASONING_START. + """ + chunk = FakeChunk(content=[{ + "type": "reasoning", + "id": "rs-canonical", + "summary": [{"index": 0, "type": "summary_text", "text": ""}], + "index": 0, + }]) + result = resolve_reasoning_content(chunk) + self.assertIsNotNone(result) + self.assertEqual(result["text"], "") + self.assertEqual(result["id"], "rs-canonical") + self.assertEqual(result["index"], 0) + + def test_summary_text_delta_chunk_has_no_id(self): + """`response.reasoning_summary_text.delta` shape: text, no id — + unchanged behavior, and no id key invented.""" + chunk = FakeChunk(content=[{ + "type": "reasoning", + "summary": [{"index": 0, "type": "summary_text", "text": "Because X"}], + "index": 0, + }]) + result = resolve_reasoning_content(chunk) + self.assertIsNotNone(result) + self.assertEqual(result["text"], "Because X") + self.assertIsNone(result.get("id")) + + def test_id_attached_when_text_and_id_both_present(self): + chunk = FakeChunk(content=[{ + "type": "reasoning", + "id": "rs-canonical", + "summary": [{"index": 0, "type": "summary_text", "text": "Hi"}], + "index": 0, + }]) + result = resolve_reasoning_content(chunk) + self.assertEqual(result["text"], "Hi") + self.assertEqual(result["id"], "rs-canonical") + + def test_item_added_empty_summary_carries_id(self): + """`response.output_item.added` shape ({id, summary: []}) — the only + id carrier on the LangGraph Platform wire. Surfaced as a text-less + carrier; handle_reasoning_event stashes it without emitting.""" + chunk = FakeChunk(content=[{ + "type": "reasoning", + "id": "rs-canonical", + "summary": [], + "index": 0, + }]) + result = resolve_reasoning_content(chunk) + self.assertIsNotNone(result) + self.assertEqual(result["text"], "") + self.assertEqual(result["id"], "rs-canonical") + + def test_empty_summary_without_id_still_dropped(self): + chunk = FakeChunk(content=[{"type": "reasoning", "summary": [], "index": 0}]) + self.assertIsNone(resolve_reasoning_content(chunk)) + + def test_part_added_with_null_id_dropped(self): + """Observed platform wire shape: part.added with `id: null` and empty + text — nothing to surface.""" + chunk = FakeChunk(content=[{ + "type": "reasoning", + "id": None, + "summary": [{"index": 0, "type": "summary_text", "text": ""}], + "index": 0, + }]) + self.assertIsNone(resolve_reasoning_content(chunk)) + + def test_non_first_summary_part_does_not_reuse_id(self): + """A second summary part (summary index 1) belongs to the same + reasoning item; reusing the canonical id there would mint two AG-UI + messages with the same id. It must fall back to the uuid path.""" + chunk = FakeChunk(content=[{ + "type": "reasoning", + "id": "rs-canonical", + "summary": [{"index": 1, "type": "summary_text", "text": ""}], + "index": 0, + }]) + result = resolve_reasoning_content(chunk) + self.assertIsNotNone(result) + self.assertEqual(result["index"], 1) + self.assertIsNone(result.get("id")) + + +class TestHandleReasoningEventCanonicalId(unittest.TestCase): + def setUp(self): + self.agent = _record_dispatch(make_agent()) + self.agent.active_run = {} + + def _events(self, reasoning_data): + return list(self.agent.handle_reasoning_event(reasoning_data)) + + def test_id_carrier_chunk_emits_nothing(self): + """The text-less id carrier must not open a message — a store=true + item (id only, no summary ever) must keep rendering nothing.""" + self._events({"type": "text", "text": "", "index": 0, "id": "rs-canonical"}) + self.assertEqual(self.agent.dispatched, []) + self.assertEqual( + self.agent.active_run.get("pending_reasoning_id"), "rs-canonical" + ) + + def test_first_delta_opens_under_stashed_canonical_id(self): + self._events({"type": "text", "text": "", "index": 0, "id": "rs-canonical"}) + self._events({"type": "text", "text": "Because X", "index": 0}) + start_events = [ + e for e in self.agent.dispatched if e.type == EventType.REASONING_START + ] + self.assertEqual(len(start_events), 1) + self.assertEqual(start_events[0].message_id, "rs-canonical") + # consumed: a later id-less reasoning item must not inherit it + self.assertIsNone(self.agent.active_run.get("pending_reasoning_id")) + + def test_subsequent_deltas_join_the_canonical_message(self): + self._events({"type": "text", "text": "", "index": 0, "id": "rs-canonical"}) + self._events({"type": "text", "text": "Because X", "index": 0}) + start_events = [ + e for e in self.agent.dispatched if e.type == EventType.REASONING_START + ] + content_events = [ + e + for e in self.agent.dispatched + if e.type == EventType.REASONING_MESSAGE_CONTENT + ] + self.assertEqual(len(start_events), 1) + self.assertEqual(len(content_events), 1) + self.assertEqual(content_events[0].message_id, "rs-canonical") + self.assertEqual(content_events[0].delta, "Because X") + + def test_uuid_fallback_when_stream_has_no_id(self): + self._events({"type": "text", "text": "thinking…", "index": 0}) + start_events = [ + e for e in self.agent.dispatched if e.type == EventType.REASONING_START + ] + self.assertEqual(len(start_events), 1) + self.assertTrue(start_events[0].message_id) + self.assertNotEqual(start_events[0].message_id, "rs-canonical") + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/langgraph/python/tests/test_run_id_preservation.py b/integrations/langgraph/python/tests/test_run_id_preservation.py new file mode 100644 index 0000000000..04ec877433 --- /dev/null +++ b/integrations/langgraph/python/tests/test_run_id_preservation.py @@ -0,0 +1,135 @@ +"""Regression test for issue #1582. + +The client supplies a ``run_id`` on ``RunAgentInput``. The protocol +RUN_STARTED and RUN_FINISHED events must both carry that exact client +run_id so the client can correlate the run it started with the run that +finished. + +Previously the streaming loop overwrote ``self.active_run["id"]`` with +LangGraph's internal chain ``run_id`` taken off each streamed event. As a +result RUN_STARTED (emitted before the loop) carried the client id while +RUN_FINISHED (emitted after the loop) carried LangGraph's chain UUID — the +two disagreed and the client id was lost. +""" + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from ag_ui.core import EventType, RunAgentInput +from ag_ui_langgraph.agent import LangGraphAgent + + +def _make_agent(): + from langgraph.graph.state import CompiledStateGraph + + graph = MagicMock(spec=CompiledStateGraph) + graph.config_specs = [] + graph.nodes = {} + initial_state = MagicMock() + initial_state.values = {"messages": [], "copilotkit": {}} + initial_state.tasks = [] + initial_state.next = [] + initial_state.metadata = {"writes": {}} + graph.aget_state = AsyncMock(return_value=initial_state) + return LangGraphAgent(name="test", graph=graph) + + +def _event(event_type, run_id, node="model", data=None): + return { + "event": event_type, + "run_id": run_id, + "metadata": {"langgraph_node": node}, + "data": data or {}, + "name": node, + "parent_ids": [], + "tags": [], + } + + +async def _run_stream(client_run_id, chain_run_id): + agent = _make_agent() + dispatched = [] + + original_dispatch = agent._dispatch_event + + def capturing_dispatch(ev): + result = original_dispatch(ev) + dispatched.append(ev) + return result + + agent._dispatch_event = capturing_dispatch + + events = [ + _event("on_chain_start", chain_run_id, node="model"), + _event( + "on_chain_end", + chain_run_id, + node="model", + data={"output": {"messages": []}, "input": {}}, + ), + ] + + async def fake_stream(): + for ev in events: + yield ev + + final_state = MagicMock() + final_state.values = {"messages": [], "copilotkit": {}} + final_state.tasks = [] + final_state.next = [] + final_state.metadata = {"writes": {}} + + mock_prepared = { + "state": {"messages": [], "copilotkit": {}}, + "stream": fake_stream(), + "config": {"configurable": {"thread_id": "t1"}}, + } + + def fake_get_state_snapshot(state): + if isinstance(state, dict): + return state + return getattr(state, "values", {}) or {} + + with patch.object(agent, "prepare_stream", AsyncMock(return_value=mock_prepared)), \ + patch.object(agent.graph, "aget_state", AsyncMock(return_value=final_state)), \ + patch.object(agent, "get_state_snapshot", side_effect=fake_get_state_snapshot): + + input_data = RunAgentInput( + thread_id="t1", + run_id=client_run_id, + messages=[], + state={}, + tools=[], + context=[], + forwarded_props={}, + ) + + async for _ in agent._handle_stream_events(input_data): + pass + + return dispatched + + +class TestRunIdPreservation(unittest.IsolatedAsyncioTestCase): + async def test_run_started_and_finished_carry_client_run_id(self): + client_run_id = "client-run-1582" + chain_run_id = "00000000-0000-4000-8000-000000000000" + + dispatched = await _run_stream(client_run_id, chain_run_id) + + started = [e for e in dispatched if getattr(e, "type", None) == EventType.RUN_STARTED] + finished = [e for e in dispatched if getattr(e, "type", None) == EventType.RUN_FINISHED] + + self.assertEqual(len(started), 1, "expected exactly one RUN_STARTED") + self.assertEqual(len(finished), 1, "expected exactly one RUN_FINISHED") + + self.assertEqual(started[0].run_id, client_run_id) + self.assertEqual( + finished[0].run_id, + client_run_id, + "RUN_FINISHED must carry the client run_id, not LangGraph's chain run_id", + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/integrations/langgraph/python/tests/test_state_merging.py b/integrations/langgraph/python/tests/test_state_merging.py index 7ec1317550..ce1ea85957 100644 --- a/integrations/langgraph/python/tests/test_state_merging.py +++ b/integrations/langgraph/python/tests/test_state_merging.py @@ -178,3 +178,69 @@ def test_ag_ui_key_set(self): assert "ag-ui" in result assert result["ag-ui"]["tools"] == result["tools"] assert result["ag-ui"]["context"] == ctx + + # Forwarded props that must be surfaced into ag-ui state, keyed by the + # forwarded_props key (as it arrives after run()'s camel->snake conversion) + # mapped to (the ag-ui state key it lands under, a sample value). + # To wire a new forwarded prop into ag-ui state, add it here AND in + # langgraph_default_merge_state — both the test and the absence check below + # then cover it automatically. + FORWARDED_PROPS_TO_AGUI = { + # injectA2UITool -> camel_to_snake -> inject_a2_u_i_tool (A2UI middleware) + "inject_a2_u_i_tool": ("inject_a2ui_tool", "render_a2ui"), + } + + def test_camel_to_snake_key_contract(self): + """Pin the load-bearing wire-key conversion. run() snake-cases forwarded_props + keys, so the merge step keys off the CONVERTED name. The tests below feed the + converted key directly; this test guarantees the conversion actually produces + that key from the real camelCase wire name. If camel_to_snake ever changed + (e.g. collapsing the capital run to "inject_a2ui_tool"), the feature would break + silently while the table-driven tests still passed — this assertion catches it.""" + from ag_ui_langgraph.utils import camel_to_snake + assert camel_to_snake("injectA2UITool") == "inject_a2_u_i_tool" + + def test_forwarded_props_surface_into_ag_ui_state(self): + """Each configured forwarded prop lands under its ag-ui state key.""" + agent = make_agent() + forwarded = {fp: sample for fp, (_, sample) in self.FORWARDED_PROPS_TO_AGUI.items()} + result = agent.langgraph_default_merge_state( + {"messages": []}, [], make_input(forwarded_props=forwarded) + ) + for _, (agui_key, sample) in self.FORWARDED_PROPS_TO_AGUI.items(): + assert result["ag-ui"][agui_key] == sample + + def test_forwarded_props_absent_by_default(self): + """With no forwarded props, none of the ag-ui state keys are present.""" + agent = make_agent() + result = agent.langgraph_default_merge_state({"messages": []}, [], make_input()) + for _, (agui_key, _sample) in self.FORWARDED_PROPS_TO_AGUI.items(): + assert agui_key not in result["ag-ui"] + + # Must stay byte-identical to the A2UI middleware's exported + # A2UI_SCHEMA_CONTEXT_DESCRIPTION (middlewares/a2ui-middleware/src/index.ts). + # The connector matches the schema context entry by exact string equality, so + # any drift silently routes the schema into the system prompt instead of state. + A2UI_SCHEMA_CONTEXT_DESCRIPTION = ( + "A2UI Component Schema — available components for generating UI surfaces. " + "Use these component names and properties when creating A2UI operations." + ) + + def test_a2ui_schema_context_routed_into_ag_ui_state(self): + """A context entry carrying the middleware's schema description is lifted into + ag-ui.a2ui_schema and removed from the regular context list.""" + agent = make_agent() + schema_value = '{"components": ["Card", "Button"]}' + ctx = [ + Context(description="unrelated", value="keep me"), + Context(description=self.A2UI_SCHEMA_CONTEXT_DESCRIPTION, value=schema_value), + ] + result = agent.langgraph_default_merge_state({"messages": []}, [], make_input(context=ctx)) + assert result["ag-ui"]["a2ui_schema"] == schema_value + # The schema entry must NOT remain in regular context. + descriptions = [ + c.description if hasattr(c, "description") else c.get("description") + for c in result["ag-ui"]["context"] + ] + assert self.A2UI_SCHEMA_CONTEXT_DESCRIPTION not in descriptions + assert "unrelated" in descriptions diff --git a/integrations/langgraph/python/uv.lock b/integrations/langgraph/python/uv.lock index d7bf4f550d..0e42dce2dd 100644 --- a/integrations/langgraph/python/uv.lock +++ b/integrations/langgraph/python/uv.lock @@ -2,11 +2,21 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.15" +[[package]] +name = "ag-ui-a2ui-toolkit" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/ce/85f3960a83d962e5690bc0f27a3baf3bf1602edc2b0603085928c964ea14/ag_ui_a2ui_toolkit-0.0.4.tar.gz", hash = "sha256:172e2724e53df8173685a3fb896a6e5175eea06e1dc166c715db110ba4beba76", size = 18960, upload-time = "2026-06-17T13:34:28.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/7a/acf85b01cd996bd011b71e181fd9f3daff5396fc3b7d78ba9445bfc08ecf/ag_ui_a2ui_toolkit-0.0.4-py3-none-any.whl", hash = "sha256:236fc511e1ec2399bcda0c14a109b3fb0a0c3e3988c18ef1918745b1c1535e30", size = 21315, upload-time = "2026-06-17T13:34:29.505Z" }, +] + [[package]] name = "ag-ui-langgraph" -version = "0.0.35" +version = "0.0.41" source = { editable = "." } dependencies = [ + { name = "ag-ui-a2ui-toolkit" }, { name = "ag-ui-protocol" }, { name = "langchain" }, { name = "langchain-core" }, @@ -29,6 +39,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "ag-ui-a2ui-toolkit", specifier = ">=0.0.4" }, { name = "ag-ui-protocol", specifier = ">=0.1.15" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.115.12" }, { name = "langchain", specifier = ">=1.2.0" }, diff --git a/integrations/langgraph/typescript/LICENSE b/integrations/langgraph/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/langgraph/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/langgraph/typescript/examples/langgraph.json b/integrations/langgraph/typescript/examples/langgraph.json index c4b4024ac2..d9d71f8173 100644 --- a/integrations/langgraph/typescript/examples/langgraph.json +++ b/integrations/langgraph/typescript/examples/langgraph.json @@ -9,7 +9,10 @@ "tool_based_generative_ui": "./src/agents/tool_based_generative_ui/agent.ts:toolBasedGenerativeUiGraph", "subgraphs": "./src/agents/subgraphs/agent.ts:subGraphsAgentGraph", "agentic_chat_multimodal": "./src/agents/agentic_chat_multimodal/agent.ts:agenticChatMultimodalGraph", - "agentic_chat_reasoning": "./src/agents/agentic_chat_reasoning/agent.ts:agenticChatReasoningGraph" + "agentic_chat_reasoning": "./src/agents/agentic_chat_reasoning/agent.ts:agenticChatReasoningGraph", + "a2ui_dynamic_schema": "./src/agents/a2ui_dynamic_schema/agent.ts:a2uiDynamicSchemaGraph", + "a2ui_fixed_schema": "./src/agents/a2ui_fixed_schema/agent.ts:a2uiFixedSchemaGraph", + "a2ui_recovery": "./src/agents/a2ui_recovery/agent.ts:a2uiRecoveryGraph" }, "env": ".env" } diff --git a/integrations/langgraph/typescript/examples/package.json b/integrations/langgraph/typescript/examples/package.json index 58f7c97a83..10f9da9a26 100644 --- a/integrations/langgraph/typescript/examples/package.json +++ b/integrations/langgraph/typescript/examples/package.json @@ -3,13 +3,15 @@ "version": "0.1.0", "description": "TypeScript examples for LangGraph agents with CopilotKit integration", "type": "module", + "packageManager": "pnpm@10.33.4", "scripts": { "build": "tsc", - "dev": "pnpx @langchain/langgraph-cli@1.1.13 dev", + "dev": "pnpx @langchain/langgraph-cli@1.2.3 dev", "start": "node dist/index.js" }, "dependencies": { - "@copilotkit/sdk-js": "0.0.0-mme-ag-ui-0-0-46-20260227141603", + "@ag-ui/langgraph": "0.0.41", + "@copilotkit/sdk-js": "1.57.1", "@langchain/core": "^1.1.44", "@langchain/anthropic": "^0.3.0", "@langchain/google-genai": "^0.2.0", diff --git a/integrations/langgraph/typescript/examples/pnpm-lock.yaml b/integrations/langgraph/typescript/examples/pnpm-lock.yaml index 64b7a103aa..6473acf3d5 100644 --- a/integrations/langgraph/typescript/examples/pnpm-lock.yaml +++ b/integrations/langgraph/typescript/examples/pnpm-lock.yaml @@ -8,30 +8,33 @@ importers: .: dependencies: + '@ag-ui/langgraph': + specifier: 0.0.41 + version: 0.0.41(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.53)(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)) '@copilotkit/sdk-js': - specifier: 0.0.0-mme-ag-ui-0-0-46-20260227141603 - version: 0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/core@0.0.42)(@langchain/community@0.0.53(openai@6.15.0(zod@3.25.76)))(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(@langchain/langgraph@1.3.2(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(langchain@1.2.8(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)))(typescript@5.8.3)(zod@3.25.76) + specifier: 1.57.1 + version: 1.57.1(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.53)(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(@langchain/langgraph@1.3.0(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(langchain@1.2.8(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(typescript@5.8.3)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) '@langchain/anthropic': specifier: ^0.3.0 - version: 0.3.34(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(zod@3.25.76) + version: 0.3.34(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(zod@3.25.76) '@langchain/core': specifier: ^1.1.44 - version: 1.1.47(openai@6.15.0(zod@3.25.76)) + version: 1.1.46(openai@6.15.0(zod@3.25.76)) '@langchain/google-genai': specifier: ^0.2.0 - version: 0.2.18(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76))) + version: 0.2.18(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76))) '@langchain/langgraph': specifier: ^1.1.0 - version: 1.3.2(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) + version: 1.3.0(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) '@langchain/openai': specifier: ^1.2.0 - version: 1.2.0(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76))) + version: 1.2.0(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76))) dotenv: specifier: ^16.4.5 version: 16.6.1 langchain: specifier: ^1.2.3 - version: 1.2.8(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)) + version: 1.2.8(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)) uuid: specifier: ^10.0.0 version: 10.0.0 @@ -48,8 +51,32 @@ importers: packages: - '@ag-ui/core@0.0.42': - resolution: {integrity: sha512-C2hMg4Gs5oiUDgK9cA2RsTwSSmFZdIsqPklDrFw/Ue+quH6EU3vKp5YoOq7nuaQYO4pO8Em+Z+l5/M5PpcvP1g==} + '@ag-ui/a2ui-toolkit@0.0.3': + resolution: {integrity: sha512-bKjtuYQufGZ+vc2oTz1v5S6ab2gH/whQIIgbGfP+LMisdAkDV7bqeg4e+lZO3xNmdmkCa6nvkovtudMkqxmxEA==} + + '@ag-ui/client@0.0.53': + resolution: {integrity: sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==} + + '@ag-ui/core@0.0.53': + resolution: {integrity: sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==} + + '@ag-ui/encoder@0.0.53': + resolution: {integrity: sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==} + + '@ag-ui/langgraph@0.0.31': + resolution: {integrity: sha512-mK24pfQZiV5SlnDLhTka+873gw7QQOAWXqqDSnwkuyoQQQFX7KC8xZR+4Da2dWqyVhbhNPx+amE16X7twS1wcg==} + peerDependencies: + '@ag-ui/client': '>=0.0.42' + '@ag-ui/core': '>=0.0.42' + + '@ag-ui/langgraph@0.0.41': + resolution: {integrity: sha512-xo7ja/kuctmdPiH83QOUIpDs/AY3GzxW1fM37x9otK9fqwnKgi2JIcjfcdvAdGYdsCkXBn2WWQ2PVH+rdsLOzg==} + peerDependencies: + '@ag-ui/client': '>=0.0.42' + '@ag-ui/core': '>=0.0.42' + + '@ag-ui/proto@0.0.53': + resolution: {integrity: sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==} '@anthropic-ai/sdk@0.65.0': resolution: {integrity: sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw==} @@ -64,23 +91,28 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@bufbuild/protobuf@2.12.0': + resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==} + '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} - '@copilotkit/sdk-js@0.0.0-mme-ag-ui-0-0-46-20260227141603': - resolution: {integrity: sha512-qwPTcJiGixz5v3u1zWWp3onvvrS5LIjgKPf6XC58/+DJopPY4n5U0DRukVOUbIj+nPNrbfsitkB2IKFMUo3TyA==} + '@copilotkit/license-verifier@0.4.0': + resolution: {integrity: sha512-axD7B767YVHGfz/nekw3wUk9GFZGIcIq2X9AZfNA0qb6GnHcB3yJ636T21LHhon5sHerxe1oOOuNYmiAUMNonQ==} + + '@copilotkit/sdk-js@1.57.1': + resolution: {integrity: sha512-MpCdYoAKZ6yAZPHPNAT1JbfiGxSwRsxzRKSPuiTPBH1GdM1jpMso6WA7CNJSa7tuLLzRPZvBcnPlnU49vwK28w==} peerDependencies: - '@langchain/community': ^0.3.58 '@langchain/core': '>=0.4.0 <2.0.0' '@langchain/langgraph': '>=0.4.0 <2.0.0' langchain: '>=1.0.0' typescript: ^5.2.3 zod: ^3.23.3 || ^3.24.0 || ^3.25.0 - '@copilotkit/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603': - resolution: {integrity: sha512-b29dZR67mDq85v9h4ritwJ3dUVek8UpR4MZ0SHuFgZF7BYzMOGoGleh96H/8Mj1s6hTiQ781NVAPEJ6OiY4FDA==} + '@copilotkit/shared@1.57.1': + resolution: {integrity: sha512-RiACMH8TIHec3yJUEbh4sDnhb2JWKHPRcA07oX6lVnM3aO/pL/U9XsGYnZmw9VVWOjRvLl1pWFIZDOf+ybiXSg==} peerDependencies: - '@ag-ui/core': ^0.0.46 + '@ag-ui/core': '>=0.0.48' '@google/generative-ai@0.24.1': resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==} @@ -92,294 +124,8 @@ packages: peerDependencies: '@langchain/core': '>=0.3.58 <0.4.0' - '@langchain/community@0.0.53': - resolution: {integrity: sha512-iFqZPt4MRssGYsQoKSXWJQaYTZCC7WNuilp2JCCs3wKmJK3l6mR0eV+PDrnT+TaDHUVxt/b0rwgM0sOiy0j2jA==} - engines: {node: '>=18'} - peerDependencies: - '@aws-crypto/sha256-js': ^5.0.0 - '@aws-sdk/client-bedrock-agent-runtime': ^3.485.0 - '@aws-sdk/client-bedrock-runtime': ^3.422.0 - '@aws-sdk/client-dynamodb': ^3.310.0 - '@aws-sdk/client-kendra': ^3.352.0 - '@aws-sdk/client-lambda': ^3.310.0 - '@aws-sdk/client-sagemaker-runtime': ^3.310.0 - '@aws-sdk/client-sfn': ^3.310.0 - '@aws-sdk/credential-provider-node': ^3.388.0 - '@azure/search-documents': ^12.0.0 - '@clickhouse/client': ^0.2.5 - '@cloudflare/ai': '*' - '@datastax/astra-db-ts': ^1.0.0 - '@elastic/elasticsearch': ^8.4.0 - '@getmetal/metal-sdk': '*' - '@getzep/zep-js': ^0.9.0 - '@gomomento/sdk': ^1.51.1 - '@gomomento/sdk-core': ^1.51.1 - '@google-ai/generativelanguage': ^0.2.1 - '@gradientai/nodejs-sdk': ^1.2.0 - '@huggingface/inference': ^2.6.4 - '@mozilla/readability': '*' - '@neondatabase/serverless': '*' - '@opensearch-project/opensearch': '*' - '@pinecone-database/pinecone': '*' - '@planetscale/database': ^1.8.0 - '@premai/prem-sdk': ^0.3.25 - '@qdrant/js-client-rest': ^1.8.2 - '@raycast/api': ^1.55.2 - '@rockset/client': ^0.9.1 - '@smithy/eventstream-codec': ^2.0.5 - '@smithy/protocol-http': ^3.0.6 - '@smithy/signature-v4': ^2.0.10 - '@smithy/util-utf8': ^2.0.0 - '@supabase/postgrest-js': ^1.1.1 - '@supabase/supabase-js': ^2.10.0 - '@tensorflow-models/universal-sentence-encoder': '*' - '@tensorflow/tfjs-converter': '*' - '@tensorflow/tfjs-core': '*' - '@upstash/redis': ^1.20.6 - '@upstash/vector': ^1.0.7 - '@vercel/kv': ^0.2.3 - '@vercel/postgres': ^0.5.0 - '@writerai/writer-sdk': ^0.40.2 - '@xata.io/client': ^0.28.0 - '@xenova/transformers': ^2.5.4 - '@zilliz/milvus2-sdk-node': '>=2.2.7' - better-sqlite3: ^9.4.0 - cassandra-driver: ^4.7.2 - cborg: ^4.1.1 - chromadb: '*' - closevector-common: 0.1.3 - closevector-node: 0.1.6 - closevector-web: 0.1.6 - cohere-ai: '*' - convex: ^1.3.1 - couchbase: ^4.3.0 - discord.js: ^14.14.1 - dria: ^0.0.3 - duck-duck-scrape: ^2.2.5 - faiss-node: ^0.5.1 - firebase-admin: ^11.9.0 || ^12.0.0 - google-auth-library: ^8.9.0 - googleapis: ^126.0.1 - hnswlib-node: ^3.0.0 - html-to-text: ^9.0.5 - interface-datastore: ^8.2.11 - ioredis: ^5.3.2 - it-all: ^3.0.4 - jsdom: '*' - jsonwebtoken: ^9.0.2 - llmonitor: ^0.5.9 - lodash: ^4.17.21 - lunary: ^0.6.11 - mongodb: '>=5.2.0' - mysql2: ^3.3.3 - neo4j-driver: '*' - node-llama-cpp: '*' - pg: ^8.11.0 - pg-copy-streams: ^6.0.5 - pickleparser: ^0.2.1 - portkey-ai: ^0.1.11 - redis: '*' - replicate: ^0.18.0 - typeorm: ^0.3.12 - typesense: ^1.5.3 - usearch: ^1.1.1 - vectordb: ^0.1.4 - voy-search: 0.6.2 - weaviate-ts-client: '*' - web-auth-library: ^1.0.3 - ws: ^8.14.2 - peerDependenciesMeta: - '@aws-crypto/sha256-js': - optional: true - '@aws-sdk/client-bedrock-agent-runtime': - optional: true - '@aws-sdk/client-bedrock-runtime': - optional: true - '@aws-sdk/client-dynamodb': - optional: true - '@aws-sdk/client-kendra': - optional: true - '@aws-sdk/client-lambda': - optional: true - '@aws-sdk/client-sagemaker-runtime': - optional: true - '@aws-sdk/client-sfn': - optional: true - '@aws-sdk/credential-provider-node': - optional: true - '@azure/search-documents': - optional: true - '@clickhouse/client': - optional: true - '@cloudflare/ai': - optional: true - '@datastax/astra-db-ts': - optional: true - '@elastic/elasticsearch': - optional: true - '@getmetal/metal-sdk': - optional: true - '@getzep/zep-js': - optional: true - '@gomomento/sdk': - optional: true - '@gomomento/sdk-core': - optional: true - '@google-ai/generativelanguage': - optional: true - '@gradientai/nodejs-sdk': - optional: true - '@huggingface/inference': - optional: true - '@mozilla/readability': - optional: true - '@neondatabase/serverless': - optional: true - '@opensearch-project/opensearch': - optional: true - '@pinecone-database/pinecone': - optional: true - '@planetscale/database': - optional: true - '@premai/prem-sdk': - optional: true - '@qdrant/js-client-rest': - optional: true - '@raycast/api': - optional: true - '@rockset/client': - optional: true - '@smithy/eventstream-codec': - optional: true - '@smithy/protocol-http': - optional: true - '@smithy/signature-v4': - optional: true - '@smithy/util-utf8': - optional: true - '@supabase/postgrest-js': - optional: true - '@supabase/supabase-js': - optional: true - '@tensorflow-models/universal-sentence-encoder': - optional: true - '@tensorflow/tfjs-converter': - optional: true - '@tensorflow/tfjs-core': - optional: true - '@upstash/redis': - optional: true - '@upstash/vector': - optional: true - '@vercel/kv': - optional: true - '@vercel/postgres': - optional: true - '@writerai/writer-sdk': - optional: true - '@xata.io/client': - optional: true - '@xenova/transformers': - optional: true - '@zilliz/milvus2-sdk-node': - optional: true - better-sqlite3: - optional: true - cassandra-driver: - optional: true - cborg: - optional: true - chromadb: - optional: true - closevector-common: - optional: true - closevector-node: - optional: true - closevector-web: - optional: true - cohere-ai: - optional: true - convex: - optional: true - couchbase: - optional: true - discord.js: - optional: true - dria: - optional: true - duck-duck-scrape: - optional: true - faiss-node: - optional: true - firebase-admin: - optional: true - google-auth-library: - optional: true - googleapis: - optional: true - hnswlib-node: - optional: true - html-to-text: - optional: true - interface-datastore: - optional: true - ioredis: - optional: true - it-all: - optional: true - jsdom: - optional: true - jsonwebtoken: - optional: true - llmonitor: - optional: true - lodash: - optional: true - lunary: - optional: true - mongodb: - optional: true - mysql2: - optional: true - neo4j-driver: - optional: true - node-llama-cpp: - optional: true - pg: - optional: true - pg-copy-streams: - optional: true - pickleparser: - optional: true - portkey-ai: - optional: true - redis: - optional: true - replicate: - optional: true - typeorm: - optional: true - typesense: - optional: true - usearch: - optional: true - vectordb: - optional: true - voy-search: - optional: true - weaviate-ts-client: - optional: true - web-auth-library: - optional: true - ws: - optional: true - - '@langchain/core@0.1.63': - resolution: {integrity: sha512-+fjyYi8wy6x1P+Ee1RWfIIEyxd9Ee9jksEwvrggPwwI/p45kIDTdYTblXsM13y4mNWTiACyLSdbwnPaxxdoz+w==} - engines: {node: '>=18'} - - '@langchain/core@1.1.47': - resolution: {integrity: sha512-+fiPu6ZFnJMrZyKeM77OIVPoMPAY6OKWacnPlojHtXTbMMzb2cEOKAJV0U07cDl86NHSCIYYa0i4CyKZzXbHQQ==} + '@langchain/core@1.1.46': + resolution: {integrity: sha512-i8rDC83BpItxChCw4Lf+6tAr+k+OUcbirc5ZkrhI9ywYWmvxegUljLGOGYvtJNTbEAIFkhYIODPE5QRqyjF6sA==} engines: {node: '>=20'} '@langchain/google-genai@0.2.18': @@ -400,10 +146,9 @@ packages: peerDependencies: '@langchain/core': ^1.1.44 - '@langchain/langgraph-sdk@1.9.4': - resolution: {integrity: sha512-hhASJGKa2MDJDtDkuIFdWGysMTog/HkYe0r6B6Gn1XqsURWnF7FIFl9diITAPOv1tB8YpyjnbpsBj/NkT5d+jQ==} + '@langchain/langgraph-sdk@1.9.2': + resolution: {integrity: sha512-1kDPjR0VH/39q2h8k0Sxi35KxOvEQPModVCepxGLlRkbZmuWUH+zfICuJd3rmD1ByeOKQBZEaB7Y+VCYmSMt1w==} peerDependencies: - '@langchain/core': ^1.1.44 react: ^18 || ^19 react-dom: ^18 || ^19 svelte: ^4.0.0 || ^5.0.0 @@ -418,8 +163,8 @@ packages: vue: optional: true - '@langchain/langgraph@1.3.2': - resolution: {integrity: sha512-SL7Ktsr681R7da+1b2MVOWEbaCoFJOXEJPTGOjg4JIG4C7quWbTYC8DzxhcCxte6D/8cGp0rYDBnbKLXEpNqlA==} + '@langchain/langgraph@1.3.0': + resolution: {integrity: sha512-QvhTjiyqFPz81A+y6LHs223w6DTjv5+882DT4mup72bd72rRhNjTYo5fhes5um0swnKArvY/arc7KeFInfHHWw==} engines: {node: '>=18'} peerDependencies: '@langchain/core': ^1.1.44 @@ -429,10 +174,6 @@ packages: zod-to-json-schema: optional: true - '@langchain/openai@0.0.34': - resolution: {integrity: sha512-M+CW4oXle5fdoz2T2SwdOef8pl3/1XmUx1vjn2mXUVM/128aO0l23FMF0SNBsAbRV6P+p/TuzjodchJbi0Ht/A==} - engines: {node: '>=18'} - '@langchain/openai@1.2.0': resolution: {integrity: sha512-r2g5Be3Sygw7VTJ89WVM/M94RzYToNTwXf8me1v+kgKxzdHbd/8XPYDFxpXEp3REyPgUrtJs+Oplba9pkTH5ug==} engines: {node: '>=20'} @@ -450,6 +191,10 @@ packages: resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} engines: {node: '>=8'} + '@protobuf-ts/protoc@2.11.1': + resolution: {integrity: sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==} + hasBin: true + '@segment/analytics-core@1.8.2': resolution: {integrity: sha512-5FDy6l8chpzUfJcNlIcyqYQq4+JTUynlVoCeCUuVz+l+6W0PXg+ljKp34R4yLVCcY5VVZohuW+HH0VLWdwYVAg==} @@ -466,57 +211,22 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node-fetch@2.6.13': - resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} - - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@20.19.9': resolution: {integrity: sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==} - '@types/retry@0.12.0': - resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} - '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - - agentkeepalive@4.6.0: - resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} - engines: {node: '>= 8.0.0'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - binary-search@1.3.6: - resolution: {integrity: sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA==} - buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -528,25 +238,12 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - commander@10.0.1: - resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} - engines: {node: '>=14'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} console-table-printer@2.14.6: resolution: {integrity: sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw==} - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -555,73 +252,19 @@ packages: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - expr-eval@2.0.2: - resolution: {integrity: sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==} + fast-json-patch@3.1.1: + resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} fast-xml-parser@4.5.6: resolution: {integrity: sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==} hasBin: true - flat@5.0.2: - resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} - hasBin: true - - form-data-encoder@1.7.2: - resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - formdata-node@4.4.1: - resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} - engines: {node: '>= 12.20'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graphql@16.12.0: resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -630,27 +273,9 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - humanize-ms@1.2.1: - resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - is-any-array@2.0.1: - resolution: {integrity: sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==} - is-network-error@1.3.2: resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} engines: {node: '>=16'} @@ -671,14 +296,6 @@ packages: peerDependencies: '@langchain/core': 1.1.13 - langsmith@0.1.68: - resolution: {integrity: sha512-otmiysWtVAqzMx3CJ4PrtUBhWRG5Co8Z4o7hSZENPjlit9/j3/vm3TSvbaxpDYakZxtMjhkcJTqrdYFipISEiQ==} - peerDependencies: - openai: '*' - peerDependenciesMeta: - openai: - optional: true - langsmith@0.4.0: resolution: {integrity: sha512-/X99fHBuBFFup778dNmgAVJMdFULz0S8yZUT1cD1RRSviMjxq1GZo8PulRR1ALDxpgYsJs8ueF9godUzF13LSw==} peerDependencies: @@ -716,45 +333,10 @@ packages: ws: optional: true - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - ml-array-mean@1.1.6: - resolution: {integrity: sha512-MIdf7Zc8HznwIisyiJGRH9tRigg3Yf4FldW8DxKxpCCv/g5CafTw0RRu51nojVEOXuCQC7DRVVu5c7XXO/5joQ==} - - ml-array-sum@1.1.6: - resolution: {integrity: sha512-29mAh2GwH7ZmiRnup4UyibQZB9+ZLyMShvt4cH4eTK+cL2oEMIZFnSyB3SS8MlsTh6q/w/yh48KmqLxmovN4Dw==} - - ml-distance-euclidean@2.0.0: - resolution: {integrity: sha512-yC9/2o8QF0A3m/0IXqCTXCzz2pNEzvmcE/9HFKOZGnTjatvBbsn4lWYJkxENkA4Ug2fnYl7PXQxnPi21sgMy/Q==} - - ml-distance@4.0.1: - resolution: {integrity: sha512-feZ5ziXs01zhyFUUUeZV5hwc0f5JW0Sh0ckU1koZe/wdVkJdGxcP06KNQuF0WBTj8FttQUzcvQcpcrOp/XrlEw==} - - ml-tree-similarity@1.0.0: - resolution: {integrity: sha512-XJUyYqjSuUQkNQHMscr6tcjldsOoAekxADTplt40QKfwW6nd++1wHWV9AArl0Zvw/TIHgNaZZNvr8QGvE8wLRg==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - deprecated: Use your platform's native DOMException instead - node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -764,22 +346,6 @@ packages: encoding: optional: true - num-sort@2.1.0: - resolution: {integrity: sha512-1MQz1Ed8z2yckoBeSfkQHHO9K1yDRxxtotKSJ9yvcTUUxSvfvzEq5GwBrjjHEpMlq/k5gvXdmJ1SbYxWtpNoVg==} - engines: {node: '>=8'} - - openai@4.104.0: - resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} - hasBin: true - peerDependencies: - ws: ^8.18.0 - zod: ^3.23.8 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - openai@6.15.0: resolution: {integrity: sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==} hasBin: true @@ -804,10 +370,6 @@ packages: resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} engines: {node: '>=20'} - p-retry@4.6.2: - resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} - engines: {node: '>=8'} - p-retry@7.1.1: resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} engines: {node: '>=20'} @@ -820,9 +382,8 @@ packages: resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} engines: {node: '>=20'} - retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} - engines: {node: '>= 4'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} @@ -856,12 +417,12 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + untruncate-json@0.0.1: + resolution: {integrity: sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==} + uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true @@ -874,15 +435,6 @@ packages: resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} hasBin: true - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - - web-streams-polyfill@4.0.0-beta.3: - resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} - engines: {node: '>= 14'} - webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -899,11 +451,79 @@ packages: snapshots: - '@ag-ui/core@0.0.42': + '@ag-ui/a2ui-toolkit@0.0.3': {} + + '@ag-ui/client@0.0.53': dependencies: + '@ag-ui/core': 0.0.53 + '@ag-ui/encoder': 0.0.53 + '@ag-ui/proto': 0.0.53 + '@types/uuid': 10.0.0 + compare-versions: 6.1.1 + fast-json-patch: 3.1.1 rxjs: 7.8.1 + untruncate-json: 0.0.1 + uuid: 11.1.0 zod: 3.25.76 + '@ag-ui/core@0.0.53': + dependencies: + zod: 3.25.76 + + '@ag-ui/encoder@0.0.53': + dependencies: + '@ag-ui/core': 0.0.53 + '@ag-ui/proto': 0.0.53 + + '@ag-ui/langgraph@0.0.31(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.53)(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76))': + dependencies: + '@ag-ui/client': 0.0.53 + '@ag-ui/core': 0.0.53 + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) + '@langchain/langgraph-sdk': 1.9.2(openai@6.15.0(zod@3.25.76)) + langchain: 1.2.8(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)) + partial-json: 0.1.7 + rxjs: 7.8.1 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - react + - react-dom + - svelte + - vue + - ws + - zod-to-json-schema + + '@ag-ui/langgraph@0.0.41(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.53)(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76))': + dependencies: + '@ag-ui/a2ui-toolkit': 0.0.3 + '@ag-ui/client': 0.0.53 + '@ag-ui/core': 0.0.53 + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) + '@langchain/langgraph-sdk': 1.9.2(openai@6.15.0(zod@3.25.76)) + langchain: 1.2.8(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)) + partial-json: 0.1.7 + rxjs: 7.8.1 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - react + - react-dom + - svelte + - vue + - ws + - zod-to-json-schema + + '@ag-ui/proto@0.0.53': + dependencies: + '@ag-ui/core': 0.0.53 + '@bufbuild/protobuf': 2.12.0 + '@protobuf-ts/protoc': 2.11.1 + '@anthropic-ai/sdk@0.65.0(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -912,91 +532,63 @@ snapshots: '@babel/runtime@7.29.2': {} + '@bufbuild/protobuf@2.12.0': {} + '@cfworker/json-schema@4.1.1': {} - '@copilotkit/sdk-js@0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/core@0.0.42)(@langchain/community@0.0.53(openai@6.15.0(zod@3.25.76)))(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(@langchain/langgraph@1.3.2(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(langchain@1.2.8(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)))(typescript@5.8.3)(zod@3.25.76)': + '@copilotkit/license-verifier@0.4.0': {} + + '@copilotkit/sdk-js@1.57.1(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.53)(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(@langchain/langgraph@1.3.0(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76))(langchain@1.2.8(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(typescript@5.8.3)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76)': dependencies: - '@copilotkit/shared': 0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/core@0.0.42) - '@langchain/community': 0.0.53(openai@6.15.0(zod@3.25.76)) - '@langchain/core': 1.1.47(openai@6.15.0(zod@3.25.76)) - '@langchain/langgraph': 1.3.2(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) - langchain: 1.2.8(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)) + '@ag-ui/langgraph': 0.0.31(@ag-ui/client@0.0.53)(@ag-ui/core@0.0.53)(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)) + '@copilotkit/shared': 1.57.1(@ag-ui/core@0.0.53) + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) + '@langchain/langgraph': 1.3.0(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) + langchain: 1.2.8(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)) typescript: 5.8.3 zod: 3.25.76 transitivePeerDependencies: + - '@ag-ui/client' - '@ag-ui/core' + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' - encoding + - openai + - react + - react-dom + - svelte + - vue + - ws + - zod-to-json-schema - '@copilotkit/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/core@0.0.42)': + '@copilotkit/shared@1.57.1(@ag-ui/core@0.0.53)': dependencies: - '@ag-ui/core': 0.0.42 + '@ag-ui/client': 0.0.53 + '@ag-ui/core': 0.0.53 + '@copilotkit/license-verifier': 0.4.0 '@segment/analytics-node': 2.3.0 + '@standard-schema/spec': 1.1.0 chalk: 4.1.2 graphql: 16.12.0 - uuid: 10.0.0 + partial-json: 0.1.7 + uuid: 11.1.0 zod: 3.25.76 + zod-to-json-schema: 3.24.6(zod@3.25.76) transitivePeerDependencies: - encoding '@google/generative-ai@0.24.1': {} - '@langchain/anthropic@0.3.34(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(zod@3.25.76)': + '@langchain/anthropic@0.3.34(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(zod@3.25.76)': dependencies: '@anthropic-ai/sdk': 0.65.0(zod@3.25.76) - '@langchain/core': 1.1.47(openai@6.15.0(zod@3.25.76)) + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) fast-xml-parser: 4.5.6 transitivePeerDependencies: - zod - '@langchain/community@0.0.53(openai@6.15.0(zod@3.25.76))': - dependencies: - '@langchain/core': 0.1.63(openai@6.15.0(zod@3.25.76)) - '@langchain/openai': 0.0.34 - expr-eval: 2.0.2 - flat: 5.0.2 - langsmith: 0.1.68(openai@6.15.0(zod@3.25.76)) - uuid: 9.0.1 - zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) - transitivePeerDependencies: - - encoding - - openai - - '@langchain/core@0.1.63(openai@4.104.0(zod@3.25.76))': - dependencies: - ansi-styles: 5.2.0 - camelcase: 6.3.0 - decamelize: 1.2.0 - js-tiktoken: 1.0.20 - langsmith: 0.1.68(openai@4.104.0(zod@3.25.76)) - ml-distance: 4.0.1 - mustache: 4.2.0 - p-queue: 6.6.2 - p-retry: 4.6.2 - uuid: 9.0.1 - zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) - transitivePeerDependencies: - - openai - - '@langchain/core@0.1.63(openai@6.15.0(zod@3.25.76))': - dependencies: - ansi-styles: 5.2.0 - camelcase: 6.3.0 - decamelize: 1.2.0 - js-tiktoken: 1.0.20 - langsmith: 0.1.68(openai@6.15.0(zod@3.25.76)) - ml-distance: 4.0.1 - mustache: 4.2.0 - p-queue: 6.6.2 - p-retry: 4.6.2 - uuid: 9.0.1 - zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) - transitivePeerDependencies: - - openai - - '@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76))': + '@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76))': dependencies: '@cfworker/json-schema': 4.1.1 '@standard-schema/spec': 1.1.0 @@ -1012,36 +604,42 @@ snapshots: - openai - ws - '@langchain/google-genai@0.2.18(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))': + '@langchain/google-genai@0.2.18(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))': dependencies: '@google/generative-ai': 0.24.1 - '@langchain/core': 1.1.47(openai@6.15.0(zod@3.25.76)) + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) uuid: 11.1.0 - '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))': + '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))': dependencies: - '@langchain/core': 1.1.47(openai@6.15.0(zod@3.25.76)) + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) uuid: 10.0.0 - '@langchain/langgraph-checkpoint@1.0.2(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))': + '@langchain/langgraph-checkpoint@1.0.2(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))': dependencies: - '@langchain/core': 1.1.47(openai@6.15.0(zod@3.25.76)) + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) uuid: 10.0.0 - '@langchain/langgraph-sdk@1.9.4(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))': + '@langchain/langgraph-sdk@1.9.2(openai@6.15.0(zod@3.25.76))': dependencies: - '@langchain/core': 1.1.47(openai@6.15.0(zod@3.25.76)) + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) '@langchain/protocol': 0.0.15 '@types/json-schema': 7.0.15 p-queue: 9.3.0 p-retry: 7.1.1 uuid: 13.0.2 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - ws - '@langchain/langgraph@1.3.2(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76)': + '@langchain/langgraph@1.3.0(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76)': dependencies: - '@langchain/core': 1.1.47(openai@6.15.0(zod@3.25.76)) - '@langchain/langgraph-checkpoint': 1.0.2(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76))) - '@langchain/langgraph-sdk': 1.9.4(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76))) + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) + '@langchain/langgraph-checkpoint': 1.0.2(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76))) + '@langchain/langgraph-sdk': 1.9.2(openai@6.15.0(zod@3.25.76)) '@langchain/protocol': 0.0.15 '@standard-schema/spec': 1.1.0 uuid: 10.0.0 @@ -1049,25 +647,19 @@ snapshots: optionalDependencies: zod-to-json-schema: 3.24.6(zod@3.25.76) transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai - react - react-dom - svelte - vue - - '@langchain/openai@0.0.34': - dependencies: - '@langchain/core': 0.1.63(openai@4.104.0(zod@3.25.76)) - js-tiktoken: 1.0.20 - openai: 4.104.0(zod@3.25.76) - zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) - transitivePeerDependencies: - - encoding - ws - '@langchain/openai@1.2.0(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))': + '@langchain/openai@1.2.0(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))': dependencies: - '@langchain/core': 1.1.47(openai@6.15.0(zod@3.25.76)) + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) js-tiktoken: 1.0.20 openai: 6.15.0(zod@3.25.76) zod: 3.25.76 @@ -1082,6 +674,8 @@ snapshots: dependencies: '@lukeed/csprng': 1.1.0 + '@protobuf-ts/protoc@2.11.1': {} + '@segment/analytics-core@1.8.2': dependencies: '@lukeed/uuid': 2.0.1 @@ -1109,55 +703,23 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node-fetch@2.6.13': - dependencies: - '@types/node': 20.19.9 - form-data: 4.0.5 - - '@types/node@18.19.130': - dependencies: - undici-types: 5.26.5 - '@types/node@20.19.9': dependencies: undici-types: 6.21.0 - '@types/retry@0.12.0': {} - '@types/uuid@10.0.0': {} - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - - agentkeepalive@4.6.0: - dependencies: - humanize-ms: 1.2.1 - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - ansi-styles@5.2.0: {} - - asynckit@0.4.0: {} - base64-js@1.5.1: {} - binary-search@1.3.6: {} - buffer@6.0.3: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - camelcase@6.3.0: {} - chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -1169,118 +731,32 @@ snapshots: color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - commander@10.0.1: {} + compare-versions@6.1.1: {} console-table-printer@2.14.6: dependencies: simple-wcswidth: 1.1.2 - decamelize@1.2.0: {} - - delayed-stream@1.0.0: {} - dotenv@16.6.1: {} dset@3.1.4: {} - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - event-target-shim@5.0.1: {} - eventemitter3@4.0.7: {} eventemitter3@5.0.4: {} - expr-eval@2.0.2: {} + fast-json-patch@3.1.1: {} fast-xml-parser@4.5.6: dependencies: strnum: 1.1.2 - flat@5.0.2: {} - - form-data-encoder@1.7.2: {} - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - formdata-node@4.4.1: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 4.0.0-beta.3 - - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - gopd@1.2.0: {} - graphql@16.12.0: {} has-flag@4.0.0: {} - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - humanize-ms@1.2.1: - dependencies: - ms: 2.1.3 - ieee754@1.2.1: {} - is-any-array@2.0.1: {} - is-network-error@1.3.2: {} jose@5.10.0: {} @@ -1294,11 +770,11 @@ snapshots: '@babel/runtime': 7.29.2 ts-algebra: 2.0.0 - langchain@1.2.8(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)): + langchain@1.2.8(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76)): dependencies: - '@langchain/core': 1.1.47(openai@6.15.0(zod@3.25.76)) - '@langchain/langgraph': 1.3.2(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76)))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.47(openai@6.15.0(zod@3.25.76))) + '@langchain/core': 1.1.46(openai@6.15.0(zod@3.25.76)) + '@langchain/langgraph': 1.3.0(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76)))(openai@6.15.0(zod@3.25.76))(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.46(openai@6.15.0(zod@3.25.76))) langsmith: 0.4.0(openai@6.15.0(zod@3.25.76)) uuid: 10.0.0 zod: 3.25.76 @@ -1311,30 +787,9 @@ snapshots: - react-dom - svelte - vue + - ws - zod-to-json-schema - langsmith@0.1.68(openai@4.104.0(zod@3.25.76)): - dependencies: - '@types/uuid': 10.0.0 - commander: 10.0.1 - p-queue: 6.6.2 - p-retry: 4.6.2 - semver: 7.7.2 - uuid: 10.0.0 - optionalDependencies: - openai: 4.104.0(zod@3.25.76) - - langsmith@0.1.68(openai@6.15.0(zod@3.25.76)): - dependencies: - '@types/uuid': 10.0.0 - commander: 10.0.1 - p-queue: 6.6.2 - p-retry: 4.6.2 - semver: 7.7.2 - uuid: 10.0.0 - optionalDependencies: - openai: 6.15.0(zod@3.25.76) - langsmith@0.4.0(openai@6.15.0(zod@3.25.76)): dependencies: '@types/uuid': 10.0.0 @@ -1352,61 +807,12 @@ snapshots: optionalDependencies: openai: 6.15.0(zod@3.25.76) - math-intrinsics@1.1.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - ml-array-mean@1.1.6: - dependencies: - ml-array-sum: 1.1.6 - - ml-array-sum@1.1.6: - dependencies: - is-any-array: 2.0.1 - - ml-distance-euclidean@2.0.0: {} - - ml-distance@4.0.1: - dependencies: - ml-array-mean: 1.1.6 - ml-distance-euclidean: 2.0.0 - ml-tree-similarity: 1.0.0 - - ml-tree-similarity@1.0.0: - dependencies: - binary-search: 1.3.6 - num-sort: 2.1.0 - - ms@2.1.3: {} - mustache@4.2.0: {} - node-domexception@1.0.0: {} - node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 - num-sort@2.1.0: {} - - openai@4.104.0(zod@3.25.76): - dependencies: - '@types/node': 18.19.130 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 - optionalDependencies: - zod: 3.25.76 - transitivePeerDependencies: - - encoding - openai@6.15.0(zod@3.25.76): optionalDependencies: zod: 3.25.76 @@ -1423,11 +829,6 @@ snapshots: eventemitter3: 5.0.4 p-timeout: 7.0.1 - p-retry@4.6.2: - dependencies: - '@types/retry': 0.12.0 - retry: 0.13.1 - p-retry@7.1.1: dependencies: is-network-error: 1.3.2 @@ -1438,7 +839,7 @@ snapshots: p-timeout@7.0.1: {} - retry@0.13.1: {} + partial-json@0.1.7: {} rxjs@7.8.1: dependencies: @@ -1462,20 +863,16 @@ snapshots: typescript@5.8.3: {} - undici-types@5.26.5: {} - undici-types@6.21.0: {} + untruncate-json@0.0.1: {} + uuid@10.0.0: {} uuid@11.1.0: {} uuid@13.0.2: {} - uuid@9.0.1: {} - - web-streams-polyfill@4.0.0-beta.3: {} - webidl-conversions@3.0.1: {} whatwg-url@5.0.0: diff --git a/integrations/langgraph/typescript/examples/src/agents/a2ui_dynamic_schema/agent.ts b/integrations/langgraph/typescript/examples/src/agents/a2ui_dynamic_schema/agent.ts new file mode 100644 index 0000000000..cd43f2cbaf --- /dev/null +++ b/integrations/langgraph/typescript/examples/src/agents/a2ui_dynamic_schema/agent.ts @@ -0,0 +1,80 @@ +/** + * Dynamic A2UI agent (prebuilt). + * + * Uses LangChain's `createAgent` prebuilt with the AG-UI `getA2UITools` + * factory. A secondary LLM (the subagent shipped inside the factory) designs + * the A2UI components and data; the AG-UI middleware detects the resulting + * `a2ui_operations` payload in the tool result and renders the surface. + */ + +import { createAgent } from "langchain"; +import { copilotkitMiddleware } from "@copilotkit/sdk-js/langgraph"; +import { ChatOpenAI } from "@langchain/openai"; +import { getA2UITools } from "@ag-ui/langgraph"; + +const CUSTOM_CATALOG_ID = "https://a2ui.org/demos/dojo/dynamic_catalog.json"; + +// Project-specific composition rules — tells the subagent how to use the +// pre-made domain components (HotelCard, ProductCard, TeamMemberCard) shipped +// in the dojo's dynamic catalog. +const COMPOSITION_GUIDE = ` +## Available Pre-made Components + +You have 4 components. Use Row as the root with structural children to repeat a card per item. + +### Row +Layout container. Use structural children to repeat a card template: + {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}} + +### HotelCard +Props: name, location, rating (number 0-5), pricePerNight, amenities (optional), action +Example: + {"id":"card","component":"HotelCard","name":{"path":"name"},"location":{"path":"location"}, + "rating":{"path":"rating"},"pricePerNight":{"path":"pricePerNight"}, + "action":{"event":{"name":"book","context":{"name":{"path":"name"}}}}} + +### ProductCard +Props: name, price, rating (number 0-5), description (optional), badge (optional), action +Example: + {"id":"card","component":"ProductCard","name":{"path":"name"},"price":{"path":"price"}, + "rating":{"path":"rating"},"description":{"path":"description"}, + "action":{"event":{"name":"select","context":{"name":{"path":"name"}}}}} + +### TeamMemberCard +Props: name, role, department (optional), email (optional), avatarUrl (optional), action +Example: + {"id":"card","component":"TeamMemberCard","name":{"path":"name"},"role":{"path":"role"}, + "department":{"path":"department"},"email":{"path":"email"}, + "action":{"event":{"name":"contact","context":{"name":{"path":"name"}}}}} + +## RULES +- Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"} +- Inside templates, use RELATIVE paths (no leading slash): {"path":"name"} not {"path":"/name"} +- Always provide data in the "data" argument as {"items":[...]} +- Pick the card type that best matches the user's request +- Generate 3-4 realistic items with diverse data +`; + +const a2uiTool = getA2UITools({ + model: new ChatOpenAI({ model: "gpt-4o" }), + defaultCatalogId: CUSTOM_CATALOG_ID, + guidelines: { compositionGuide: COMPOSITION_GUIDE }, +}); + +const a2uiDynamicSchemaAgent = createAgent({ + model: "openai:gpt-4o", + // Cast: tool returned by `getA2UITools` is typed against `@ag-ui/langgraph`'s + // own `@langchain/core` peer, which can skew vs. the consumer's pin. + tools: [a2uiTool as any], + middleware: [copilotkitMiddleware], + systemPrompt: `You are a helpful assistant that creates rich visual UI on the fly. + +When the user asks for visual content (product comparisons, dashboards, lists, cards, etc.), +use the generate_a2ui tool to create a dynamic A2UI surface. +IMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.`, +}); + +// Export the inner graph, not the ReactAgent wrapper, so LangGraph Platform can +// inject its managed checkpointer (the wrapper swallows the injection — +// langchainjs#10144 — causing MISSING_CHECKPOINTER on the 2nd turn deployed). +export const a2uiDynamicSchemaGraph = a2uiDynamicSchemaAgent.graph; diff --git a/integrations/langgraph/typescript/examples/src/agents/a2ui_fixed_schema/agent.ts b/integrations/langgraph/typescript/examples/src/agents/a2ui_fixed_schema/agent.ts new file mode 100644 index 0000000000..7f26e0d2aa --- /dev/null +++ b/integrations/langgraph/typescript/examples/src/agents/a2ui_fixed_schema/agent.ts @@ -0,0 +1,189 @@ +/** + * Fixed-schema A2UI agent (prebuilt). + * + * Pre-built component layouts for flight and hotel cards. The agent only + * supplies the data; layout/styling is fixed in code. Demonstrates the + * "controlled gen-UI" pattern: author owns the UI shape, agent owns the data. + */ + +import { createAgent } from "langchain"; +import { copilotkitMiddleware } from "@copilotkit/sdk-js/langgraph"; +import { tool } from "@langchain/core/tools"; + +const CUSTOM_CATALOG_ID = + "https://a2ui.org/demos/dojo/fixed_catalog.json"; + +const A2UI_OPERATIONS_KEY = "a2ui_operations"; + +// Flight search layout — agent supplies `flights` array; rendering is fixed. +const FLIGHT_SURFACE_ID = "flight-search-results"; +const FLIGHT_SCHEMA: Array> = [ + { + id: "root", + component: "Row", + children: { componentId: "flight-card", path: "/flights" }, + gap: 16, + }, + { + id: "flight-card", + component: "FlightCard", + airline: { path: "airline" }, + airlineLogo: { path: "airlineLogo" }, + flightNumber: { path: "flightNumber" }, + origin: { path: "origin" }, + destination: { path: "destination" }, + date: { path: "date" }, + departureTime: { path: "departureTime" }, + arrivalTime: { path: "arrivalTime" }, + duration: { path: "duration" }, + status: { path: "status" }, + price: { path: "price" }, + action: { + event: { + name: "book_flight", + context: { + flightNumber: { path: "flightNumber" }, + origin: { path: "origin" }, + destination: { path: "destination" }, + price: { path: "price" }, + }, + }, + }, + }, +]; + +// Hotel search layout — agent supplies `hotels` array; rendering is fixed. +const HOTEL_SURFACE_ID = "hotel-search-results"; +const HOTEL_SCHEMA: Array> = [ + { + id: "root", + component: "Row", + children: { componentId: "hotel-card", path: "/hotels" }, + gap: 16, + }, + { + id: "hotel-card", + component: "HotelCard", + name: { path: "name" }, + location: { path: "location" }, + rating: { path: "rating" }, + pricePerNight: { path: "price" }, + action: { + event: { + name: "book_hotel", + context: { + hotelName: { path: "name" }, + price: { path: "price" }, + }, + }, + }, + }, +]; + +function renderOperations( + surfaceId: string, + catalogId: string, + schema: Array>, + data: Record, +): string { + const ops = [ + { + version: "v0.9", + createSurface: { surfaceId, catalogId }, + }, + { + version: "v0.9", + updateComponents: { surfaceId, components: schema }, + }, + { + version: "v0.9", + updateDataModel: { surfaceId, path: "/", value: data }, + }, + ]; + return JSON.stringify({ [A2UI_OPERATIONS_KEY]: ops }); +} + +const searchFlights = tool( + async ({ flights }: { flights: Array> }) => { + return renderOperations( + FLIGHT_SURFACE_ID, + CUSTOM_CATALOG_ID, + FLIGHT_SCHEMA, + { flights }, + ); + }, + { + name: "search_flights", + description: + "Search for flights and display the results as rich cards. Each flight " + + "must have: id, airline (e.g. 'United Airlines'), airlineLogo (use Google " + + "favicon API like 'https://www.google.com/s2/favicons?domain=united.com&sz=128'), " + + "flightNumber, origin, destination, date (e.g. 'Tue, Mar 18'), departureTime, " + + "arrivalTime, duration (e.g. '4h 25m'), status ('On Time' or 'Delayed'), " + + "and price (e.g. '$289').", + schema: { + type: "object", + properties: { + flights: { + type: "array", + items: { type: "object" }, + description: "Array of flight result objects.", + }, + }, + required: ["flights"], + } as any, + }, +); + +const searchHotels = tool( + async ({ hotels }: { hotels: Array> }) => { + return renderOperations( + HOTEL_SURFACE_ID, + CUSTOM_CATALOG_ID, + HOTEL_SCHEMA, + { hotels }, + ); + }, + { + name: "search_hotels", + description: + "Search for hotels and display the results as rich cards with star ratings. " + + "Each hotel must have: id, name (e.g. 'The Plaza'), location " + + "(e.g. 'Midtown Manhattan, NYC'), rating (float 0-5, e.g. 4.5), and " + + "price (per night, e.g. '$350'). Generate 3-4 realistic results.", + schema: { + type: "object", + properties: { + hotels: { + type: "array", + items: { type: "object" }, + description: "Array of hotel result objects.", + }, + }, + required: ["hotels"], + } as any, + }, +); + +const a2uiFixedSchemaAgent = createAgent({ + model: "openai:gpt-4o", + tools: [searchFlights, searchHotels], + middleware: [copilotkitMiddleware], + systemPrompt: `You are a helpful travel assistant that can search for flights and hotels. + +When the user asks about flights, use the search_flights tool. +When the user asks about hotels, use the search_hotels tool. +IMPORTANT: After calling a tool, do NOT repeat or summarize the data in your text response. The tool renders a rich UI automatically. Just say something brief like "Here are your results" or ask if they'd like to book. + +For flights, each needs: id, airline, airlineLogo (Google favicon API), flightNumber, origin, destination, +date, departureTime, arrivalTime, duration, status, and price. + +For hotels, each needs: id, name, location, rating (float 0-5), and price (per night). + +Generate 3-5 realistic results.`, +}); + +// Export the inner graph, not the ReactAgent wrapper, so LangGraph Platform can +// inject its managed checkpointer (the wrapper swallows the injection — +// langchainjs#10144 — causing MISSING_CHECKPOINTER on the 2nd turn deployed). +export const a2uiFixedSchemaGraph = a2uiFixedSchemaAgent.graph; diff --git a/integrations/langgraph/typescript/examples/src/agents/a2ui_recovery/agent.ts b/integrations/langgraph/typescript/examples/src/agents/a2ui_recovery/agent.ts new file mode 100644 index 0000000000..dd7cfafa50 --- /dev/null +++ b/integrations/langgraph/typescript/examples/src/agents/a2ui_recovery/agent.ts @@ -0,0 +1,78 @@ +/** + * A2UI recovery agent (OSS-162) — DRAFT showcase, verify before wiring. + * + * A clone of `a2ui_dynamic_schema` that showcases the error-recovery loop. It + * needs NO new mechanism: on this branch `getA2UITools` already runs + * `runA2UIGenerationWithRecovery` (default 3 attempts) and the middleware gate + * runs at the component-close boundary — both default to STRUCTURAL validation + * when no catalog is supplied (missing root, dangling child reference, + * unresolved binding, malformed/empty components). So this rides the exact same + * runtime A2UI wiring as the existing demos (add it to the runtime `a2ui.agents` + * list); no catalog/`schema` and no A/B middleware choice required. + * + * In the dojo demo the sub-agent's render_a2ui output is driven by aimock: the + * first attempt emits a structurally-invalid surface (a Row whose repeated child + * references a `card` component the model forgot to include → "unresolved child"), + * which the gate suppresses (no wipe) and the loop regenerates with the error fed + * back, then a valid surface paints. A second prompt forces repeated failure to + * demonstrate the tasteful hard-failure state. + * + * (Catalog-aware SEMANTIC validation — unknown component / missing required prop — + * is the separate, optional scope that would need the catalog wired; not used here.) + */ + +import { createAgent } from "langchain"; +import { copilotkitMiddleware } from "@copilotkit/sdk-js/langgraph"; +import { ChatOpenAI } from "@langchain/openai"; +import { getA2UITools, type A2UIAttemptRecord } from "@ag-ui/langgraph"; + +const CUSTOM_CATALOG_ID = "https://a2ui.org/demos/dojo/dynamic_catalog.json"; + +const COMPOSITION_GUIDE = ` +## Available Pre-made Components + +Use Row as the root with structural children to repeat a card per item. + +### Row +Layout container. Repeat a card template via structural children: + {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}} + +### HotelCard / ProductCard / TeamMemberCard +Card components bound to per-item data (relative paths inside the template). + +## RULES +- Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"} +- ALWAYS include the referenced card component in the components array. +- Inside templates, use RELATIVE paths (no leading slash): {"path":"name"} not {"path":"/name"} +- Always provide data in the "data" argument as {"items":[...]} +- Generate 3-4 realistic items with diverse data. +`; + +const a2uiTool = getA2UITools({ + model: new ChatOpenAI({ model: "gpt-4o" }), + defaultCatalogId: CUSTOM_CATALOG_ID, + guidelines: { compositionGuide: COMPOSITION_GUIDE }, + // Recovery loop runs by default; set explicitly for the showcase. No catalog + // → structural validation (which is all this demo's error needs). + recovery: { maxAttempts: 3 }, + onA2UIAttempt: (rec: A2UIAttemptRecord) => { + // Dev observability: each attempt (incl. rejected ones) is logged. + // eslint-disable-next-line no-console + console.log( + `[a2ui recovery] attempt ${rec.attempt}: ${rec.ok ? "valid" : "invalid"}`, + rec.errors, + ); + }, +}); + +export const a2uiRecoveryGraph = createAgent({ + model: "openai:gpt-4o", + // Cast: tool typed against @ag-ui/langgraph's own @langchain/core peer. + tools: [a2uiTool as any], + middleware: [copilotkitMiddleware], + systemPrompt: `You are a helpful assistant that creates rich visual UI on the fly. + +When the user asks for visual content (hotel/product comparisons, team rosters, lists, cards, etc.), +use the generate_a2ui tool to create a dynamic A2UI surface. +IMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.`, +}); diff --git a/integrations/langgraph/typescript/examples/src/agents/agentic_chat/agent.ts b/integrations/langgraph/typescript/examples/src/agents/agentic_chat/agent.ts index 9cea9af813..d755ac260f 100644 --- a/integrations/langgraph/typescript/examples/src/agents/agentic_chat/agent.ts +++ b/integrations/langgraph/typescript/examples/src/agents/agentic_chat/agent.ts @@ -7,15 +7,18 @@ */ import { createAgent } from "langchain"; -import { MemorySaver } from "@langchain/langgraph"; import { copilotkitMiddleware } from "@copilotkit/sdk-js/langgraph"; -const checkpointer = new MemorySaver(); - -export const agenticChatGraph = createAgent({ +const agenticChatAgent = createAgent({ model: "openai:gpt-4o", tools: [], // Backend tools go here middleware: [copilotkitMiddleware], systemPrompt: "You are a helpful assistant.", - checkpointer }); + +// Export the inner graph, not the ReactAgent wrapper. On LangGraph Platform the +// server injects its managed checkpointer into the graph; the wrapper does not +// forward that injection to its private #graph (langchainjs#10144), so on the +// 2nd turn getState/resume fails with MISSING_CHECKPOINTER. Exporting `.graph` +// lets the platform inject persistence directly. No compiled checkpointer. +export const agenticChatGraph = agenticChatAgent.graph; diff --git a/integrations/langgraph/typescript/package.json b/integrations/langgraph/typescript/package.json index af49979ab7..5a013de471 100644 --- a/integrations/langgraph/typescript/package.json +++ b/integrations/langgraph/typescript/package.json @@ -1,6 +1,7 @@ { "name": "@ag-ui/langgraph", - "version": "0.0.34", + "version": "0.0.42", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" @@ -30,6 +31,7 @@ "unlink:global": "pnpm unlink --global" }, "dependencies": { + "@ag-ui/a2ui-toolkit": "workspace:*", "@langchain/core": "^1.1.40", "@langchain/langgraph-sdk": "^1.8.8", "langchain": ">=1.2.0", diff --git a/integrations/langgraph/typescript/src/__tests__/agent.test.ts b/integrations/langgraph/typescript/src/__tests__/agent.test.ts index 6d25c4e311..2249f0f85d 100644 --- a/integrations/langgraph/typescript/src/__tests__/agent.test.ts +++ b/integrations/langgraph/typescript/src/__tests__/agent.test.ts @@ -575,6 +575,47 @@ describe("forwarded headers injected into payload.config.configurable", () => { // ─── Integration tests (skipped without LANGGRAPH_API_URL) ─────────────────── +describe("langGraphDefaultMergeState forwards props into ag-ui state", () => { + // Forwarded props that must surface into ag-ui state, keyed by the + // forwardedProps key mapped to [ag-ui state key, sample value]. To wire a new + // forwarded prop into ag-ui state, add it here AND in + // langGraphDefaultMergeState — both assertions below then cover it. + const FORWARDED_PROPS_TO_AGUI: Record = { + injectA2UITool: ["inject_a2ui_tool", "render_a2ui"], + }; + + function mergeWith(forwardedProps: Record) { + const { agent } = buildMockedAgent(); + const input = { + threadId: "t1", + runId: "r1", + state: {}, + messages: [], + tools: [], + context: [], + forwardedProps, + } as any; + return (agent as any).langGraphDefaultMergeState({ messages: [] }, [], input); + } + + it("surfaces each configured forwarded prop under its ag-ui state key", () => { + const forwarded = Object.fromEntries( + Object.entries(FORWARDED_PROPS_TO_AGUI).map(([fp, [, sample]]) => [fp, sample]), + ); + const result = mergeWith(forwarded); + for (const [aguiKey, sample] of Object.values(FORWARDED_PROPS_TO_AGUI)) { + expect(result["ag-ui"][aguiKey]).toEqual(sample); + } + }); + + it("omits the ag-ui keys when no forwarded props are present", () => { + const result = mergeWith({}); + for (const [aguiKey] of Object.values(FORWARDED_PROPS_TO_AGUI)) { + expect(result["ag-ui"]).not.toHaveProperty(aguiKey); + } + }); +}); + describe("integration tests (require LANGGRAPH_API_URL)", () => { it.todo( "test 13: successful stream against langgraph-api >= 0.7.x — integration test (gated on LANGGRAPH_API_URL)", diff --git a/integrations/langgraph/typescript/src/__tests__/sse-drop-recovery.test.ts b/integrations/langgraph/typescript/src/__tests__/sse-drop-recovery.test.ts new file mode 100644 index 0000000000..27a4f6f6a8 --- /dev/null +++ b/integrations/langgraph/typescript/src/__tests__/sse-drop-recovery.test.ts @@ -0,0 +1,258 @@ +/** + * Repro for the SSE-stream-drop bug (OSS-28 / GitHub #1278) on the + * TypeScript LangGraph integration. + * + * The Python integration was fixed by an ID guard in `prepare_stream` + * (regenerate only when the last user message id is present in the + * checkpoint). The TypeScript `prepareStream` previously had no such guard: + * it routed into `prepareRegenerateStream` on any non-system count mismatch + * (`stateNonSystemCount > inputNonSystemCount`, agent.ts), then + * `getCheckpointByMessage` threw `Error("Message not found")` because the + * client's freshly generated UUID was never persisted. + * + * The guard is now ported to agent.ts: regenerate is only taken when the + * incoming IDs are not already a subset of the checkpoint AND the last user + * message's ID exists in the checkpoint. These tests assert recovery. + */ +import { describe, it, expect, vi } from "vitest"; +import { LangGraphAgent } from "../agent"; + +function buildAgent(checkpointMessages: any[], history: any[]) { + const agent = new LangGraphAgent({ + graphId: "test-graph", + deploymentUrl: "http://localhost:8000", + }); + + (agent as any).activeRun = { + id: "run-1", + threadId: "thread-1", + hasFunctionStreaming: false, + modelMadeToolCall: false, + }; + // Pre-set assistant so prepareStream doesn't need a live search. + (agent as any).assistant = { + assistant_id: "asst-1", + graph_id: "test-graph", + config: { configurable: {} }, + }; + + const streamCalls: any[] = []; + (agent as any).client = { + threads: { + get: vi.fn().mockResolvedValue({ thread_id: "thread-1" }), + create: vi.fn().mockResolvedValue({ thread_id: "thread-1" }), + getState: vi + .fn() + .mockResolvedValue({ values: { messages: checkpointMessages }, tasks: [] }), + getHistory: vi.fn().mockResolvedValue(history), + updateState: vi + .fn() + .mockResolvedValue({ checkpoint: { checkpoint_id: "ck-fork" } }), + }, + assistants: { + search: vi.fn().mockResolvedValue([ + { assistant_id: "asst-1", graph_id: "test-graph", config: { configurable: {} } }, + ]), + getGraph: vi.fn().mockResolvedValue({ nodes: [], edges: [] }), + getSchemas: vi.fn().mockResolvedValue({ + input_schema: { properties: { messages: {}, tools: {} } }, + output_schema: { properties: { messages: {}, tools: {} } }, + }), + }, + runs: { + stream: vi.fn().mockImplementation((_t: string, _a: string, payload: any) => { + streamCalls.push(payload); + return { + [Symbol.asyncIterator]() { + return { next: async () => ({ done: true, value: undefined }) }; + }, + }; + }), + }, + }; + + const events: any[] = []; + (agent as any).subscriber = { + next: (e: any) => events.push(e), + error: vi.fn(), + complete: vi.fn(), + closed: false, + }; + + return { agent, events, streamCalls }; +} + +const STREAM_MODE = ["events", "values", "updates", "messages-tuple"] as const; + +describe("OSS-28 / #1278 SSE-drop recovery (TypeScript)", () => { + it("recovers from a fresh-UUID resend as a continuation (no throw, no regenerate)", async () => { + // Server finished the previous turn: checkpoint has Human + AI (2 non-system). + const checkpointMessages = [ + { type: "human", id: "h1", content: "first question" }, + { type: "ai", id: "ai1", content: "first answer" }, + ]; + // Realistic history: only h1/ai1 were ever persisted -- the fresh client + // UUID is nowhere in it. (If regenerate were taken, getCheckpointByMessage + // would walk this and throw "Message not found".) + const history = [ + { + values: { messages: checkpointMessages }, + checkpoint: { checkpoint_id: "ck-1", checkpoint_ns: "" }, + parent_checkpoint: null, + next: [], + }, + ]; + const { agent, streamCalls } = buildAgent(checkpointMessages, history); + // Loud guard: any accidental routing into regenerate fails the test + // immediately (mirrors the Python test's AssertionError side-effect). + (agent as any).prepareRegenerateStream = vi.fn(() => { + throw new Error("SSE-drop recovery must not enter regenerate"); + }); + + // SSE dropped before MESSAGES_SNAPSHOT, so the client resends only the new + // user message with a freshly generated UUID (1 non-system message). + // 2 > 1, but the fresh UUID isn't in the checkpoint -> continuation, not + // regeneration. Must not throw; must produce a normal stream. + const input = { + runId: "run-1", + threadId: "thread-1", + messages: [ + { id: "fresh-uuid-never-persisted", role: "user", content: "second question" }, + ], + tools: [], + context: [], + forwardedProps: {}, + }; + + const prepared = await agent.prepareStream(input as any, STREAM_MODE as any); + + expect(prepared).toBeTruthy(); + // Regenerate path not taken, and the history lookup never happened. + expect((agent as any).prepareRegenerateStream).not.toHaveBeenCalled(); + expect((agent as any).client.threads.getHistory).not.toHaveBeenCalled(); + expect((agent as any).subscriber.error).not.toHaveBeenCalled(); + // The new turn must actually reach the stream (not be silently dropped): + // exactly one stream started, carrying the fresh-UUID message. + expect(streamCalls).toHaveLength(1); + const streamedMessages = (streamCalls[0] as any).input?.messages ?? []; + expect( + streamedMessages.some((m: any) => m.id === "fresh-uuid-never-persisted"), + ).toBe(true); + }); + + it("count mismatch with all incoming IDs in checkpoint is a continuation (isContinuation branch)", async () => { + // The motivating non-regeneration case: the client is behind (it never + // received ai1), so it resends only [h1] while the checkpoint holds + // [h1, ai1]. Count mismatches (2 > 1), but every incoming ID is already in + // the checkpoint, so isContinuation short-circuits BEFORE the last-user-id + // check -- a distinct guard from test 1 (which falls through via the + // last-user-id check). A regression flipping `every` -> `some` or dropping + // the length precondition would wrongly regenerate here. + const checkpointMessages = [ + { type: "human", id: "h1", content: "first question" }, + { type: "ai", id: "ai1", content: "first answer" }, + ]; + const { agent, streamCalls } = buildAgent(checkpointMessages, []); + (agent as any).prepareRegenerateStream = vi.fn(() => { + throw new Error("a continuation must not enter regenerate"); + }); + + const input = { + runId: "run-1", + threadId: "thread-1", + messages: [{ id: "h1", role: "user", content: "first question" }], + tools: [], + context: [], + forwardedProps: {}, + }; + + const prepared = await agent.prepareStream(input as any, STREAM_MODE as any); + + expect(prepared).toBeTruthy(); + expect((agent as any).prepareRegenerateStream).not.toHaveBeenCalled(); + expect((agent as any).client.threads.getHistory).not.toHaveBeenCalled(); + expect(streamCalls).toHaveLength(1); + }); + + it("underlying landmine still throws for an unknown id (guard is load-bearing)", async () => { + // The crash site is unchanged: regenerating against an id absent from + // history still throws "Message not found". This is why the prepareStream + // guard exercised above is load-bearing -- if a refactor made this return + // silently instead of throwing, the guard could be dropped and the + // thread-corruption bug would return undetected. Mirrors the Python + // test_underlying_landmine_still_raises_for_unknown_id. + const checkpointMessages = [ + { type: "human", id: "h1", content: "real" }, + ]; + const history = [ + { + values: { messages: checkpointMessages }, + checkpoint: { checkpoint_id: "ck-1", checkpoint_ns: "" }, + parent_checkpoint: null, + next: [], + }, + ]; + const { agent } = buildAgent(checkpointMessages, history); + + await expect( + (agent as any).getCheckpointByMessage("fresh-uuid-never-persisted", "thread-1"), + ).rejects.toThrow("Message not found"); + }); + + it("a genuine edit still routes into regenerate", async () => { + // checkpoint: 4 non-system messages. + const checkpointMessages = [ + { type: "human", id: "h1", content: "original" }, + { type: "ai", id: "ai1", content: "answer" }, + { type: "human", id: "h2", content: "regenerate from here" }, + { type: "ai", id: "ai2", content: "second answer" }, + ]; + const { agent } = buildAgent(checkpointMessages, []); + // Spy out the regenerate machinery; we assert routing and that its result + // is returned unchanged (parity with the Python test's assertIs). + const regenResult = { streamResponse: {}, state: {}, streamMode: STREAM_MODE }; + const regenSpy = vi.fn().mockResolvedValue(regenResult); + (agent as any).prepareRegenerateStream = regenSpy; + + // An incoming id (h-edited) is NOT in the checkpoint -> not a plain + // continuation; the LAST user id (h2) IS in the checkpoint -> genuine edit. + const input = { + runId: "run-1", + threadId: "thread-1", + messages: [ + { id: "h1", role: "user", content: "original" }, + { id: "h-edited", role: "user", content: "edited earlier turn" }, + { id: "h2", role: "user", content: "regenerate from here" }, + ], + tools: [], + context: [], + forwardedProps: {}, + }; + + const result = await agent.prepareStream(input as any, STREAM_MODE as any); + + expect(regenSpy).toHaveBeenCalledTimes(1); + expect(result).toBe(regenResult); + }); + + it("a genuine continuation (no count mismatch) does NOT throw", async () => { + // Control: when the client is in sync (checkpoint count == input count), + // there's no regenerate routing and no throw. + const checkpointMessages = [ + { type: "human", id: "h1", content: "first question" }, + ]; + const { agent } = buildAgent(checkpointMessages, []); + + const input = { + runId: "run-1", + threadId: "thread-1", + messages: [{ id: "h1", role: "user", content: "first question" }], + tools: [], + context: [], + forwardedProps: {}, + }; + + const prepared = await agent.prepareStream(input as any, STREAM_MODE as any); + expect(prepared).toBeTruthy(); + }); +}); diff --git a/integrations/langgraph/typescript/src/a2ui-tool.test.ts b/integrations/langgraph/typescript/src/a2ui-tool.test.ts new file mode 100644 index 0000000000..624864addd --- /dev/null +++ b/integrations/langgraph/typescript/src/a2ui-tool.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for the LangGraph A2UI tool's streaming subagent. + * + * `streamRenderSubagent` STREAMS the model (`stream`) so the nested render_a2ui + * tool-call arg deltas surface natively as the graph's OnChatModelStream events + * — which the generic agent.ts translator paints progressively. The subagent + * emits nothing itself; it just accumulates the streamed chunks and returns the + * final render args for the recovery loop. We drive it with a fake model that + * streams a fixed render_a2ui call as several AIMessageChunks (one arg fragment + * each), like a real provider, and assert the fragments reconstruct. + */ + +import { describe, it, expect } from "vitest"; +import { AIMessageChunk } from "@langchain/core/messages"; + +import { streamRenderSubagent } from "./a2ui-tool"; + +// A structurally-valid render_a2ui result. +const VALID_ARGS = { + surfaceId: "s1", + components: [ + { id: "root", component: "Column", children: ["t"] }, + { id: "t", component: "Text", text: "hi" }, + ], + data: {}, +}; + +/** Split JSON into `parts` non-empty fragments, the way a provider streams. */ +function argChunks(args: unknown, parts = 4): string[] { + const text = JSON.stringify(args); + const size = Math.max(1, Math.floor(text.length / parts)); + const out: string[] = []; + for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size)); + return out.length ? out : [text]; +} + +/** Fake bound model: streams a fixed render_a2ui call as several chunks. */ +function fakeBoundModel(args: unknown, callId = "call-1") { + return { + async *stream(_messages: unknown[]) { + const fragments = argChunks(args); + for (let i = 0; i < fragments.length; i++) { + yield new AIMessageChunk({ + content: "", + tool_call_chunks: [ + { + // Name + id only on the first fragment, mirroring how providers + // stamp them once at the start of the call. + name: i === 0 ? "render_a2ui" : undefined, + args: fragments[i], + id: i === 0 ? callId : undefined, + index: 0, + type: "tool_call_chunk", + }, + ], + }); + } + }, + }; +} + +describe("streamRenderSubagent", () => { + it("accumulates streamed chunks into the full render args", async () => { + // The render call arrives as several partial AIMessageChunk fragments; the + // subagent must merge them back into the complete structured args for the + // recovery loop. (Surfacing the deltas on the wire is langgraph's job, via + // the OnChatModelStream events the stream emits — not this function's.) + const captured = await streamRenderSubagent( + fakeBoundModel(VALID_ARGS), + "PROMPT", + [], + ); + expect(captured).toEqual(VALID_ARGS); + }); + + it("returns null when the model produces no render call", async () => { + const emptyModel = { + // eslint-disable-next-line require-yield + async *stream(_messages: unknown[]) { + return; + }, + }; + const captured = await streamRenderSubagent(emptyModel, "PROMPT", []); + expect(captured).toBeNull(); + }); +}); diff --git a/integrations/langgraph/typescript/src/a2ui-tool.ts b/integrations/langgraph/typescript/src/a2ui-tool.ts new file mode 100644 index 0000000000..c5887c1eaa --- /dev/null +++ b/integrations/langgraph/typescript/src/a2ui-tool.ts @@ -0,0 +1,234 @@ +/** + * A2UI subagent tool factory for LangGraph TS agents. + * + * Thin adapter over ``@ag-ui/a2ui-toolkit`` — the heavy lifting (op builders, + * prompt assembly, history walkers, output envelope) lives in the toolkit so + * each new framework adapter (ADK, Mastra, Strands, …) only owns the + * framework-specific glue: tool decorator, runtime state access, model + * binding + invoke. + * + * Streaming: the subagent's `render_a2ui` call must STREAM to the AG-UI wire so + * the a2ui middleware paints the surface progressively (the "building" skeleton + * keys off the inner tool-call's arg deltas, not the final result). On LangGraph + * this is FREE: the subagent runs `model.stream` inside the graph, so its nested + * `render_a2ui` tool-call arg deltas surface natively as `OnChatModelStream` + * events, which the generic `agent.ts` translator already turns into inner + * TOOL_CALL_START/ARGS/END. So this adapter emits NO A2UI-specific custom events + * — it just streams the subagent and hands the accumulated args to the recovery + * loop. (Frameworks whose SDK does NOT surface a nested model stream as wire + * events — e.g. Strands — own that explicit push in their own adapter.) + * + * Example usage in a chat node: + * + * import { getA2UITools } from "@ag-ui/langgraph"; + * + * const a2ui = getA2UITools({ model: new ChatOpenAI({ model: "gpt-4o" }) }); + * + * const modelWithTools = chatModel.bindTools( + * [...state.tools, a2ui], + * { parallel_tool_calls: false }, + * ); + * + * Signature note: the factory takes a single `A2UIToolParams` object owned by + * `@ag-ui/a2ui-toolkit`. Every framework adapter (LG, Strands, ADK, …) shares + * that exact params shape — only the body below is framework-specific. A new + * knob added to `A2UIToolParams` reaches this adapter with no signature change. + */ + +import { tool, type ToolRuntime } from "@langchain/core/tools"; +import { SystemMessage } from "@langchain/core/messages"; +import { + A2UI_OPERATIONS_KEY, + BASIC_CATALOG_ID, + GENERATE_A2UI_ARG_DESCRIPTIONS, + RENDER_A2UI_TOOL_DEF, + buildA2UIEnvelope, + prepareA2UIRequest, + resolveA2UIToolParams, + wrapErrorEnvelope, + runA2UIGenerationWithRecovery, + type A2UIToolParams, +} from "@ag-ui/a2ui-toolkit"; + +/** Name of the render tool the A2UI middleware injects (and the subagent binds). */ +const RENDER_A2UI_TOOL_NAME = RENDER_A2UI_TOOL_DEF.function.name; + +/** + * Loose type for the subagent model. + * + * Typed as `any` (rather than `BaseChatModel`) to tolerate `@langchain/core` version + * skew between this package and the consumer — e.g. `ChatOpenAI` shipping its own + * peer-pinned core. The factory only needs `bindTools` + `stream`, which is checked + * at runtime. + */ +export type A2UISubagentModel = any; + +// Re-export the toolkit constants/types for callers that previously imported +// them from this package — keeps the public surface stable. +export { A2UI_OPERATIONS_KEY, BASIC_CATALOG_ID }; +export type { A2UIToolParams }; + +/** Tool arguments exposed to the main agent's planner. */ +interface GenerateA2UIArgs { + /** + * `"create"` to render a new surface, `"update"` to modify a surface + * previously rendered in this conversation. Defaults to `"create"`. + */ + intent?: "create" | "update"; + /** + * Required when `intent="update"`. The surface id of the prior render + * to modify. + */ + target_surface_id?: string; + /** Optional natural-language description of the changes to apply on update. */ + changes?: string; +} + +/** + * Run the structured-output subagent once and return the captured `render_a2ui` + * args — or `null` if the model produced no call. + * + * Uses `stream` (not `invoke`) so the nested `render_a2ui` tool-call arg deltas + * surface natively as the graph's `OnChatModelStream` events — which the generic + * `agent.ts` translator already turns into inner TOOL_CALL_START/ARGS/END, + * painting the surface progressively. This adapter emits NO A2UI-specific + * events: it merely consumes the stream to accumulate the final structured args + * for the recovery loop. + */ +export async function streamRenderSubagent( + modelWithTool: A2UISubagentModel, + prompt: string, + messages: unknown[], +): Promise | null> { + let accumulated: any = null; + const gen = await modelWithTool.stream([ + new SystemMessage(prompt), + ...(messages as any[]), + ]); + for await (const chunk of gen) { + // Accumulate the streamed AIMessageChunks so the final parsed tool_calls + // reconstruct even when each frame carries only an incremental arg fragment. + // (Surfacing the deltas on the wire is langgraph's job, via the + // OnChatModelStream events this stream emits.) + accumulated = accumulated === null ? chunk : accumulated.concat(chunk); + } + + const toolCalls: Array<{ name?: string; args?: Record }> = + accumulated?.tool_calls ?? []; + for (const call of toolCalls) { + if (call.name == null || call.name === RENDER_A2UI_TOOL_NAME) { + return (call.args ?? {}) as Record; + } + } + return null; +} + +/** + * Build a LangGraph tool that delegates A2UI surface generation to a subagent. + * + * The returned tool is ready to bind into a chat model alongside any other tools. + * + * @param params Shared `A2UIToolParams` (model + behavior knobs). The toolkit + * owns the shape and fills defaults via `resolveA2UIToolParams`. + */ +export function getA2UITools( + params: A2UIToolParams, +) { + // Shared: normalize knobs + fill canonical defaults (toolName, catalogId, …) + // so this adapter never re-implements default logic. A new params field + + // its default lives entirely in the toolkit. + const { + model, + guidelines, + defaultSurfaceId, + defaultCatalogId, + toolName, + toolDescription, + catalog, + recovery, + onA2UIAttempt, + } = resolveA2UIToolParams(params); + // Loose-typed locally: the generic TModel only guarantees the shape the + // toolkit needs; bindTools/stream are checked at runtime (see guard below). + const chatModel = model as A2UISubagentModel; + + return tool( + async ( + input: GenerateA2UIArgs, + runtime: ToolRuntime, unknown>, + ): Promise => { + // Defensive: a custom state schema (or a non-graph invocation) may not + // preseed `state`/`messages` — mirror the Python adapter's graceful + // degrade (`state.get("messages", [])`) instead of throwing mid-tool. + const state = (runtime.state ?? {}) as Record; + const allMessages = (state.messages as Array) ?? []; + // Strip current (unbalanced) tool call from history. + const messages = allMessages.slice(0, -1); + + // Shared: decide create/update, find prior surface, build the prompt. + const prep = prepareA2UIRequest({ + intent: input.intent, + targetSurfaceId: input.target_surface_id, + changes: input.changes, + messages, + state, + guidelines, + }); + if (prep.error) return wrapErrorEnvelope(prep.error); + + // Glue: bind the structured-output tool. + if (!chatModel.bindTools) { + return wrapErrorEnvelope("Provided model does not support bindTools"); + } + const modelWithTool = chatModel.bindTools([RENDER_A2UI_TOOL_DEF], { + tool_choice: { type: "function", function: { name: "render_a2ui" } }, + }); + + // Shared: validate→retry loop. On each retry the prompt is re-augmented + // with the prior attempt's structured errors; only a validated surface is + // committed (the middleware gate suppresses any unvalidated attempt, so a + // rejected attempt never paints). Returns a structured hard-failure + // envelope once the attempt cap is hit. + const { envelope } = await runA2UIGenerationWithRecovery({ + basePrompt: prep.prompt, + catalog, + config: recovery, + onAttempt: onA2UIAttempt, + invokeSubagent: (prompt) => + streamRenderSubagent(modelWithTool, prompt, messages), + buildEnvelope: (args) => + buildA2UIEnvelope({ + args, + isUpdate: prep.isUpdate, + targetSurfaceId: input.target_surface_id, + prior: prep.prior, + defaultSurfaceId, + defaultCatalogId, + }), + }); + return envelope; + }, + { + name: toolName, + description: toolDescription, + schema: { + type: "object", + properties: { + intent: { + type: "string", + enum: ["create", "update"], + description: GENERATE_A2UI_ARG_DESCRIPTIONS.intent, + }, + target_surface_id: { + type: "string", + description: GENERATE_A2UI_ARG_DESCRIPTIONS.target_surface_id, + }, + changes: { + type: "string", + description: GENERATE_A2UI_ARG_DESCRIPTIONS.changes, + }, + }, + } as any, + }, + ); +} diff --git a/integrations/langgraph/typescript/src/agent.ts b/integrations/langgraph/typescript/src/agent.ts index 69ec423c86..57dfd5c9df 100644 --- a/integrations/langgraph/typescript/src/agent.ts +++ b/integrations/langgraph/typescript/src/agent.ts @@ -102,6 +102,9 @@ type RunAgentExtendedInput< forwardedProps?: Omit, "input"> & { nodeName?: string; threadMetadata?: Record; + // A2UI tool-injection flag set by the A2UI middleware. Surfaced into + // ag-ui state so graphs/tools can read it directly. + injectA2UITool?: boolean | string; }; }; @@ -155,6 +158,10 @@ export class LangGraphAgent extends AbstractAgent { messagesInProcess: MessagesInProgressRecord; emittedToolCallStartIds: Set = new Set(); reasoningProcess: null | ReasoningInProgress; + // Canonical reasoning id (e.g. OpenAI `rs_…`) stashed from a text-less id + // carrier chunk, consumed when the first text delta opens the reasoning + // message. See handleReasoningEvent. + private pendingReasoningId?: string; activeRun?: RunMetadata; // Subgraph node names discovered dynamically from langgraph_checkpoint_ns private subgraphs: Set = new Set(); @@ -292,6 +299,7 @@ export class LangGraphAgent extends AbstractAgent { hasFunctionStreaming: false, modelMadeToolCall: false, }; + this.pendingReasoningId = undefined; // Reset per-run flags this.cancelRequested = false; this.cancelSent = false; @@ -452,26 +460,69 @@ export class LangGraphAgent extends AbstractAgent { (m) => m.role !== "system", ).length; - if (stateNonSystemCount > inputNonSystemCount) { - let lastUserMessage: LangGraphMessage | null = null; - // Find the first user message by working backwards from the last message - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") { - lastUserMessage = aguiMessagesToLangChain([messages[i]])[0]; - break; + // Skip regeneration detection when command.resume is set — a resume from + // interrupt is explicitly NOT a regeneration. On the second interrupt-resume + // cycle the LangGraph thread state has accumulated tool/AI messages from the + // first interrupt while the frontend's input.messages hasn't, which would + // otherwise trigger the regeneration path and ignore the resume. + if (!forwardedProps?.command?.resume && stateNonSystemCount > inputNonSystemCount) { + // A higher checkpoint count than the frontend sent does NOT always mean a + // regeneration. If an SSE stream dropped before MESSAGES_SNAPSHOT, the + // client never learned the persisted message IDs and resends the new user + // turn with a freshly generated UUID, making the checkpoint legitimately + // longer than the input even though this is a continuation. Routing that + // into regeneration calls getCheckpointByMessage with an ID that was never + // persisted, which throws "Message not found" and breaks the thread on + // every subsequent turn (#1278). + // + // Only treat the count mismatch as a regeneration when the incoming IDs are + // NOT already a subset of the checkpoint (a genuine edit) AND the last user + // message's ID actually exists in the checkpoint. Otherwise fall through to + // a normal continuation stream so the end-of-run MESSAGES_SNAPSHOT re-syncs + // the client. This continuation/regeneration decision mirrors the Python + // guard in prepare_stream. The outer count pre-filter differs only in which + // inputs enter this block (this side excludes system messages from both + // counts, Python only from the incoming side); both reach the same + // continuation-vs-regenerate decision for the recovery case. + const checkpointIds = new Set( + (agentStateMessages as LangGraphPlatformMessage[]) + .map((m) => m.id) + .filter((id): id is string => Boolean(id)), + ); + // Tool results are excluded from the comparison: connectors (e.g. + // CopilotKit) reassign tool-message IDs that won't match the checkpoint's + // placeholders. Human/AI IDs are stable and sufficient to distinguish a + // continuation from a genuine regeneration. + const incomingNonToolIds = messages + .filter((m) => m.role !== "tool" && Boolean(m.id)) + .map((m) => m.id as string); + const isContinuation = + incomingNonToolIds.length > 0 && + incomingNonToolIds.every((id) => checkpointIds.has(id)); + + if (!isContinuation) { + let lastUserMessage: LangGraphMessage | null = null; + let lastUserMessageId: string | undefined; + // Find the last user message by working backwards from the end. + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") { + lastUserMessageId = messages[i].id; + lastUserMessage = aguiMessagesToLangChain([messages[i]])[0]; + break; + } } - } - if (!lastUserMessage) { - return this.subscriber.error( - "No user message found in messages to regenerate", - ); + if ( + lastUserMessage && + lastUserMessageId && + checkpointIds.has(lastUserMessageId) + ) { + return this.prepareRegenerateStream( + { ...input, messageCheckpoint: lastUserMessage }, + streamMode, + ); + } } - - return this.prepareRegenerateStream( - { ...input, messageCheckpoint: lastUserMessage }, - streamMode, - ); } this.activeRun!.graphInfo = await this.client.assistants.getGraph( this.assistant.assistant_id, @@ -513,8 +564,10 @@ export class LangGraphAgent extends AbstractAgent { schemaKeys: this.activeRun!.schemaKeys, }); } - // @ts-ignore - const { command, ...restProps } = forwardedProps; + // forwardedProps is optional on the input; the SSE-drop recovery now reaches + // this continuation path (instead of returning early via regenerate), so guard + // against an undefined value here rather than throwing on destructure. + const { command, ...restProps } = forwardedProps ?? {}; if (command?.resume && typeof command.resume === "string") { try { command.resume = JSON.parse(command.resume); @@ -1527,7 +1580,19 @@ export class LangGraphAgent extends AbstractAgent { } handleReasoningEvent(reasoningData: LangGraphReasoning) { - if (!reasoningData || !reasoningData.type || !reasoningData.text) { + if (!reasoningData || !reasoningData.type) { + return; + } + + // A text-less chunk is still meaningful when it carries the provider's + // canonical reasoning id (the `response.output_item.added` / + // `…summary_part.added` chunks): stash the id so the first text delta + // opens the reasoning message under it, WITHOUT opening a message here — + // a summary-less (store=true) reasoning item must keep rendering nothing. + if (!reasoningData.text) { + if (reasoningData.id) { + this.pendingReasoningId = reasoningData.id; + } return; } @@ -1551,8 +1616,13 @@ export class LangGraphAgent extends AbstractAgent { } if (!this.reasoningProcess) { - // No thinking step yet. Start a new one - const messageId = randomUUID(); + // No thinking step yet. Start a new one. Prefer the provider's + // canonical reasoning id (e.g. OpenAI `rs_…`) when the stream carried + // one: the snapshot converter re-emits this same reasoning under that + // id, and only a matching id lets the client reconcile the streamed + // copy with the snapshot copy instead of rendering both. + const messageId = reasoningData.id ?? this.pendingReasoningId ?? randomUUID(); + this.pendingReasoningId = undefined; this.dispatchEvent({ type: EventType.REASONING_START, messageId, @@ -1837,14 +1907,24 @@ export class LangGraphAgent extends AbstractAgent { return [...acc, mappedTool]; }, []); + // Surface the A2UI tool-injection flag (set by the A2UI middleware via + // forwardedProps.injectA2UITool) into ag-ui state so graphs/tools can read + // it directly from state regardless of run mode. TS forwardedProps keys are + // not snake-cased, so the original camelCase key is used as-is. + const injectA2UITool = input.forwardedProps?.injectA2UITool; + const agUiState: StateEnrichment["ag-ui"] = { + tools: langGraphTools, + context: input.context, + }; + if (injectA2UITool !== undefined) { + agUiState.inject_a2ui_tool = injectA2UITool; + } + return { ...state, messages: newMessages, tools: langGraphTools, - "ag-ui": { - tools: langGraphTools, - context: input.context, - }, + "ag-ui": agUiState, copilotkit: { ...(state as any).copilotkit, actions: langGraphTools, diff --git a/integrations/langgraph/typescript/src/index.ts b/integrations/langgraph/typescript/src/index.ts index 5bea62b807..4ca4383fc5 100644 --- a/integrations/langgraph/typescript/src/index.ts +++ b/integrations/langgraph/typescript/src/index.ts @@ -1,4 +1,20 @@ import { HttpAgent } from "@ag-ui/client"; export * from './agent' +export { + getA2UITools, + A2UI_OPERATIONS_KEY, + BASIC_CATALOG_ID, + type A2UIToolParams, + type A2UISubagentModel, +} from './a2ui-tool' +// Re-export the toolkit types consumers need to type the shared params object +// and its callbacks (e.g. `onA2UIAttempt`) without depending on the toolkit +// package directly. +export type { + A2UIGuidelines, + A2UIRecoveryConfig, + A2UIValidationCatalog, + A2UIAttemptRecord, +} from '@ag-ui/a2ui-toolkit' export class LangGraphHttpAgent extends HttpAgent {} \ No newline at end of file diff --git a/integrations/langgraph/typescript/src/message-conversion.test.ts b/integrations/langgraph/typescript/src/message-conversion.test.ts index 4f67474a8a..8aed2935d8 100644 --- a/integrations/langgraph/typescript/src/message-conversion.test.ts +++ b/integrations/langgraph/typescript/src/message-conversion.test.ts @@ -4,9 +4,29 @@ */ import { Message as LangGraphMessage } from "@langchain/langgraph-sdk"; -import { Message } from "@ag-ui/client"; +import { Message, ReasoningMessage } from "@ag-ui/client"; import { aguiMessagesToLangChain, langchainMessagesToAgui } from "./utils"; +// Runtime shape of a reasoning content block on a LangChain assistant message +// (not part of the LangGraph SDK's typed content union). +type ReasoningBlock = { + type?: string; + id?: string; + text?: string; + encrypted_content?: string; + summary?: { text?: string }[]; +}; + +// The LangGraph SDK's MessageContent type models only string | (text|image) +// blocks, so a reasoning content block has no place in it. These two helpers +// centralize the single unavoidable cast at that boundary — building a fixture +// AIMessage whose content carries reasoning blocks, and reading those blocks +// back out — so the test bodies stay cast-free. +const aiMessageWithBlocks = (id: string, content: unknown[]): LangGraphMessage => + ({ id, type: "ai", content }) as unknown as LangGraphMessage; +const contentBlocksOf = (message: LangGraphMessage): ReasoningBlock[] => + message.content as unknown as ReasoningBlock[]; + describe("Message Conversion - All Types", () => { describe("aguiMessagesToLangChain", () => { it("should convert user message", () => { @@ -78,10 +98,10 @@ describe("Message Conversion - All Types", () => { expect(result[2].type).toBe("human"); }); - it("should drop reasoning messages (display-only)", () => { - // Reasoning content already lives inside the assistant AIMessage's - // content blocks at the LangChain layer; emitting a separate LangGraph - // message would duplicate context on the next turn. + it("should fold reasoning messages onto the adjacent assistant (not drop)", () => { + // Reasoning belongs as a content block ON the assistant AIMessage — not a + // standalone message (would duplicate context), but not dropped either + // (the model would lose its chain-of-thought on a stateless turn). const msgs: Message[] = [ { id: "u1", role: "user", content: "Hi" }, { id: "r1", role: "reasoning", content: "thinking..." }, @@ -91,6 +111,9 @@ describe("Message Conversion - All Types", () => { expect(result).toHaveLength(2); expect(result[0].type).toBe("human"); expect(result[1].type).toBe("ai"); + const reasoningBlocks = contentBlocksOf(result[1]).filter((b) => b.type === "reasoning"); + expect(reasoningBlocks).toHaveLength(1); + expect(reasoningBlocks[0].id).toBe("r1"); }); it("should drop developer messages (handled by agent system prompt)", () => { @@ -279,4 +302,161 @@ describe("Message Conversion - All Types", () => { expect(back[0].toolCallId).toBe("tc1"); }); }); + + // Reasoning must survive AG-UI <-> LangChain conversion losslessly so a + // stateless client can hand a reasoning model back its own chain-of-thought. + describe("reasoning round-trip", () => { + it("should fold a reasoning message onto the adjacent assistant message", () => { + const msgs: Message[] = [ + { id: "u1", role: "user", content: "Hi" }, + { id: "rs_abc", role: "reasoning", content: "step 1; step 2", encryptedValue: "ENC123" }, + { id: "a1", role: "assistant", content: "Hello" }, + ]; + const result = aguiMessagesToLangChain(msgs); + + expect(result).toHaveLength(2); // reasoning folded in, not standalone + expect(result[0].type).toBe("human"); + expect(result[1].type).toBe("ai"); + const blocks = contentBlocksOf(result[1]); + const reasoningBlocks = blocks.filter((b) => b.type === "reasoning"); + expect(reasoningBlocks).toHaveLength(1); + expect(reasoningBlocks[0].id).toBe("rs_abc"); + expect(reasoningBlocks[0].encrypted_content).toBe("ENC123"); + expect(blocks.some((b) => b.type === "text" && b.text === "Hello")).toBe(true); + }); + + it("should emit a reasoning message for an AI reasoning content block", () => { + const msg = aiMessageWithBlocks("a1", [ + { type: "reasoning", id: "rs_abc", summary: [{ type: "summary_text", text: "step 1; step 2" }], encrypted_content: "ENC123" }, + { type: "text", text: "Hello" }, + ]); + const result = langchainMessagesToAgui([msg]); + + expect(result).toHaveLength(2); + const reasoning = result[0] as ReasoningMessage; + expect(reasoning.role).toBe("reasoning"); + expect(reasoning.id).toBe("rs_abc"); + expect(reasoning.content).toBe("step 1; step 2"); + expect(reasoning.encryptedValue).toBe("ENC123"); + expect(result[1].role).toBe("assistant"); + expect(result[1].content).toBe("Hello"); + }); + + it("should preserve a reasoning block that carries only an id (store=true)", () => { + // Real OpenAI Responses (store=true) persists reasoning as just an rs_ id + // with empty summary; the id is the round-trip handle. + const msg = aiMessageWithBlocks("a1", [ + { type: "reasoning", id: "rs_only", summary: [], content: [] }, + { type: "text", text: "Done." }, + ]); + const agui = langchainMessagesToAgui([msg]); + const reasoning = agui.filter((m) => m.role === "reasoning"); + expect(reasoning).toHaveLength(1); + expect(reasoning[0].id).toBe("rs_only"); + + const back = aguiMessagesToLangChain(agui); + const blocks = contentBlocksOf(back[0]).filter((b) => b.type === "reasoning"); + expect(blocks).toHaveLength(1); + expect(blocks[0].id).toBe("rs_only"); + }); + + it("should round-trip reasoning losslessly (langchain -> agui -> langchain)", () => { + const original = aiMessageWithBlocks("a1", [ + { type: "reasoning", id: "rs_abc", summary: [{ type: "summary_text", text: "because X" }], encrypted_content: "ENC123" }, + { type: "text", text: "The answer is 42." }, + ]); + const agui = langchainMessagesToAgui([original]); + const back = aguiMessagesToLangChain(agui); + + expect(back).toHaveLength(1); + const allBlocks = contentBlocksOf(back[0]); + const blocks = allBlocks.filter((b) => b.type === "reasoning"); + expect(blocks).toHaveLength(1); + expect(blocks[0].id).toBe("rs_abc"); + expect(blocks[0].encrypted_content).toBe("ENC123"); + // The summary text and the assistant's own text must survive too. + const summaryText = (blocks[0].summary ?? []).map((s) => s.text).join(""); + expect(summaryText).toContain("because X"); + expect(allBlocks.some((b) => b.type === "text" && b.text === "The answer is 42.")).toBe(true); + }); + + it("should preserve every part of a multi-part summary on round-trip", () => { + const original = aiMessageWithBlocks("a1", [ + { type: "reasoning", id: "rs_multi", summary: [{ text: "first part" }, { text: "second part" }] }, + { type: "text", text: "Answer." }, + ]); + const back = aguiMessagesToLangChain(langchainMessagesToAgui([original])); + const block = contentBlocksOf(back[0]).find((b) => b.type === "reasoning")!; + const text = (block.summary ?? []).map((s) => s.text).join(""); + expect(text).toContain("first part"); + expect(text).toContain("second part"); + }); + + it("should give multiple id-less reasoning blocks distinct ids", () => { + const msg = aiMessageWithBlocks("a1", [ + { type: "reasoning", summary: [{ text: "alpha" }] }, + { type: "reasoning", summary: [{ text: "beta" }] }, + { type: "text", text: "Done." }, + ]); + const reasoning = langchainMessagesToAgui([msg]).filter((m) => m.role === "reasoning"); + expect(reasoning).toHaveLength(2); + expect(reasoning[0].id).not.toBe(reasoning[1].id); + }); + + it("should fold two buffered reasoning messages onto one assistant", () => { + const msgs: Message[] = [ + { id: "rs_1", role: "reasoning", content: "first" }, + { id: "rs_2", role: "reasoning", content: "second" }, + { id: "a1", role: "assistant", content: "Hello" }, + ]; + const result = aguiMessagesToLangChain(msgs); + expect(result).toHaveLength(1); + const ids = contentBlocksOf(result[0]).filter((b) => b.type === "reasoning").map((b) => b.id); + expect(ids).toEqual(["rs_1", "rs_2"]); + }); + + it("should drop reasoning that is not immediately followed by an assistant", () => { + // No assistant to attach to; materializing standalone loops under + // add_messages, so the drop is deliberate. Lock in the behavior. + const trailing = aguiMessagesToLangChain([ + { id: "u1", role: "user", content: "Hi" }, + { id: "rs_x", role: "reasoning", content: "orphan" }, + ]); + expect(trailing.map((m) => m.type)).toEqual(["human"]); + + const followedByUser = aguiMessagesToLangChain([ + { id: "rs_y", role: "reasoning", content: "orphan" }, + { id: "u1", role: "user", content: "Hi" }, + ]); + expect(followedByUser.map((m) => m.type)).toEqual(["human"]); + }); + }); + + // Tool-call argument handling must match the Python converter (no crash on + // empty arguments; no `undefined` emitted for missing args). + describe("tool-call argument robustness", () => { + it("should not throw on an assistant tool call with empty arguments", () => { + const msg: Message = { + id: "a1", + role: "assistant", + content: "", + toolCalls: [{ id: "tc1", type: "function", function: { name: "noargs", arguments: "" } }], + }; + const result = aguiMessagesToLangChain([msg]); + const ai = result[0] as { tool_calls?: { args: unknown }[] }; + expect(ai.tool_calls?.[0].args).toEqual({}); + }); + + it("should emit \"{}\" (not undefined) for a tool call with no args", () => { + const msg = { + id: "a1", + type: "ai", + content: "", + tool_calls: [{ id: "tc1", name: "noargs" }], + } as unknown as LangGraphMessage; + const result = langchainMessagesToAgui([msg]); + const assistant = result[0] as { toolCalls?: { function: { arguments: string } }[] }; + expect(assistant.toolCalls?.[0].function.arguments).toBe("{}"); + }); + }); }); diff --git a/integrations/langgraph/typescript/src/reasoning-content.test.ts b/integrations/langgraph/typescript/src/reasoning-content.test.ts index 511cbc1873..0ec9932448 100644 --- a/integrations/langgraph/typescript/src/reasoning-content.test.ts +++ b/integrations/langgraph/typescript/src/reasoning-content.test.ts @@ -5,6 +5,8 @@ */ import { resolveReasoningContent, resolveEncryptedReasoningContent } from "./utils"; +import { LangGraphAgent } from "./agent"; +import { EventType } from "@ag-ui/client"; describe("resolveReasoningContent", () => { it("should handle Anthropic old format (thinking)", () => { @@ -213,3 +215,164 @@ describe("resolveEncryptedReasoningContent", () => { ).toBeNull(); }); }); + +// ─── Canonical reasoning id (snapshot reconciliation) ──────────────────────── +// +// Since reasoning round-trips through MESSAGES_SNAPSHOT under the provider's +// canonical block id (e.g. OpenAI `rs_…`), the streamed reasoning message must +// open under that same id or the client renders the reasoning twice (the +// langgraph-python dojo e2e strict-mode failure). The canonical id arrives on +// the `response.reasoning_summary_part.added` chunk (empty text, id set). +describe("resolveReasoningContent canonical id", () => { + it("surfaces the empty-text summary_part.added chunk and extracts the id", () => { + const eventData = { + chunk: { + content: [{ + type: "reasoning", + id: "rs-canonical", + summary: [{ index: 0, type: "summary_text", text: "" }], + index: 0, + }], + }, + }; + const result = resolveReasoningContent(eventData); + expect(result).not.toBeNull(); + expect(result!.text).toBe(""); + expect(result!.id).toBe("rs-canonical"); + expect(result!.index).toBe(0); + }); + + it("does not invent an id on text delta chunks", () => { + const eventData = { + chunk: { + content: [{ + type: "reasoning", + summary: [{ index: 0, type: "summary_text", text: "Because X" }], + index: 0, + }], + }, + }; + const result = resolveReasoningContent(eventData); + expect(result!.text).toBe("Because X"); + expect(result!.id).toBeUndefined(); + }); + + it("attaches the id when text and id are both present", () => { + const eventData = { + chunk: { + content: [{ + type: "reasoning", + id: "rs-canonical", + summary: [{ index: 0, type: "summary_text", text: "Hi" }], + }], + }, + }; + const result = resolveReasoningContent(eventData); + expect(result!.text).toBe("Hi"); + expect(result!.id).toBe("rs-canonical"); + }); + + it("surfaces the output_item.added shape (id, empty summary) as a text-less id carrier", () => { + // The only id carrier observed on the LangGraph Platform wire. + const eventData = { + chunk: { + content: [{ type: "reasoning", id: "rs-canonical", summary: [], index: 0 }], + }, + }; + const result = resolveReasoningContent(eventData); + expect(result).not.toBeNull(); + expect(result!.text).toBe(""); + expect(result!.id).toBe("rs-canonical"); + }); + + it("drops empty-summary items without an id", () => { + const eventData = { + chunk: { content: [{ type: "reasoning", summary: [], index: 0 }] }, + }; + expect(resolveReasoningContent(eventData)).toBeNull(); + }); + + it("drops the part.added shape when its id is null (platform wire shape)", () => { + const eventData = { + chunk: { + content: [{ + type: "reasoning", + id: null, + summary: [{ index: 0, type: "summary_text", text: "" }], + index: 0, + }], + }, + }; + expect(resolveReasoningContent(eventData)).toBeNull(); + }); + + it("does not reuse the item id for non-first summary parts", () => { + const eventData = { + chunk: { + content: [{ + type: "reasoning", + id: "rs-canonical", + summary: [{ index: 1, type: "summary_text", text: "" }], + index: 0, + }], + }, + }; + const result = resolveReasoningContent(eventData); + expect(result).not.toBeNull(); + expect(result!.index).toBe(1); + expect(result!.id).toBeUndefined(); + }); +}); + +describe("handleReasoningEvent canonical id", () => { + function buildAgent() { + const agent = new LangGraphAgent({ + graphId: "test-graph", + deploymentUrl: "http://localhost:8000", + }); + const dispatched: any[] = []; + (agent as any).dispatchEvent = (event: any) => { + dispatched.push(event); + return true; + }; + return { agent, dispatched }; + } + + it("stashes the id from a text-less carrier without emitting anything", () => { + const { agent, dispatched } = buildAgent(); + agent.handleReasoningEvent({ type: "text", text: "", index: 0, id: "rs-canonical" }); + expect(dispatched).toHaveLength(0); + }); + + it("opens REASONING_START under the stashed canonical id on the first text delta", () => { + const { agent, dispatched } = buildAgent(); + agent.handleReasoningEvent({ type: "text", text: "", index: 0, id: "rs-canonical" }); + agent.handleReasoningEvent({ type: "text", text: "Because X", index: 0 }); + + const starts = dispatched.filter((e) => e.type === EventType.REASONING_START); + const contents = dispatched.filter( + (e) => e.type === EventType.REASONING_MESSAGE_CONTENT, + ); + expect(starts).toHaveLength(1); + expect(starts[0].messageId).toBe("rs-canonical"); + expect(contents).toHaveLength(1); + expect(contents[0].messageId).toBe("rs-canonical"); + expect(contents[0].delta).toBe("Because X"); + }); + + it("falls back to a random id when the stream carries none", () => { + const { agent, dispatched } = buildAgent(); + agent.handleReasoningEvent({ type: "text", text: "thinking…", index: 0 }); + + const starts = dispatched.filter((e) => e.type === EventType.REASONING_START); + expect(starts).toHaveLength(1); + expect(starts[0].messageId).toBeTruthy(); + expect(starts[0].messageId).not.toBe("rs-canonical"); + }); + + it("still drops chunks with neither text nor id", () => { + const { agent, dispatched } = buildAgent(); + agent.handleReasoningEvent({ type: "text", text: "", index: 0 }); + expect(dispatched).toHaveLength(0); + }); +}); diff --git a/integrations/langgraph/typescript/src/resume-skips-regeneration.test.ts b/integrations/langgraph/typescript/src/resume-skips-regeneration.test.ts new file mode 100644 index 0000000000..8a088520cf --- /dev/null +++ b/integrations/langgraph/typescript/src/resume-skips-regeneration.test.ts @@ -0,0 +1,216 @@ +/** + * Tests for the resume-skips-regeneration fix. + * + * The bug: prepareStream's regeneration detection + * (stateNonSystemCount > inputNonSystemCount) + * runs BEFORE the command.resume check. On the 2nd interrupt-resume cycle, + * the LangGraph thread state has accumulated tool/AI messages from the first + * interrupt while the frontend's input.messages hasn't — triggering the + * regeneration path, which ignores command.resume and restarts the graph + * fresh. The graph never re-hits interrupt(), no CUSTOM/on_interrupt event + * is emitted, and the frontend's useInterrupt never sees the second interrupt. + * + * The fix: skip regeneration detection when forwardedProps.command.resume is + * set. A resume is explicitly NOT a regeneration. + * + * NOTE: Same constraint as interrupt-handling.test.ts — LangGraphAgent can't + * be instantiated in isolation (requires @ag-ui/client protoc). We test the + * decision logic directly. This MUST stay in sync with agent.ts ~line 455: + * if (!forwardedProps?.command?.resume && stateNonSystemCount > inputNonSystemCount) + */ + +import { describe, it, expect } from "vitest"; + +interface LangGraphPlatformMessage { + id: string; + type: string; + content?: string; +} + +interface AgUiMessage { + id: string; + role: string; + content?: string; +} + +/** + * Mirrors the regeneration decision logic from agent.ts prepareStream. + * Returns true when the adapter WOULD enter the regeneration path. + */ +function shouldRegenerate(params: { + agentStateMessages: LangGraphPlatformMessage[]; + inputMessages: AgUiMessage[]; + commandResume: unknown; +}): boolean { + const { agentStateMessages, inputMessages, commandResume } = params; + + const stateNonSystemCount = agentStateMessages.filter( + (m) => m.type !== "system", + ).length; + const inputNonSystemCount = inputMessages.filter( + (m) => m.role !== "system", + ).length; + + // Must match agent.ts: + // if (!forwardedProps?.command?.resume && stateNonSystemCount > inputNonSystemCount) + return !commandResume && stateNonSystemCount > inputNonSystemCount; +} + +describe("Resume skips regeneration detection", () => { + // Simulate 2nd interrupt-resume: thread state has 5 messages (user + ai + tool_call + // + tool_result + ai), frontend only sent 2 (user + ai from first round). + const threadStateMessages: LangGraphPlatformMessage[] = [ + { id: "1", type: "human", content: "Schedule a meeting" }, + { id: "2", type: "ai", content: "Sure, let me check calendars" }, + { id: "3", type: "tool", content: '{"available": ["3pm","4pm"]}' }, + { id: "4", type: "ai", content: "Pick a time" }, + { id: "5", type: "human", content: "3pm please" }, + ]; + + const frontendMessages: AgUiMessage[] = [ + { id: "1", role: "user", content: "Schedule a meeting" }, + { id: "5", role: "user", content: "3pm please" }, + ]; + + it("should NOT regenerate when command.resume is set (the bug fix)", () => { + expect( + shouldRegenerate({ + agentStateMessages: threadStateMessages, + inputMessages: frontendMessages, + commandResume: { action: "pick_time", value: "3pm" }, + }), + ).toBe(false); + }); + + it("should NOT regenerate when command.resume is a string", () => { + expect( + shouldRegenerate({ + agentStateMessages: threadStateMessages, + inputMessages: frontendMessages, + commandResume: '{"action":"pick_time","value":"3pm"}', + }), + ).toBe(false); + }); + + it("SHOULD regenerate when command.resume is NOT set and state has more messages", () => { + expect( + shouldRegenerate({ + agentStateMessages: threadStateMessages, + inputMessages: frontendMessages, + commandResume: undefined, + }), + ).toBe(true); + }); + + it("SHOULD regenerate when command.resume is null and state has more messages", () => { + expect( + shouldRegenerate({ + agentStateMessages: threadStateMessages, + inputMessages: frontendMessages, + commandResume: null, + }), + ).toBe(true); + }); + + it("should NOT regenerate when message counts are equal (regardless of resume)", () => { + const equalMessages: LangGraphPlatformMessage[] = [ + { id: "1", type: "human", content: "Hello" }, + ]; + const equalInput: AgUiMessage[] = [ + { id: "1", role: "user", content: "Hello" }, + ]; + + expect( + shouldRegenerate({ + agentStateMessages: equalMessages, + inputMessages: equalInput, + commandResume: undefined, + }), + ).toBe(false); + }); + + it("should NOT regenerate when input has MORE messages than state", () => { + const smallState: LangGraphPlatformMessage[] = [ + { id: "1", type: "human", content: "Hello" }, + ]; + const bigInput: AgUiMessage[] = [ + { id: "1", role: "user", content: "Hello" }, + { id: "2", role: "assistant", content: "Hi there" }, + { id: "3", role: "user", content: "How are you?" }, + ]; + + expect( + shouldRegenerate({ + agentStateMessages: smallState, + inputMessages: bigInput, + commandResume: undefined, + }), + ).toBe(false); + }); + + it("should filter system messages from both sides", () => { + // 3 state messages but 1 is system → 2 non-system + const stateWithSystem: LangGraphPlatformMessage[] = [ + { id: "sys", type: "system", content: "Context injection" }, + { id: "1", type: "human", content: "Hello" }, + { id: "2", type: "ai", content: "Hi" }, + ]; + // 2 input messages but 1 is system → 1 non-system + // stateNonSystemCount (2) > inputNonSystemCount (1) → would regenerate + const inputWithSystem: AgUiMessage[] = [ + { id: "sys", role: "system", content: "System prompt" }, + { id: "1", role: "user", content: "Hello" }, + ]; + + // Without resume: regenerates + expect( + shouldRegenerate({ + agentStateMessages: stateWithSystem, + inputMessages: inputWithSystem, + commandResume: undefined, + }), + ).toBe(true); + + // With resume: skips regeneration + expect( + shouldRegenerate({ + agentStateMessages: stateWithSystem, + inputMessages: inputWithSystem, + commandResume: "some_value", + }), + ).toBe(false); + }); + + it("should handle empty resume object as truthy (command.resume = {})", () => { + // An empty object is truthy in JS — if the graph sends resume: {}, it's still a resume + expect( + shouldRegenerate({ + agentStateMessages: threadStateMessages, + inputMessages: frontendMessages, + commandResume: {}, + }), + ).toBe(false); + }); + + it("should handle resume = false as falsy (edge case)", () => { + // command.resume = false is falsy — should allow regeneration + expect( + shouldRegenerate({ + agentStateMessages: threadStateMessages, + inputMessages: frontendMessages, + commandResume: false, + }), + ).toBe(true); + }); + + it("should handle resume = 0 as falsy (edge case)", () => { + // command.resume = 0 is falsy — should allow regeneration + expect( + shouldRegenerate({ + agentStateMessages: threadStateMessages, + inputMessages: frontendMessages, + commandResume: 0, + }), + ).toBe(true); + }); +}); diff --git a/integrations/langgraph/typescript/src/types.ts b/integrations/langgraph/typescript/src/types.ts index d3945a9b52..a49c0150ed 100644 --- a/integrations/langgraph/typescript/src/types.ts +++ b/integrations/langgraph/typescript/src/types.ts @@ -34,7 +34,10 @@ export interface StateEnrichment { tools: LangGraphToolWithName[]; 'ag-ui': { tools: LangGraphToolWithName[]; - context: RunAgentInput['context'] + context: RunAgentInput['context']; + // A2UI tool-injection flag forwarded by the A2UI middleware + // (forwardedProps.injectA2UITool). Present only when the middleware sets it. + inject_a2ui_tool?: boolean | string; } } @@ -134,4 +137,9 @@ export interface LangGraphReasoning { text: string; index: number; signature?: string; + // The provider's canonical id for the reasoning item (e.g. OpenAI + // `rs_…`), when the stream carries one. Used as the AG-UI reasoning + // message id so the streamed message reconciles with the snapshot copy + // emitted under the same id. + id?: string; } diff --git a/integrations/langgraph/typescript/src/utils.test.ts b/integrations/langgraph/typescript/src/utils.test.ts index acdb508a9d..11e60786a2 100644 --- a/integrations/langgraph/typescript/src/utils.test.ts +++ b/integrations/langgraph/typescript/src/utils.test.ts @@ -12,8 +12,9 @@ import { AudioInputContent, VideoInputContent, DocumentInputContent, + InputContent, } from "@ag-ui/client"; -import { aguiMessagesToLangChain, langchainMessagesToAgui } from "./utils"; +import { aguiMessagesToLangChain, langchainMessagesToAgui, AGUI_TYPE_KEY } from "./utils"; describe("Multimodal Message Conversion", () => { describe("aguiMessagesToLangChain", () => { @@ -379,4 +380,106 @@ describe("Multimodal Message Conversion", () => { expect(content[0].type).toBe("text"); }); }); + + describe("Metadata + media type round-trip", () => { + it("forward preserves metadata and tags type", () => { + const aguiMessage: UserMessage = { + id: "fwd-image", + role: "user", + content: [ + { + type: "image", + source: { type: "url", value: "https://example.com/photo.jpg" }, + metadata: { alt: "a cat", id: 42 }, + } as ImageInputContent, + ], + }; + + const lcMessages = aguiMessagesToLangChain([aguiMessage]); + + const block = (lcMessages[0].content as Array)[0]; + expect(block.type).toBe("image_url"); + expect(block.metadata).toEqual({ alt: "a cat", id: 42, [AGUI_TYPE_KEY]: "image" }); + }); + + it("forward embeds __agui_type in metadata for non-image media", () => { + const aguiMessage: UserMessage = { + id: "fwd-audio", + role: "user", + content: [ + { + type: "audio", + source: { type: "url", value: "https://example.com/a.mp3" }, + metadata: { duration: 12 }, + } as AudioInputContent, + ], + }; + + const lcMessages = aguiMessagesToLangChain([aguiMessage]); + + const block = (lcMessages[0].content as Array)[0]; + expect(block.type).toBe("image_url"); + expect(block.metadata).toEqual({ duration: 12, [AGUI_TYPE_KEY]: "audio" }); + }); + + it("reverse restores type + metadata from a tagged block", () => { + const lcMessage: LangGraphMessage = { + id: "rev-tagged", + type: "human", + content: [ + { + type: "image_url", + image_url: { url: "https://example.com/v.mp4" }, + metadata: { fps: 30, [AGUI_TYPE_KEY]: "video" }, + }, + ] as any, + }; + + const aguiMessages = langchainMessagesToAgui([lcMessage]); + + const part = (aguiMessages[0].content as Array)[0]; + expect(part.type).toBe("video"); + expect(part.source).toEqual({ type: "url", value: "https://example.com/v.mp4" }); + expect(part.metadata).toEqual({ fps: 30 }); + }); + + it("reverse falls back to image for untagged blocks", () => { + const lcMessage: LangGraphMessage = { + id: "rev-untagged", + type: "human", + content: [ + { type: "image_url", image_url: { url: "https://example.com/x.jpg" } }, + ] as any, + }; + + const aguiMessages = langchainMessagesToAgui([lcMessage]); + + const part = (aguiMessages[0].content as Array)[0]; + expect(part.type).toBe("image"); + expect(part.metadata).toBeUndefined(); + }); + + it.each(["image", "audio", "video", "document"] as const)( + "round-trips %s through LangChain preserving type, source, and metadata", + (mediaType) => { + const source = { type: "data", value: "ZGF0YQ==", mimeType: `${mediaType}/x` } as const; + const metadata = { kind: mediaType, n: 7 }; + const original: UserMessage = { + id: `rt-${mediaType}`, + role: "user", + content: [{ type: mediaType, source, metadata } as InputContent], + }; + + const lcMessages = aguiMessagesToLangChain([original]); + const roundTripped = langchainMessagesToAgui([ + { id: original.id, type: "human", content: lcMessages[0].content } as LangGraphMessage, + ]); + + const part = (roundTripped[0].content as Array)[0]; + expect(part.type).toBe(mediaType); + expect(part.source).toEqual(source); + expect(part.metadata).toEqual(metadata); + } + ); + }); }); diff --git a/integrations/langgraph/typescript/src/utils.ts b/integrations/langgraph/typescript/src/utils.ts index be56fd2561..13a45e1696 100644 --- a/integrations/langgraph/typescript/src/utils.ts +++ b/integrations/langgraph/typescript/src/utils.ts @@ -2,6 +2,7 @@ import { Message as LangGraphMessage } from "@langchain/langgraph-sdk"; import { State, SchemaKeys, LangGraphReasoning } from "./types"; import { Message, + ReasoningMessage, ToolCall, TextInputContent, ImageInputContent, @@ -38,7 +39,34 @@ export function getStreamPayloadInput({ return input; } -const MEDIA_CONTENT_TYPES = new Set(["image", "audio", "video", "document"]); +const MEDIA_CONTENT_TYPES = new Set(["image", "audio", "video", "document"] as const); +type MediaContentType = typeof MEDIA_CONTENT_TYPES extends Set ? T : never; +const DEFAULT_MEDIA_CONTENT_TYPE: MediaContentType = "image"; +export const AGUI_TYPE_KEY = "__agui_type" as const; + +/** + * Metadata carried through a LangChain content block. Survives the LangGraph + * checkpoint JSON round-trip as an inert extra key. `__agui_type` stashes the + * original AG-UI media type so the reverse converter can restore it; defaults + * to `"image"` when absent (legacy blocks written before this fix). + */ +type LangchainBlockMetadata = Record & { + [AGUI_TYPE_KEY]?: MediaContentType; +}; + +/** + * The shape carried through LangChain content blocks. `metadata` is an inert + * extra key for LangChain/model providers that survives the LangGraph + * checkpoint JSON round-trip. The original AG-UI media type is stashed inside + * metadata as `__agui_type` so the reverse converter can restore it without + * adding a separate top-level field to the block. + */ +type LangchainMultimodalBlock = { + type: string; + text?: string; + image_url?: { url: string } | string; + metadata?: LangchainBlockMetadata; +}; function mediaSourceToUrl(source: InputContentDataSource | InputContentUrlSource): string | null { if (source.type === "data") { @@ -52,12 +80,13 @@ function mediaSourceToUrl(source: InputContentDataSource | InputContentUrlSource /** * Convert LangChain's multimodal content to AG-UI format. * - * LangChain only supports `text` and `image_url` content blocks. - * `image_url` blocks are converted to `ImageInputContent` with the - * appropriate source type (data or URL). + * LangChain only supports `text` and `image_url` content blocks. `image_url` + * blocks are converted back to the original AG-UI media type when the forward + * converter tagged them with `__agui_type` (and any carried `metadata` is + * restored); untagged blocks fall back to `DEFAULT_MEDIA_CONTENT_TYPE`. */ function convertLangchainMultimodalToAgui( - content: Array<{ type: string; text?: string; image_url?: any }> + content: Array ): InputContent[] { const aguiContent: InputContent[] = []; @@ -68,14 +97,24 @@ function convertLangchainMultimodalToAgui( text: item.text, }); } else if (item.type === "image_url") { - // LangChain only uses `image_url` blocks for all media, so we always - // produce ImageInputContent here. The true media type is not recoverable. const imageUrl = typeof item.image_url === "string" ? item.image_url : item.image_url?.url; if (!imageUrl) continue; + // Restore the original media type from __agui_type in metadata, then + // strip it so the returned InputContent.metadata matches the original. + // Blocks without __agui_type fall back to image (preserves legacy behavior). + const rawMeta = item.metadata; + const restoredType: MediaContentType = rawMeta?.[AGUI_TYPE_KEY] ?? DEFAULT_MEDIA_CONTENT_TYPE; + let cleanMeta: LangchainBlockMetadata | undefined; + if (rawMeta !== undefined) { + const { [AGUI_TYPE_KEY]: _stripped, ...rest } = rawMeta; + cleanMeta = Object.keys(rest).length > 0 ? rest : undefined; + } + + let source: InputContentDataSource | InputContentUrlSource; // Parse data URLs to extract base64 data if (imageUrl.startsWith("data:")) { // Format: data:mime_type;base64,data @@ -83,25 +122,17 @@ function convertLangchainMultimodalToAgui( const mimeType = header.includes(":") ? header.split(":")[1].split(";")[0] : "image/png"; - - aguiContent.push({ - type: "image", - source: { - type: "data", - value: data || "", - mimeType, - }, - }); + source = { type: "data", value: data || "", mimeType }; } else { // Regular URL - aguiContent.push({ - type: "image", - source: { - type: "url", - value: imageUrl, - }, - }); + source = { type: "url", value: imageUrl }; } + + const restored = { type: restoredType, source } as InputContent; + if (cleanMeta !== undefined) { + (restored as { metadata?: unknown }).metadata = cleanMeta; + } + aguiContent.push(restored); } } @@ -118,8 +149,8 @@ function convertLangchainMultimodalToAgui( */ function convertAguiMultimodalToLangchain( content: InputContent[] -): Array<{ type: string; text?: string; image_url?: { url: string } }> { - const langchainContent: Array<{ type: string; text?: string; image_url?: { url: string } }> = []; +): LangchainMultimodalBlock[] { + const langchainContent: LangchainMultimodalBlock[] = []; for (const item of content) { if (item.type === "text") { @@ -132,9 +163,12 @@ function convertAguiMultimodalToLangchain( const mediaItem = item as ImageInputContent | AudioInputContent | VideoInputContent | DocumentInputContent; const url = mediaSourceToUrl(mediaItem.source); if (url) { + // Stash the original media type inside metadata as __agui_type so the + // reverse converter can restore it without a separate top-level field. langchainContent.push({ type: "image_url", image_url: { url }, + metadata: { ...(mediaItem.metadata as LangchainBlockMetadata ?? {}), [AGUI_TYPE_KEY]: mediaItem.type }, }); } else { console.warn(`[convertAguiMultimodalToLangchain] Dropping ${item.type} content: source could not be converted to URL`); @@ -167,10 +201,87 @@ function convertAguiMultimodalToLangchain( return langchainContent; } +// A reasoning content block as it appears on a LangChain assistant message +// (OpenAI Responses `responses/v1` shape). It is not part of the LangGraph SDK's +// typed content union, so it is declared here for narrowing. +interface ReasoningSummaryEntry { + type?: string; + text?: string; +} + +interface ReasoningContentBlock { + type: "reasoning"; + id?: string; + summary?: ReasoningSummaryEntry[]; + encrypted_content?: string; + // Flat-text shapes emitted by some non-OpenAI providers. + reasoning?: string; + text?: string; +} + +function isReasoningBlock(block: unknown): block is ReasoningContentBlock { + return ( + typeof block === "object" && + block !== null && + (block as { type?: unknown }).type === "reasoning" + ); +} + +// Extract the human-readable reasoning text from a reasoning content block. +function reasoningBlockSummaryText(block: ReasoningContentBlock): string { + if (Array.isArray(block.summary)) { + const parts = block.summary + .map((entry) => entry?.text) + .filter((text): text is string => Boolean(text)); + // Join multi-part summaries with a newline so the parts stay legible + // instead of being mashed together ("A\nB", not "AB"). + if (parts.length) return parts.join("\n"); + } + return block.reasoning ?? block.text ?? ""; +} + +// Turn a LangChain reasoning content block into an AG-UI ReasoningMessage, +// preserving the block id (the provider's `rs_…` handle — under store=true it is +// the only round-trip key) and any encrypted content (needed for store=false). +// Returns null only for a wholly empty block (nothing to render or round-trip). +function reasoningBlockToAguiMessage( + block: ReasoningContentBlock, + assistantId: string, + index = 0, +): ReasoningMessage | null { + const text = reasoningBlockSummaryText(block); + const encrypted = block.encrypted_content; + if (!block.id && !text && !encrypted) return null; + const message: ReasoningMessage = { + // Include the block index in the fallback id so multiple id-less reasoning + // blocks on one message don't collide on the same id. + id: String(block.id ?? `${assistantId}-reasoning-${index}`), + role: "reasoning", + content: text, + }; + if (encrypted) message.encryptedValue = encrypted; + return message; +} + +// Rebuild the LangChain reasoning content block from an AG-UI ReasoningMessage +// (inverse of reasoningBlockToAguiMessage). +function aguiReasoningMessageToBlock(message: ReasoningMessage): ReasoningContentBlock { + const block: ReasoningContentBlock = { + type: "reasoning", + id: message.id, + summary: message.content + ? [{ type: "summary_text", text: message.content }] + : [], + }; + if (message.encryptedValue) block.encrypted_content = message.encryptedValue; + return block; +} + export function langchainMessagesToAgui(messages: LangGraphMessage[]): Message[] { - return messages.map((message) => { + const out: Message[] = []; + for (const message of messages) { switch (message.type) { - case "human": + case "human": { // Handle multimodal content let userContent: string | InputContent[]; if (Array.isArray(message.content)) { @@ -179,15 +290,28 @@ export function langchainMessagesToAgui(messages: LangGraphMessage[]): Message[] userContent = stringifyIfNeeded(resolveMessageContent(message.content)); } - return { + out.push({ id: message.id!, role: "user", content: userContent, - }; + }); + break; + } case "generic": - case "ai": - const aiContent = resolveMessageContent(message.content) - return { + case "ai": { + // Surface reasoning content blocks as standalone ReasoningMessages + // placed BEFORE the assistant message (matching streaming order), so a + // client with no persistent checkpoint can round-trip them. + if (Array.isArray(message.content)) { + message.content.forEach((block, index) => { + if (isReasoningBlock(block)) { + const reasoningMsg = reasoningBlockToAguiMessage(block, message.id!, index); + if (reasoningMsg) out.push(reasoningMsg); + } + }); + } + const aiContent = resolveMessageContent(message.content); + out.push({ id: message.id!, role: "assistant", content: aiContent ? stringifyIfNeeded(aiContent) : '', @@ -196,41 +320,62 @@ export function langchainMessagesToAgui(messages: LangGraphMessage[]): Message[] type: "function", function: { name: tc.name, - arguments: JSON.stringify(tc.args), + // Default missing args to "{}" (parity with the Python side); + // JSON.stringify(undefined) would emit an invalid `undefined`. + arguments: JSON.stringify(tc.args ?? {}), }, })), - }; + }); + break; + } case "system": - return { + out.push({ id: message.id!, role: "system", content: stringifyIfNeeded(resolveMessageContent(message.content)), - }; + }); + break; case "tool": - return { + out.push({ id: message.id!, role: "tool", content: stringifyIfNeeded(resolveMessageContent(message.content)), toolCallId: message.tool_call_id, - }; + }); + break; default: throw new Error("message type returned from LangGraph is not supported."); } - }); + } + return out; } export function aguiMessagesToLangChain(messages: Message[]): LangGraphMessage[] { - return messages - // Reasoning AG-UI messages are display-only — their content already lives - // inside the corresponding assistant AIMessage's content blocks - // (langchain-openai writes them there for the Responses API). Developer - // messages are part of the agent's configured system prompt. Re-materializing - // either as standalone LangChain messages duplicates context on every turn - // and can drive the model into a tool-call loop. - .filter((message) => message.role !== "reasoning" && message.role !== "developer") - .map((message, index) => { + const out: LangGraphMessage[] = []; + // Reasoning is display-only at the AG-UI layer but lives as a content block ON + // the assistant AIMessage at the LangChain layer. To round-trip reasoning + // without loss (so a stateless client can hand the model back its own + // chain-of-thought), buffer reasoning messages and re-attach them as content + // blocks on the assistant that follows (matching streaming order). Developer + // messages stay dropped — they are configured on the agent itself. + // + // Reasoning that is NOT immediately followed by an assistant message (trailing, + // or followed by a user/tool/system message) is intentionally discarded: there + // is no assistant to attach it to, and re-materializing it as a standalone + // message causes exponential message duplication and tool-call loops under the + // add_messages reducer. The snapshot side (langchainMessagesToAgui) only ever + // emits reasoning immediately before its assistant, so this drop never affects + // a real round-trip — only hand-crafted / partial inputs. + let pendingReasoning: ReasoningContentBlock[] = []; + for (const message of messages) { switch (message.role) { - case "user": + case "reasoning": + pendingReasoning.push(aguiReasoningMessageToBlock(message)); + continue; + case "developer": + continue; + case "user": { + pendingReasoning = []; // Handle multimodal content let content: UserMessage['content']; if (typeof message.content === "string") { @@ -241,45 +386,68 @@ export function aguiMessagesToLangChain(messages: Message[]): LangGraphMessage[] content = String(message.content); } - return { + out.push({ id: message.id, role: message.role, content, type: "human", - } as LangGraphMessage; - case "assistant": - return { + } as LangGraphMessage); + break; + } + case "assistant": { + // Fold any buffered reasoning blocks onto this assistant message. + let content: string | Array; + if (pendingReasoning.length) { + const blocks: Array = [ + ...pendingReasoning, + ]; + if (message.content) blocks.push({ type: "text", text: message.content }); + content = blocks; + pendingReasoning = []; + } else { + content = message.content ?? ""; + } + out.push({ id: message.id, type: "ai", role: message.role, - content: message.content ?? "", + content, tool_calls: (message.toolCalls ?? []).map((tc: ToolCall) => ({ id: tc.id, name: tc.function.name, - args: JSON.parse(tc.function.arguments), + // Guard empty/absent arguments (parity with the Python side): + // JSON.parse("") throws and would abort the whole conversion. + args: tc.function.arguments ? JSON.parse(tc.function.arguments) : {}, type: "tool_call", })), - }; + } as LangGraphMessage); + break; + } case "system": - return { + pendingReasoning = []; + out.push({ id: message.id, role: message.role, content: message.content, type: "system", - }; + } as LangGraphMessage); + break; case "tool": - return { + pendingReasoning = []; + out.push({ content: message.content, role: message.role, type: message.role, tool_call_id: message.toolCallId, id: message.id, - }; + } as LangGraphMessage); + break; default: - console.error(`Message role ${message.role} is not implemented`); + console.error(`Message role ${(message as { role: string }).role} is not implemented`); throw new Error("message role is not supported."); } - }); + } + return out; } function stringifyIfNeeded(item: any) { @@ -317,11 +485,34 @@ export function resolveReasoningContent(eventData: any): LangGraphReasoning | nu } // OpenAI Responses API v1 format: { type: "reasoning", summary: [{ text: "..." }] } - if (block.type === 'reasoning' && block.summary?.[0]?.text) { - return { - type: 'text', - text: block.summary[0].text, - index: block.summary[0].index ?? 0, + // + // The reasoning item's canonical id (OpenAI `rs_…`) only travels on + // text-less chunks: the `response.output_item.added` chunk ({ id, + // summary: [] }) and — depending on the langchain-openai version — the + // `…summary_part.added` chunk ({ id, summary: [{ text: "" }] }). The + // `…summary_text.delta` chunks carry text but no id. Surface the id + // carriers (instead of dropping them for having no text) so the streamed + // reasoning message can adopt the canonical id — the id the snapshot + // converter emits for the same block; handleReasoningEvent stashes the id + // without opening a message, so summary-less (store=true) items still + // render nothing. Only the first summary part takes the id: later parts + // belong to the same item, and reusing its id would mint two messages + // with one id. + if (block.type === 'reasoning' && Array.isArray(block.summary)) { + if (block.summary.length === 0 && block.id) { + return { type: 'text', text: '', index: block.index ?? 0, id: String(block.id) }; + } + const part = block.summary[0]; + if (part && typeof part === 'object' && (part.text || block.id)) { + const result: LangGraphReasoning = { + type: 'text', + text: part.text ?? '', + index: part.index ?? 0, + }; + if (block.id && (part.index ?? 0) === 0) { + result.id = String(block.id); + } + return result; } } diff --git a/integrations/langroid/python/LICENSE b/integrations/langroid/python/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/langroid/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/langroid/python/pyproject.toml b/integrations/langroid/python/pyproject.toml index 90f4eaf54c..976d6a595d 100644 --- a/integrations/langroid/python/pyproject.toml +++ b/integrations/langroid/python/pyproject.toml @@ -1,25 +1,33 @@ [project] -name = "ag_ui_langroid" -version = "0.1.0" +name = "ag-ui-langroid" +version = "0.1.1" +description = "Implementation of the AG-UI protocol for Langroid." +readme = "README.md" +requires-python = ">=3.10, <3.14" +license = "MIT" +license-files = ["LICENSE"] authors = [ { name = "AG-UI Contributors" } ] -requires-python = ">=3.10, <3.14" +keywords = ["ag-ui", "langroid", "agent", "protocol", "llm", "streaming"] dependencies = [ "ag-ui-protocol>=0.1.10", "fastapi>=0.115.12", "langroid>=0.1.0", ] +[project.urls] +Repository = "https://github.com/ag-ui-protocol/ag-ui" +Homepage = "https://github.com/ag-ui-protocol/ag-ui" + [tool.ag-ui.scripts] test = "python -m unittest discover tests" -[build-system] -requires = ["uv_build>=0.8.0,<0.9"] -build-backend = "uv_build" - [dependency-groups] dev = [ "httpx>=0.28.0", ] +[build-system] +requires = ["uv_build>=0.8.0,<0.9"] +build-backend = "uv_build" diff --git a/integrations/langroid/python/tests/test_agent.py b/integrations/langroid/python/tests/test_agent.py index a5693e926d..f117489f5b 100644 --- a/integrations/langroid/python/tests/test_agent.py +++ b/integrations/langroid/python/tests/test_agent.py @@ -357,5 +357,114 @@ def llm_response(self, msg): self.assertEqual(tracking_agent.last_input, "") +class TestLangroidAgentBackendToolDemoCoupling(unittest.TestCase): + """Characterization tests pinning the hardcoded Dojo-demo backend tool + response generation in ``LangroidAgent.run``. + + The ``run`` method contains tool-name-specific natural-language response + synthesis (``get_weather``, ``render_chart``, ``generate_recipe``) that is + coupled to the AG-UI Dojo demo tools. These tests guard the current + behavior so any future decoupling/generalization can be done safely with a + regression net rather than by guesswork. See PR description for the flagged + follow-up and the exact agent.py line ranges involved. + """ + + def _run_backend_tool(self, request, handler_result, **tool_kwargs): + tool_response = FakeToolResponse(request=request, **tool_kwargs) + + class BackendAgent: + def __init__(self, response, result): + self._response = response + self._result = result + self.message_history = [] + + def llm_response(self, msg): + return self._response + + agent_impl = BackendAgent(tool_response, handler_result) + # Attach the named backend handler dynamically so it is treated as a + # backend (not frontend) tool. + setattr(agent_impl, request, lambda msg: handler_result) + + agui_agent = LangroidAgent(agent=agent_impl, name="test") + input_data = _make_input( + messages=[_make_user_message(f"call {request}")], + tools=[], # no frontend tools -> backend path + ) + events = _collect_events(agui_agent, input_data) + text = "".join( + e.delta for e in events if e.type == EventType.TEXT_MESSAGE_CONTENT + ) + return events, text + + def test_get_weather_produces_demo_specific_response(self): + weather = { + "location": "NYC", + "temperature": 72, + "conditions": "sunny", + "humidity": 40, + "wind_speed": 5, + "feels_like": 70, + } + events, text = self._run_backend_tool( + "get_weather", weather, location="NYC" + ) + + event_types = [e.type for e in events] + self.assertIn(EventType.TOOL_CALL_START, event_types) + self.assertIn(EventType.TOOL_CALL_RESULT, event_types) + # Hardcoded demo template (agent.py get_weather branch). + self.assertEqual( + text, + "The current weather in NYC is 72°F with sunny conditions. " + "The wind speed is 5 mph, and the humidity level is at 40%. " + "It feels like 70°F.", + ) + + def test_render_chart_produces_demo_specific_response(self): + # Use a ``message`` that differs from the ``chart_type``-derived + # fallback (``f"{chart_type} chart has been rendered"``) so this test + # proves the ``message`` key takes precedence rather than the code + # falling through to the default. With chart_type="pie", the fallback + # would be "pie chart has been rendered" -- the assertion below would + # fail if message were ignored. + chart = { + "chart_type": "pie", + "status": "completed", + "message": "bar chart has been rendered", + } + events, text = self._run_backend_tool("render_chart", chart) + + event_types = [e.type for e in events] + self.assertIn(EventType.TOOL_CALL_RESULT, event_types) + # Hardcoded demo template (agent.py render_chart branch): the provided + # ``message`` is honored verbatim, not the chart_type fallback. + self.assertEqual(text, "bar chart has been rendered.") + + def test_generate_recipe_produces_demo_specific_response(self): + # The generate_recipe branch (agent.py ~642-659) reads the recipe from + # the *tool args* (tool_args.get("recipe")), not the handler result, + # and selects one of four sub-templates based on whether ingredients + # and/or instructions are present. This pins the both-present branch. + recipe = { + "title": "Pancakes", + "ingredients": ["flour", "eggs"], + "instructions": ["mix", "cook"], + } + events, text = self._run_backend_tool( + "generate_recipe", {"status": "completed"}, recipe=recipe + ) + + event_types = [e.type for e in events] + self.assertIn(EventType.TOOL_CALL_RESULT, event_types) + # Hardcoded demo template (agent.py generate_recipe branch): title is + # lowercased and the ingredients-and-instructions sub-template is used. + self.assertEqual( + text, + "I created a complete pancakes recipe based on the existing " + "ingredients and instructions.", + ) + + if __name__ == "__main__": unittest.main() diff --git a/integrations/langroid/python/uv.lock b/integrations/langroid/python/uv.lock index 6e67512445..cc869dc9d7 100644 --- a/integrations/langroid/python/uv.lock +++ b/integrations/langroid/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10, <3.14" resolution-markers = [ "python_full_version >= '3.13'", @@ -24,7 +24,7 @@ wheels = [ [[package]] name = "ag-ui-langroid" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "ag-ui-protocol" }, @@ -319,12 +319,20 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db wheels = [ { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] @@ -2230,25 +2238,25 @@ wheels = [ [[package]] name = "primp" -version = "1.1.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/25/1113a87a693121f4eb18d2df3a99d8ad43984f4068e31a5765c03e4b8b96/primp-1.1.1.tar.gz", hash = "sha256:58775e74f86cc58f9abe4b1dacea399fa6367c1959e591ad9345f151ad38d259", size = 311388, upload-time = "2026-02-24T16:12:53.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/0f/027fc0394f70721c6dc5054fb3efff6479753da0b272e15b16cefba958b8/primp-1.1.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:691215c5a514a7395c1ee775cd03a94a41497941e17291e1a71f5356142c61e6", size = 3997489, upload-time = "2026-02-24T16:12:49.154Z" }, - { url = "https://files.pythonhosted.org/packages/af/ea/0f23fbfef2a550c420eaa73fd3e21176acb0ddf0d50028d8bc8d937441be/primp-1.1.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:17ace56cd24a894236121bf37d3616ec15d5299a6fa2d2a30fbbf9c22b946a03", size = 3734591, upload-time = "2026-02-24T16:12:45.629Z" }, - { url = "https://files.pythonhosted.org/packages/0a/63/c5669652446a981dd5faad8a8255e5567db5818b951dbe74e81968f672cb/primp-1.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfec08ae15f6d86b2bcaaee3358d5cc349a843c8be164502ea73658a817c5cf2", size = 3875508, upload-time = "2026-02-24T16:12:59.403Z" }, - { url = "https://files.pythonhosted.org/packages/14/79/19e4d19a445b39c930a317e4ea4d1eff07ef0661b4e7397ad425f7ff0bd8/primp-1.1.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c3cf7e93e8ff4842eee9c6d4ac47d638a5c981752b19f458877a3536c1da6671", size = 3510461, upload-time = "2026-02-24T16:12:37.908Z" }, - { url = "https://files.pythonhosted.org/packages/50/39/091282d624067958b42a087976c0da80eecc5ade03acfc732389be3af723/primp-1.1.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db6f3f18855bf25dca14f6d121d214e5c922275f49cdadd248eff28abb779edb", size = 3727644, upload-time = "2026-02-24T16:12:16.671Z" }, - { url = "https://files.pythonhosted.org/packages/33/ae/ca4e4a5d0cbd35684a228fd1f7c1425db0860a7bd74ce8f40835f6184834/primp-1.1.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d8363faadb1d07fa8ae73de6ed2ca4666b36c77ea3990714164b8ee7ab1aa1d", size = 4004689, upload-time = "2026-02-24T16:12:57.957Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ed/b3cf17bcac4914aa63cd83d763c9e347aab6e0b9285645b0015b036f914d/primp-1.1.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:302241ee447c185417e93e3a3e5a2801fdd710b1a5cc63c01a26ee7dc634e9b1", size = 3918084, upload-time = "2026-02-24T16:12:30.283Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/f563eaeb654749fa519c627b1f1ab93cf875537c56123fba507f74b647fc/primp-1.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a37ad318f1b8295d414e1c32ca407efcb92e664c5ff41f06901bd3ee03bab1fa", size = 4108648, upload-time = "2026-02-24T16:12:15.269Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b9/2df5376900c293238cf641591952979f689ea3f009195df4cce15786afb9/primp-1.1.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e46829d9d86caf18b2b40829655d470e0ce2eebb061f2ee973451b2509f1c5a2", size = 4055747, upload-time = "2026-02-24T16:12:42.925Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e9/eaaea488b4ae445059bd99559649402c77ddd9dfdda01528daa9ee11d8fe/primp-1.1.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:8ef9cb971915d2db3fbb1a512777261e5267c95d4717b18aff453f5e3dbb9bda", size = 3742046, upload-time = "2026-02-24T16:12:19.945Z" }, - { url = "https://files.pythonhosted.org/packages/0a/92/0607dd9d01840e0c007519d69cdcbb6f1358d6d7f8e739fc3359773b50d2/primp-1.1.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:1a350656142772b5d6afc0dfaf9172c69449fbfafb9b6590af7ba116d32554d7", size = 3857103, upload-time = "2026-02-24T16:12:39.338Z" }, - { url = "https://files.pythonhosted.org/packages/e5/b6/5d574a7a84afd38df03c5535a9bb1052090bd0289760dcca24188510dd09/primp-1.1.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ec71a66750befd219f29cb6ff01bc1c26671040fc76b4115bf045c85f84da041", size = 4357972, upload-time = "2026-02-24T16:12:12.159Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f3/34ba2deba36de0a6041a61c16f2097e0bd2e74114f8d85096b3911288b4c/primp-1.1.1-cp310-abi3-win32.whl", hash = "sha256:901dc1e40b99ba5925463ab120af14afb8a66f4ac7eb2cdf87aaf21047f6db39", size = 3259840, upload-time = "2026-02-24T16:12:31.762Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/fa3c17e5b6e4cff5bbdfd6bed1d0e8f81e17708dd8106906a031a2432b61/primp-1.1.1-cp310-abi3-win_amd64.whl", hash = "sha256:6bedd91451ec9ac46203ccb5c2c9925e9206e33abec7c791a2b39e3f86530bf0", size = 3596643, upload-time = "2026-02-24T16:12:21.554Z" }, - { url = "https://files.pythonhosted.org/packages/94/3d/a5b391107ba1c72dc8eb4f603c5764067449e1445438d71e093a72d5eda1/primp-1.1.1-cp310-abi3-win_arm64.whl", hash = "sha256:fd22a10164536374262e32fccbf81736b20798ac7582f159d5ffdef01a755579", size = 3606836, upload-time = "2026-02-24T16:12:28.579Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cc/4b/7efa54f38da7de8df6b70dfed173bb41a52b740b144e4be24c1172db4209/primp-1.3.1.tar.gz", hash = "sha256:b04a5941bf9c876d011c5defaf5a25be093d56e7270b8da52c9788b9df2a829a", size = 1360029, upload-time = "2026-05-23T17:39:25.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/80/c4885a783a7493e396d89a592ba19fce63ef6bd6ad47230924a884a30ec0/primp-1.3.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:27b87e6370045a0c65c0e4dfdfacbfe637387d05673ce8ddcce400263f7c27f0", size = 5123967, upload-time = "2026-05-23T17:39:08.586Z" }, + { url = "https://files.pythonhosted.org/packages/58/c1/c965cc23f96a364803d44b4331f33e4465bb6f269add37e39d0ad77ffe33/primp-1.3.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:27a8804eb9a3f641f379ee2b443591428cf85c898816e93d04d3e7b6f229ebcb", size = 4743059, upload-time = "2026-05-23T17:39:15.536Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/f4248d8d833d43fd8ba78208f2f4bf7fba7d3aec8c516090a95d18d6f550/primp-1.3.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:862974796552a51af8e276bb19c5d5e189168ab8bad216aef7ce3726a8d3b1dd", size = 5100121, upload-time = "2026-05-23T17:39:04.64Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ad/519e32e0184763e1a76c9321fdeac0bb9b30bf85746f12058feec0cc4a27/primp-1.3.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ceb24198994799706f4020a00173ba9c1b491aa9805b1e014d87946677bc3c5d", size = 4738042, upload-time = "2026-05-23T17:39:35.967Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7b/723cb40694b47ec79a142ed8492835c0ecae9fef7acbed014f04b018d1de/primp-1.3.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3298b8afcf0a88ba6622bfc18e78aeb11afbb7d5afa4774f24acf7491f54a2d", size = 5001773, upload-time = "2026-05-23T17:39:03.01Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/80a2e3bdab1c51d738b82ea210a5ab93986b443c561e792e42cae296ec10/primp-1.3.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8d38c5a6d0a863274cbcae9678f265fcdcead3c20d12d152244e88f5f2186b", size = 5334228, upload-time = "2026-05-23T17:39:24.214Z" }, + { url = "https://files.pythonhosted.org/packages/19/70/c95b8054c7d1fe2d84226ec60a5f48ce6c95a08b7c8b1702d7742082f444/primp-1.3.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96f831c78ddb5900873f51e294bf9bbb4bbfdac3a2f39ce4023f8c558d299332", size = 5157269, upload-time = "2026-05-23T17:38:48.142Z" }, + { url = "https://files.pythonhosted.org/packages/34/bb/9b66986b7ecf2eff987134cd94bde533142e3085d6f67531f1a369ceaaae/primp-1.3.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329d0c320841f65b39d80801d8bae126732b84ec1094ca17b14fda0bda1b20ff", size = 5347438, upload-time = "2026-05-23T17:39:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/5d127748d06f3c6a3367f3c4974e45b98cda61cd28ea79ef91ad3fe9e093/primp-1.3.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6c3c67670c38a03e9e8da45b212243d35afc8efa018317c46ecdce47f05329d1", size = 5264862, upload-time = "2026-05-23T17:39:20.625Z" }, + { url = "https://files.pythonhosted.org/packages/16/f3/1aac229425cac142c48418e2de9f70597161ea936543b5e3c9e7476e1921/primp-1.3.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9409a31028a8c62a609d389554ad4f5339aad075130300cd443beef0336d7179", size = 4969889, upload-time = "2026-05-23T17:39:22.412Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/a94d6e6166139c76ae42eb941328679309ca85139e8753d639657a24474c/primp-1.3.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:88ca36c2bd1b7c64b96ad07ca367d2d111ac8e9670549be5f232da8bf795d21e", size = 5082679, upload-time = "2026-05-23T17:39:28.411Z" }, + { url = "https://files.pythonhosted.org/packages/cf/61/21d297db575ed660c6aaf35c9014c1874ace45d6dcb79d1a4d3d2608bffb/primp-1.3.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:74d13800b501aa003fb05c263d38f8d61656c83a60b2951046c0fc412bc73976", size = 5605392, upload-time = "2026-05-23T17:39:38.007Z" }, + { url = "https://files.pythonhosted.org/packages/36/d6/9262a7ebb1d980a2db0cd505bb902bb3e66acd8a1cb763a4c2921f2f6a5b/primp-1.3.1-cp310-abi3-win32.whl", hash = "sha256:09ada1752629fe89d7b128beeb59cb641f404af462e24177ba36aed1cf322299", size = 4270373, upload-time = "2026-05-23T17:38:44.98Z" }, + { url = "https://files.pythonhosted.org/packages/8f/68/f0c6a60fadff0c185aef232b951a6fa4bbb64511facc48d34734db14f16f/primp-1.3.1-cp310-abi3-win_amd64.whl", hash = "sha256:c0d1e294466cd5ec7ef173eedf8df25cbdc050138d40447a906e92b8553e7765", size = 4661498, upload-time = "2026-05-23T17:39:32.213Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/232a52abc77384ac66b9c1741691dec3659b1207bb6c5e55c1e9b59d22f1/primp-1.3.1-cp310-abi3-win_arm64.whl", hash = "sha256:43304cb41cbb46f361de49faf1cbdba57f969f628c9297239c7ed8ef0cac420f", size = 4624481, upload-time = "2026-05-23T17:38:42.724Z" }, ] [[package]] diff --git a/integrations/langroid/typescript/package.json b/integrations/langroid/typescript/package.json index c3e8cb0765..eef4601aa1 100644 --- a/integrations/langroid/typescript/package.json +++ b/integrations/langroid/typescript/package.json @@ -1,5 +1,7 @@ { "name": "@ag-ui/langroid", + "//": "private: @ag-ui/langroid is currently an empty HttpAgent subclass with no added behavior — intentionally NOT published. See src/index.ts for what would make it publishable.", + "private": true, "author": "AG-UI Contributors", "version": "0.0.1", "main": "./dist/index.js", diff --git a/integrations/langroid/typescript/src/index.ts b/integrations/langroid/typescript/src/index.ts index 6f1d0daf86..b953e7d9de 100644 --- a/integrations/langroid/typescript/src/index.ts +++ b/integrations/langroid/typescript/src/index.ts @@ -3,6 +3,38 @@ * Check more about using Langroid: https://github.com/langroid/langroid */ +/** + * STATUS: UNPUBLISHED ON PURPOSE. + * + * This package is currently a no-op: `LangroidHttpAgent` is an empty subclass + * of `HttpAgent` that adds no behavior. Because it has nothing to offer over + * `@ag-ui/client`'s `HttpAgent`, it is marked `"private": true` in package.json + * and is intentionally NOT published to npm. + * + * To resolve this, pick ONE of the following: + * + * 1) MAKE IT PUBLISHABLE — give it a real capability surface. + * Mirror `@ag-ui/adk`'s `ADKAgent`, which exposes a typed `getCapabilities()` + * that fetches the agent's `/capabilities` endpoint and validates the + * response with Zod. Reference implementation: + * integrations/adk-middleware/typescript/src/index.ts + * Once this class adds equivalent Langroid-specific behavior, making it + * publishable requires BOTH removing the `"private": true` / `"//"` keys + * from package.json AND enrolling it in the release pipeline — removing + * the keys alone will NOT publish it. To enroll: add a TypeScript scope + * (e.g. `integration-langroid-ts`, mirroring sibling `integration-*-ts` + * scopes) to scripts/release/release.config.json AND add `@ag-ui/langroid` + * to nx.json `release.projects`. These two lists must stay in sync or + * scripts/release/verify-nx-release-allowlist.sh fails. + * + * 2) DELETE THIS TS PACKAGE — accept a Python-only integration shape. + * Precedent: some AG-UI integrations are Python-only — e.g. `agent-spec` + * ships a Python adapter (`ag-ui-agent-spec`) and no TS package at all. + * If Langroid follows that shape, remove this entire TS package + * (integrations/langroid/typescript) and rely on the Python integration + * (`ag_ui_langroid`) alone. + */ + import { HttpAgent } from "@ag-ui/client"; export class LangroidHttpAgent extends HttpAgent {} diff --git a/integrations/llama-index/python/examples/server/routers/backend_tool_rendering.py b/integrations/llama-index/python/examples/server/routers/backend_tool_rendering.py index c24b8d1982..fb8992d7b5 100644 --- a/integrations/llama-index/python/examples/server/routers/backend_tool_rendering.py +++ b/integrations/llama-index/python/examples/server/routers/backend_tool_rendering.py @@ -4,6 +4,7 @@ from datetime import datetime import json +import os from textwrap import dedent from zoneinfo import ZoneInfo @@ -54,6 +55,23 @@ def get_weather_condition(code: int) -> str: return conditions.get(code, "Unknown") +def _mock_weather(location: str) -> str: + """Return deterministic canned weather data for tests. + + Used when ``AG_UI_MOCK_WEATHER`` is set so e2e runs don't depend on the + live open-meteo API (which rate-limits CI's shared egress IPs). + """ + return json.dumps({ + "temperature": 21.0, + "feelsLike": 20.0, + "humidity": 65.0, + "windSpeed": 12.0, + "windGust": 18.0, + "conditions": get_weather_condition(1), + "location": location, + }) + + async def get_weather(location: str) -> str: """Get current weather for a location. @@ -64,6 +82,9 @@ async def get_weather(location: str) -> str: Dictionary with weather information including temperature, feels like, humidity, wind speed, wind gust, conditions, and location name. """ + if os.getenv("AG_UI_MOCK_WEATHER"): + return _mock_weather(location) + async with httpx.AsyncClient() as client: # Geocode the location geocoding_url = ( diff --git a/integrations/llama-index/typescript/LICENSE b/integrations/llama-index/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/llama-index/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/llama-index/typescript/package.json b/integrations/llama-index/typescript/package.json index b640b6658c..804030e14f 100644 --- a/integrations/llama-index/typescript/package.json +++ b/integrations/llama-index/typescript/package.json @@ -2,6 +2,7 @@ "name": "@ag-ui/llamaindex", "author": "Logan Markewich ", "version": "0.1.5", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/integrations/mastra/typescript/LICENSE b/integrations/mastra/typescript/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/integrations/mastra/typescript/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/integrations/mastra/typescript/examples/src/mastra/tools/weather-tool.ts b/integrations/mastra/typescript/examples/src/mastra/tools/weather-tool.ts index ab891c00a3..97380aedca 100644 --- a/integrations/mastra/typescript/examples/src/mastra/tools/weather-tool.ts +++ b/integrations/mastra/typescript/examples/src/mastra/tools/weather-tool.ts @@ -41,6 +41,21 @@ export const weatherTool = createTool({ }); const getWeather = async (location: string) => { + // Return deterministic canned weather data when AG_UI_MOCK_WEATHER is set so + // e2e runs don't depend on the live open-meteo API (which rate-limits CI's + // shared egress IPs). The dojo-e2e workflow sets this for the server process. + if (process.env.AG_UI_MOCK_WEATHER) { + return { + temperature: 21, + feelsLike: 20, + humidity: 65, + windSpeed: 12, + windGust: 18, + conditions: getWeatherCondition(1), + location, + }; + } + const geocodingUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1`; const geocodingResponse = await fetch(geocodingUrl); const geocodingData = (await geocodingResponse.json()) as GeocodingResponse; diff --git a/integrations/pydantic-ai/python/examples/server/api/backend_tool_rendering.py b/integrations/pydantic-ai/python/examples/server/api/backend_tool_rendering.py index d35cd41e10..bdee71b305 100644 --- a/integrations/pydantic-ai/python/examples/server/api/backend_tool_rendering.py +++ b/integrations/pydantic-ai/python/examples/server/api/backend_tool_rendering.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from datetime import datetime from textwrap import dedent from zoneinfo import ZoneInfo @@ -9,6 +10,24 @@ import httpx from pydantic_ai import Agent + +def _mock_weather(location: str) -> dict[str, str | float]: + """Return deterministic canned weather data for tests. + + Used when ``AG_UI_MOCK_WEATHER`` is set so e2e runs don't depend on the + live open-meteo API (which rate-limits CI's shared egress IPs). + """ + return { + "temperature": 21.0, + "feelsLike": 20.0, + "humidity": 65.0, + "windSpeed": 12.0, + "windGust": 18.0, + "conditions": get_weather_condition(1), + "location": location, + } + + agent = Agent( "openai:gpt-4o-mini", instructions=dedent( @@ -82,6 +101,9 @@ async def get_weather(location: str) -> dict[str, str | float]: Dictionary with weather information including temperature, feels like, humidity, wind speed, wind gust, conditions, and location name. """ + if os.getenv("AG_UI_MOCK_WEATHER"): + return _mock_weather(location) + async with httpx.AsyncClient() as client: # Geocode the location geocoding_url = ( diff --git a/integrations/pydantic-ai/typescript/LICENSE b/integrations/pydantic-ai/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/pydantic-ai/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/pydantic-ai/typescript/package.json b/integrations/pydantic-ai/typescript/package.json index e5271d4362..c02ca675bd 100644 --- a/integrations/pydantic-ai/typescript/package.json +++ b/integrations/pydantic-ai/typescript/package.json @@ -2,6 +2,7 @@ "name": "@ag-ui/pydantic-ai", "author": "Steven Hartland ", "version": "0.0.2", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/integrations/server-starter-all-features/typescript/package.json b/integrations/server-starter-all-features/typescript/package.json index 10ec8ce723..42fd998178 100644 --- a/integrations/server-starter-all-features/typescript/package.json +++ b/integrations/server-starter-all-features/typescript/package.json @@ -2,6 +2,8 @@ "name": "@ag-ui/server-starter-all-features", "author": "Markus Ecker ", "version": "0.0.1", + "//": "private: this is a create-ag-ui-app scaffold template, intentionally excluded from the npm release pipeline (not a published package).", + "private": true, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/integrations/server-starter/typescript/package.json b/integrations/server-starter/typescript/package.json index 62d3246043..76e2af06f4 100644 --- a/integrations/server-starter/typescript/package.json +++ b/integrations/server-starter/typescript/package.json @@ -2,6 +2,8 @@ "name": "@ag-ui/server-starter", "author": "Markus Ecker ", "version": "0.0.1", + "//": "private: this is a create-ag-ui-app scaffold template, intentionally excluded from the npm release pipeline (not a published package).", + "private": true, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/integrations/vercel-ai-sdk/typescript/LICENSE b/integrations/vercel-ai-sdk/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/vercel-ai-sdk/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/vercel-ai-sdk/typescript/package.json b/integrations/vercel-ai-sdk/typescript/package.json index 7c87d60121..8a5aa2d144 100644 --- a/integrations/vercel-ai-sdk/typescript/package.json +++ b/integrations/vercel-ai-sdk/typescript/package.json @@ -1,6 +1,11 @@ { "name": "@ag-ui/vercel-ai-sdk", "version": "0.0.2", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ag-ui-protocol/ag-ui.git" + }, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/integrations/watsonx/python/LICENSE b/integrations/watsonx/python/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/watsonx/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/watsonx/python/pyproject.toml b/integrations/watsonx/python/pyproject.toml index e55b386c42..6ddc101d7e 100644 --- a/integrations/watsonx/python/pyproject.toml +++ b/integrations/watsonx/python/pyproject.toml @@ -2,6 +2,8 @@ name = "ag_ui_watsonx" version = "0.0.1" description = "AG-UI integration for IBM watsonx orchestrate agents" +license = "MIT" +license-files = ["LICENSE"] authors = [ { name = "AG-UI Contributors" } ] diff --git a/integrations/watsonx/typescript/LICENSE b/integrations/watsonx/typescript/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/integrations/watsonx/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/watsonx/typescript/package.json b/integrations/watsonx/typescript/package.json index 5d5c0ceceb..6189f05951 100644 --- a/integrations/watsonx/typescript/package.json +++ b/integrations/watsonx/typescript/package.json @@ -2,6 +2,7 @@ "name": "@ag-ui/watsonx", "author": "AG-UI Contributors", "version": "0.0.1", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/middlewares/a2a-middleware/LICENSE b/middlewares/a2a-middleware/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/middlewares/a2a-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/middlewares/a2a-middleware/package.json b/middlewares/a2a-middleware/package.json index 74a7edc9b5..a34f5b521e 100644 --- a/middlewares/a2a-middleware/package.json +++ b/middlewares/a2a-middleware/package.json @@ -2,6 +2,7 @@ "name": "@ag-ui/a2a-middleware", "author": "Markus Ecker ", "version": "0.0.2", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/middlewares/a2ui-middleware/LICENSE b/middlewares/a2ui-middleware/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/middlewares/a2ui-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/middlewares/a2ui-middleware/__tests__/a2ui-middleware.test.ts b/middlewares/a2ui-middleware/__tests__/a2ui-middleware.test.ts index 269e4832cd..8cc8b4078c 100644 --- a/middlewares/a2ui-middleware/__tests__/a2ui-middleware.test.ts +++ b/middlewares/a2ui-middleware/__tests__/a2ui-middleware.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { AbstractAgent, BaseEvent, @@ -13,6 +13,7 @@ import { Observable, firstValueFrom, toArray } from "rxjs"; import { A2UIMiddleware, A2UIActivityType, + A2UI_SCHEMA_CONTEXT_DESCRIPTION, RENDER_A2UI_TOOL_NAME, LOG_A2UI_EVENT_TOOL_NAME, extractSurfaceIds, @@ -69,6 +70,13 @@ async function collectEvents(observable: Observable): Promise + e.type === EventType.ACTIVITY_SNAPSHOT && Array.isArray((e as any).content?.a2ui_operations); + describe("A2UIMiddleware", () => { describe("tool injection", () => { it("should inject render_a2ui tool when injectA2UITool is true", async () => { @@ -84,6 +92,23 @@ describe("A2UIMiddleware", () => { expect(mockAgent.runCalls).toHaveLength(1); const tools = mockAgent.runCalls[0].tools; expect(tools.some((t) => t.name === RENDER_A2UI_TOOL_NAME)).toBe(true); + // The flag is forwarded so downstream (e.g. LangGraph) can surface it into state. + expect(mockAgent.runCalls[0].forwardedProps?.injectA2UITool).toBe(true); + }); + + it("should forward a custom injectA2UITool tool name as the flag", async () => { + const middleware = new A2UIMiddleware({ injectA2UITool: "custom_render" }); + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const input = createRunAgentInput(); + await collectEvents(middleware.run(input, mockAgent)); + + const tools = mockAgent.runCalls[0].tools; + expect(tools.some((t) => t.name === "custom_render")).toBe(true); + expect(mockAgent.runCalls[0].forwardedProps?.injectA2UITool).toBe("custom_render"); }); it("should not inject tool by default", async () => { @@ -99,6 +124,8 @@ describe("A2UIMiddleware", () => { expect(mockAgent.runCalls).toHaveLength(1); const tools = mockAgent.runCalls[0].tools; expect(tools.some((t) => t.name === RENDER_A2UI_TOOL_NAME)).toBe(false); + // No injection -> flag must not be forwarded. + expect(mockAgent.runCalls[0].forwardedProps?.injectA2UITool).toBeUndefined(); }); it("should not duplicate tool if already present", async () => { @@ -210,10 +237,8 @@ describe("A2UIMiddleware", () => { const input = createRunAgentInput(); const events = await collectEvents(middleware.run(input, mockAgent)); - // Streaming handler should have emitted ACTIVITY_SNAPSHOT during TOOL_CALL_ARGS - const activityEvent = events.find( - (e) => e.type === EventType.ACTIVITY_SNAPSHOT - ); + // Streaming handler should have emitted a painted ACTIVITY_SNAPSHOT during TOOL_CALL_ARGS + const activityEvent = events.find(isPaint); expect(activityEvent).toBeDefined(); expect((activityEvent as any).activityType).toBe(A2UIActivityType); // Should have createSurface + updateComponents (first emission) @@ -303,13 +328,526 @@ describe("A2UIMiddleware", () => { const input = createRunAgentInput(); const events = await collectEvents(middleware.run(input, mockAgent)); - const activityEvent = events.find( - (e) => e.type === EventType.ACTIVITY_SNAPSHOT + const activitySnapshots = events.filter(isPaint); + expect(activitySnapshots.length).toBeGreaterThanOrEqual(1); + + // createSurface is emitted early — the first snapshot creates the surface + // (so the frontend can paint a skeleton before components finish). + const firstOps = (activitySnapshots[0] as any).content.a2ui_operations; + expect(firstOps.some((op: any) => op.createSurface)).toBe(true); + + // By the final snapshot, components have landed (createSurface + updateComponents). + const lastOps = (activitySnapshots[activitySnapshots.length - 1] as any).content + .a2ui_operations; + expect(lastOps.some((op: any) => op.updateComponents)).toBe(true); + expect(lastOps.length).toBeGreaterThanOrEqual(2); + }); + + it("streams data items incrementally for a repeated-template surface", async () => { + const middleware = new A2UIMiddleware(); + const toolCallId = "tc-stream-items"; + + // List surface: Row root repeats one card template over /items. + const fullArgs = JSON.stringify({ + surfaceId: "hotels", + components: [ + { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }, + { id: "card", component: "HotelCard", name: { path: "name" } }, + ], + data: { + items: [ + { name: "Alpha" }, + { name: "Bravo" }, + { name: "Charlie" }, + ], + }, + }); + + // Slice into many small deltas so item boundaries land on separate chunks. + const deltas: BaseEvent[] = []; + const chunk = 12; + for (let i = 0; i < fullArgs.length; i += chunk) { + deltas.push({ + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: fullArgs.substring(i, i + chunk), + } as BaseEvent); + } + + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: "render_a2ui" }, + ...deltas, + { type: EventType.TOOL_CALL_END, toolCallId }, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const input = createRunAgentInput(); + const events = await collectEvents(middleware.run(input, mockAgent)); + const snapshots = events.filter(isPaint); + + // Never emit a component without a `component` type (would throw in web_core). + for (const snap of snapshots) { + const ops = (snap as any).content.a2ui_operations as any[]; + for (const op of ops) { + if (op.updateComponents) { + for (const c of op.updateComponents.components) { + expect(typeof c.component).toBe("string"); + } + } + } + } + + // The data-model item count should grow across snapshots (progressive + // hydration), reaching the full 3 items by the end. + const itemCounts = snapshots + .map((s) => { + const ops = (s as any).content.a2ui_operations as any[]; + const dm = ops.find((op) => op.updateDataModel); + return dm ? (dm.updateDataModel.value.items?.length ?? 0) : -1; + }) + .filter((n) => n >= 0); + + expect(itemCounts.length).toBeGreaterThanOrEqual(2); // multiple data emits + expect(Math.max(...itemCounts)).toBe(3); // ends fully hydrated + // Monotonic non-decreasing growth. + for (let i = 1; i < itemCounts.length; i++) { + expect(itemCounts[i]).toBeGreaterThanOrEqual(itemCounts[i - 1]); + } + // TEETH: at least one PARTIAL data emit (fewer than the full 3 items) + // must have been observed. This is the assertion that fails if streaming + // is reverted to atomic data emission — atomic mode only ever emits the + // full array, so every count would equal 3 and min would not be < 3. + expect(Math.min(...itemCounts)).toBeLessThan(3); + + // updateComponents emitted exactly once-worth (atomic): the components + // array is identical across every snapshot that carries it. + const componentSets = snapshots + .map((s) => { + const ops = (s as any).content.a2ui_operations as any[]; + const uc = ops.find((op) => op.updateComponents); + return uc ? JSON.stringify(uc.updateComponents.components) : null; + }) + .filter((x): x is string => x !== null); + expect(new Set(componentSets).size).toBe(1); + }); + + it("never emits an empty surface (createSurface always rides with components)", async () => { + const middleware = new A2UIMiddleware(); + const toolCallId = "tc-early-surface"; + + const fullArgs = JSON.stringify({ + surfaceId: "early", + components: [ + { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }, + { id: "card", component: "HotelCard", name: { path: "name" } }, + ], + data: { items: [{ name: "A" }] }, + }); + + const deltas: BaseEvent[] = []; + const chunk = 8; + for (let i = 0; i < fullArgs.length; i += chunk) { + deltas.push({ + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: fullArgs.substring(i, i + chunk), + } as BaseEvent); + } + + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: "render_a2ui" }, + ...deltas, + { type: EventType.TOOL_CALL_END, toolCallId }, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const events = await collectEvents(middleware.run(createRunAgentInput(), mockAgent)); + const snapshots = events.filter(isPaint); + + // Every snapshot that carries createSurface must also carry components in + // the same payload — an empty surface would make the renderer throw + // "Component not found: root" before components arrive (a visible flash). + for (const snap of snapshots) { + const ops = (snap as any).content.a2ui_operations as any[]; + if (ops.some((op) => op.createSurface)) { + expect(ops.some((op) => op.updateComponents)).toBe(true); + } + } + // And the very first snapshot already includes components. + const firstOps = (snapshots[0] as any).content.a2ui_operations as any[]; + expect(firstOps.some((op) => op.updateComponents)).toBe(true); + }); + + it("treats an empty-string defaultCatalogId as unset (no createSurface with catalogId='')", async () => { + const middleware = new A2UIMiddleware({ defaultCatalogId: "" }); + const toolCallId = "tc-empty-catalog"; + + const fullArgs = JSON.stringify({ + surfaceId: "s-empty", + components: [ + { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }, + { id: "card", component: "HotelCard", name: { path: "name" } }, + ], + data: { items: [{ name: "A" }] }, + }); + + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: "render_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId, delta: fullArgs } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId }, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const events = await collectEvents(middleware.run(createRunAgentInput(), mockAgent)); + const snapshots = events.filter(isPaint); + // The createSurface op's catalogId must never be the empty string the + // host accidentally configured — fall through to the basic catalog + // (which the renderer can at least surface as a real, recognizable error). + for (const snap of snapshots) { + const ops = (snap as any).content.a2ui_operations as any[]; + for (const op of ops) { + if (op.createSurface) { + expect(op.createSurface.catalogId).not.toBe(""); + expect(typeof op.createSurface.catalogId).toBe("string"); + expect((op.createSurface.catalogId as string).length).toBeGreaterThan(0); + } + } + } + }); + + it("falls back to the frontend-registered catalog id when no defaultCatalogId is configured", async () => { + // Zero-config path: the host sets NO defaultCatalogId. The renderer ships + // the catalog it registered as the A2UI schema context entry + // ({ catalogId, components }). The middleware must stamp createSurface with + // THAT id — not "basic" — so the surface resolves against the catalog the + // frontend actually has. + const middleware = new A2UIMiddleware({}); + const toolCallId = "tc-fe-catalog"; + + const fullArgs = JSON.stringify({ + surfaceId: "s-fe", + components: [ + { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }, + { id: "card", component: "HotelCard", name: { path: "name" } }, + ], + data: { items: [{ name: "A" }] }, + }); + + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: "render_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId, delta: fullArgs } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId }, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const input = createRunAgentInput({ + context: [ + { + description: A2UI_SCHEMA_CONTEXT_DESCRIPTION, + value: JSON.stringify({ catalogId: "declarative-gen-ui-catalog", components: {} }), + }, + ], + }); + + const events = await collectEvents(middleware.run(input, mockAgent)); + const snapshots = events.filter(isPaint); + expect(snapshots.length).toBeGreaterThan(0); + for (const snap of snapshots) { + const ops = (snap as any).content.a2ui_operations as any[]; + for (const op of ops) { + if (op.createSurface) { + expect(op.createSurface.catalogId).toBe("declarative-gen-ui-catalog"); + } + } + } + }); + + it("configured defaultCatalogId wins over the frontend-registered catalog id", async () => { + // Explicit host override must take precedence over the frontend-shipped id. + const middleware = new A2UIMiddleware({ defaultCatalogId: "server://override" }); + const toolCallId = "tc-config-wins"; + + const fullArgs = JSON.stringify({ + surfaceId: "s-override", + components: [ + { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }, + { id: "card", component: "HotelCard", name: { path: "name" } }, + ], + data: { items: [{ name: "A" }] }, + }); + + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: "render_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId, delta: fullArgs } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId }, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const input = createRunAgentInput({ + context: [ + { + description: A2UI_SCHEMA_CONTEXT_DESCRIPTION, + value: JSON.stringify({ catalogId: "declarative-gen-ui-catalog", components: {} }), + }, + ], + }); + + const events = await collectEvents(middleware.run(input, mockAgent)); + const snapshots = events.filter(isPaint); + expect(snapshots.length).toBeGreaterThan(0); + for (const snap of snapshots) { + const ops = (snap as any).content.a2ui_operations as any[]; + for (const op of ops) { + if (op.createSurface) { + expect(op.createSurface.catalogId).toBe("server://override"); + } + } + } + }); + + it("streaming intercept fires for a custom injectA2UITool name", async () => { + // When the middleware injects the render tool under a non-default name, + // the streaming intercept must recognize that name — otherwise the + // progressive-render path silently downgrades to result-only. + const middleware = new A2UIMiddleware({ injectA2UITool: "custom_render" }); + const toolCallId = "tc-custom-name"; + + const fullArgs = JSON.stringify({ + surfaceId: "s-custom", + components: [ + { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }, + { id: "card", component: "HotelCard", name: { path: "name" } }, + ], + data: { items: [{ name: "X" }] }, + }); + + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: "custom_render" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId, delta: fullArgs } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId }, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const events = await collectEvents(middleware.run(createRunAgentInput(), mockAgent)); + const snapshots = events.filter(isPaint); + // The custom-named tool's args must produce streaming ACTIVITY_SNAPSHOTs. + expect(snapshots.length).toBeGreaterThan(0); + const hasCreate = snapshots.some((s) => + (s as any).content.a2ui_operations.some((op: any) => op.createSurface?.surfaceId === "s-custom"), ); - expect(activityEvent).toBeDefined(); - // Should have the surface ops - const ops = (activityEvent as any).content.a2ui_operations; - expect(ops.length).toBeGreaterThanOrEqual(2); + expect(hasCreate).toBe(true); + }); + + it("streaming intercept fires with injectA2UITool:true even when a2uiToolNames is overridden", async () => { + // Regression: a host that overrides `a2uiToolNames` (e.g. to add an extra + // recognized name) while keeping `injectA2UITool: true` would previously + // lose the default RENDER_A2UI_TOOL_NAME from the intercept set, because + // the conditional only added a custom *string* name. The injected tool — + // still named "render_a2ui" — would never open a streaming entry and + // the progressive-render path would silently degrade to result-only. + const middleware = new A2UIMiddleware({ + injectA2UITool: true, + a2uiToolNames: ["some_other_extra_tool"], + }); + const toolCallId = "tc-default-name-override"; + + const fullArgs = JSON.stringify({ + surfaceId: "s-default", + components: [ + { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }, + { id: "card", component: "HotelCard", name: { path: "name" } }, + ], + data: { items: [{ name: "Y" }] }, + }); + + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: "render_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId, delta: fullArgs } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId }, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const events = await collectEvents(middleware.run(createRunAgentInput(), mockAgent)); + const snapshots = events.filter(isPaint); + expect(snapshots.length).toBeGreaterThan(0); + const hasCreate = snapshots.some((s) => + (s as any).content.a2ui_operations.some( + (op: any) => op.createSurface?.surfaceId === "s-default", + ), + ); + expect(hasCreate).toBe(true); + }); + + it("does not suppress a second unrelated tool's a2ui_operations result after an earlier render streamed", async () => { + // Earlier behaviour: any streaming entry with componentsEmitted=true + // blanket-suppressed every subsequent a2ui_operations result in the + // same run, even from an unrelated outer tool with a different + // surface. Convergence fix: dedup is scoped to the outer call id. + const middleware = new A2UIMiddleware(); + + // 1. Inner render streams surface "s-first" inside outer call "outer-1". + const innerCallId = "tc-inner"; + const outer1 = "outer-1"; + const innerArgs = JSON.stringify({ + surfaceId: "s-first", + components: [ + { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }, + { id: "card", component: "HotelCard", name: { path: "name" } }, + ], + data: { items: [{ name: "A" }] }, + }); + + // 2. An unrelated outer tool "outer-2" returns a full a2ui_operations envelope + // for a different surface "s-second". The middleware must NOT swallow it. + const outer2 = "outer-2"; + const secondEnvelope = JSON.stringify({ + a2ui_operations: [ + { version: "v0.9", createSurface: { surfaceId: "s-second", catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json" } }, + { version: "v0.9", updateComponents: { surfaceId: "s-second", components: [{ id: "root", component: "Text", text: "hi" }] } }, + ], + }); + + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + // Outer call 1 opens. + { type: EventType.TOOL_CALL_START, toolCallId: outer1, toolCallName: "generate_a2ui" }, + // Inner render_a2ui inside outer-1. + { type: EventType.TOOL_CALL_START, toolCallId: innerCallId, toolCallName: "render_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: innerCallId, delta: innerArgs } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId: innerCallId }, + { type: EventType.TOOL_CALL_RESULT, toolCallId: outer1, content: JSON.stringify({ ok: true }) } as BaseEvent, + // Outer call 2 opens — completely unrelated tool that legitimately + // returns a different a2ui surface in its result content. + { type: EventType.TOOL_CALL_START, toolCallId: outer2, toolCallName: "some_other_tool" }, + { type: EventType.TOOL_CALL_RESULT, toolCallId: outer2, content: secondEnvelope } as BaseEvent, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const events = await collectEvents(middleware.run(createRunAgentInput(), mockAgent)); + const snapshots = events.filter(isPaint); + const surfaceIds = new Set(); + for (const snap of snapshots) { + const ops = (snap as any).content.a2ui_operations as any[]; + for (const op of ops) { + if (op.createSurface) surfaceIds.add(op.createSurface.surfaceId); + if (op.updateComponents) surfaceIds.add(op.updateComponents.surfaceId); + } + } + expect(surfaceIds.has("s-first")).toBe(true); + // Bucket (a) regression guard: dedup must not blanket-suppress + // unrelated subsequent surfaces in the same run. + expect(surfaceIds.has("s-second")).toBe(true); + }); + + it("paints a streamed surface exactly once when the final envelope re-wraps it under a different toolCallId (surfaceId dedup)", async () => { + // Root cause: with the streaming path painting surface S (componentsEmitted + // true, outerCallId null because generate_a2ui is itself an a2ui tool name + // and never becomes the tracked outer call), the call-id linkage dedup + // never matches the final envelope's toolCallId. The surfaceId guard must + // still suppress the redundant final re-paint of S → exactly ONE anchor. + const middleware = new A2UIMiddleware({ a2uiToolNames: ["render_a2ui", "generate_a2ui"] }); + + const innerCallId = "tc-render-inner"; + const outerResultCallId = "tc-generate-outer"; // DIFFERENT id; no linkage to inner + const innerArgs = JSON.stringify({ + surfaceId: "s-dup", + components: [ + { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }, + { id: "card", component: "HotelCard", name: { path: "name" } }, + ], + data: { items: [{ name: "A" }] }, + }); + + // Final envelope from generate_a2ui re-wraps the SAME surface s-dup. + const finalEnvelope = JSON.stringify({ + a2ui_operations: [ + { version: "v0.9", createSurface: { surfaceId: "s-dup", catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json" } }, + { version: "v0.9", updateComponents: { surfaceId: "s-dup", components: [ + { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }, + { id: "card", component: "HotelCard", name: { path: "name" } }, + ] } }, + ], + }); + + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + // Inner render_a2ui streams surface s-dup (outerCallId stays null). + { type: EventType.TOOL_CALL_START, toolCallId: innerCallId, toolCallName: "render_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: innerCallId, delta: innerArgs } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId: innerCallId }, + // generate_a2ui returns the final envelope for the SAME surface under a + // DIFFERENT toolCallId — call-id linkage cannot match this. + { type: EventType.TOOL_CALL_START, toolCallId: outerResultCallId, toolCallName: "generate_a2ui" }, + { type: EventType.TOOL_CALL_RESULT, toolCallId: outerResultCallId, content: finalEnvelope } as BaseEvent, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const events = await collectEvents(middleware.run(createRunAgentInput(), mockAgent)); + + // Count distinct painted messageIds (DOM anchors) that carry a + // createSurface/updateComponents for s-dup. + const anchorIds = new Set(); + for (const snap of events.filter(isPaint)) { + const ops = (snap as any).content.a2ui_operations as any[]; + const touchesS = ops.some( + (op) => op.createSurface?.surfaceId === "s-dup" || op.updateComponents?.surfaceId === "s-dup", + ); + if (touchesS) anchorIds.add((snap as any).messageId); + } + // Exactly one anchor for s-dup: the streaming one. The final re-paint is suppressed. + expect(anchorIds.size).toBe(1); + expect([...anchorIds][0]).toBe(`a2ui-surface-${innerCallId}`); + }); + + it("still paints an UNRELATED surface from a later tool result after a streamed surface (no over-suppression by surfaceId)", async () => { + // The surfaceId guard must only suppress surfaces THIS run already + // streamed. A different surfaceId in a later tool result must still paint. + const middleware = new A2UIMiddleware({ a2uiToolNames: ["render_a2ui", "generate_a2ui"] }); + + const innerCallId = "tc-render-inner"; + const otherCallId = "tc-other"; + const innerArgs = JSON.stringify({ + surfaceId: "s-streamed", + components: [{ id: "root", component: "Text", text: "hi" }], + data: {}, + }); + const otherEnvelope = JSON.stringify({ + a2ui_operations: [ + { version: "v0.9", createSurface: { surfaceId: "s-other", catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json" } }, + { version: "v0.9", updateComponents: { surfaceId: "s-other", components: [{ id: "root", component: "Text", text: "other" }] } }, + ], + }); + + const mockAgent = new MockAgent([ + { type: EventType.RUN_STARTED, runId: "test", threadId: "test" }, + { type: EventType.TOOL_CALL_START, toolCallId: innerCallId, toolCallName: "render_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: innerCallId, delta: innerArgs } as BaseEvent, + { type: EventType.TOOL_CALL_END, toolCallId: innerCallId }, + // Unrelated tool returns a DIFFERENT surface in its result envelope. + { type: EventType.TOOL_CALL_START, toolCallId: otherCallId, toolCallName: "some_other_tool" }, + { type: EventType.TOOL_CALL_RESULT, toolCallId: otherCallId, content: otherEnvelope } as BaseEvent, + { type: EventType.RUN_FINISHED, runId: "test", threadId: "test" }, + ]); + + const events = await collectEvents(middleware.run(createRunAgentInput(), mockAgent)); + const surfaceIds = new Set(); + for (const snap of events.filter(isPaint)) { + const ops = (snap as any).content.a2ui_operations as any[]; + for (const op of ops) { + if (op.createSurface) surfaceIds.add(op.createSurface.surfaceId); + if (op.updateComponents) surfaceIds.add(op.updateComponents.surfaceId); + } + } + expect(surfaceIds.has("s-streamed")).toBe(true); + // The unrelated surface in the later result must still paint. + expect(surfaceIds.has("s-other")).toBe(true); }); it("should produce distinct messageIds for different render_a2ui calls with the same surfaceId", async () => { @@ -363,18 +901,16 @@ describe("A2UIMiddleware", () => { const events = await collectEvents(middleware.run(input, mockAgent)); // Should have two distinct ACTIVITY_SNAPSHOT events with different messageIds - const snapshots = events.filter((e) => e.type === EventType.ACTIVITY_SNAPSHOT); + const snapshots = events.filter(isPaint); expect(snapshots).toHaveLength(2); const messageId1 = (snapshots[0] as any).messageId; const messageId2 = (snapshots[1] as any).messageId; expect(messageId1).not.toBe(messageId2); - // Both should include the surfaceId - expect(messageId1).toContain("shared-surface"); - expect(messageId2).toContain("shared-surface"); - - // Each should include its own toolCallId + // OSS-162: the messageId is keyed by the (outer) call, not the surfaceId, + // so the whole lifecycle for a call shares one id and the paint replaces the + // skeleton in place. Distinct calls still get distinct ids via their toolCallId. expect(messageId1).toContain(toolCallId1); expect(messageId2).toContain(toolCallId2); }); @@ -435,7 +971,7 @@ describe("A2UIMiddleware", () => { const input = createRunAgentInput(); const events = await collectEvents(middleware.run(input, mockAgent)); - const snapshots = events.filter((e) => e.type === EventType.ACTIVITY_SNAPSHOT); + const snapshots = events.filter(isPaint); expect(snapshots).toHaveLength(2); const messageId1 = (snapshots[0] as any).messageId; @@ -448,12 +984,19 @@ describe("A2UIMiddleware", () => { }); describe("A2UI auto-detection in tool results", () => { + // Silence console.warn from auto-detect best-effort paths (e.g. non-A2UI + // strings that happen to look JSON-ish) so the test output stays clean. + // Restored after each test so the spy doesn't leak into unrelated suites. let consoleWarnSpy: ReturnType; beforeEach(() => { consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + it("should emit ACTIVITY_SNAPSHOT when TOOL_CALL_RESULT contains a2ui_operations container", async () => { const middleware = new A2UIMiddleware(); const toolCallId = "tc-custom"; @@ -495,7 +1038,7 @@ describe("A2UI auto-detection in tool results", () => { expect(resultEvents).toHaveLength(1); // Should have auto-detected A2UI and emitted ACTIVITY_SNAPSHOT - const activitySnapshots = events.filter((e) => e.type === EventType.ACTIVITY_SNAPSHOT); + const activitySnapshots = events.filter(isPaint); expect(activitySnapshots.length).toBeGreaterThanOrEqual(1); expect((activitySnapshots[0] as any).activityType).toBe(A2UIActivityType); expect((activitySnapshots[0] as any).content.a2ui_operations).toHaveLength(2); @@ -530,7 +1073,7 @@ describe("A2UI auto-detection in tool results", () => { const input = createRunAgentInput(); const events = await collectEvents(middleware.run(input, mockAgent)); - const activitySnapshots = events.filter((e) => e.type === EventType.ACTIVITY_SNAPSHOT); + const activitySnapshots = events.filter(isPaint); expect(activitySnapshots).toHaveLength(0); const activityDeltas = events.filter((e) => e.type === EventType.ACTIVITY_DELTA); @@ -568,7 +1111,7 @@ describe("A2UI auto-detection in tool results", () => { const events = await collectEvents(middleware.run(input, mockAgent)); // Should have exactly one ACTIVITY_SNAPSHOT (from streaming, not auto-detection) - const activitySnapshots = events.filter((e) => e.type === EventType.ACTIVITY_SNAPSHOT); + const activitySnapshots = events.filter(isPaint); expect(activitySnapshots).toHaveLength(1); }); @@ -601,7 +1144,7 @@ describe("A2UI auto-detection in tool results", () => { const input = createRunAgentInput(); const events = await collectEvents(middleware.run(input, mockAgent)); - const activitySnapshots = events.filter((e) => e.type === EventType.ACTIVITY_SNAPSHOT); + const activitySnapshots = events.filter(isPaint); expect(activitySnapshots).toHaveLength(0); }); }); diff --git a/middlewares/a2ui-middleware/__tests__/json-extract.test.ts b/middlewares/a2ui-middleware/__tests__/json-extract.test.ts index df244ddc5a..2ef7a67fd2 100644 --- a/middlewares/a2ui-middleware/__tests__/json-extract.test.ts +++ b/middlewares/a2ui-middleware/__tests__/json-extract.test.ts @@ -4,6 +4,7 @@ import { extractCompleteItemsWithStatus, extractCompleteObject, extractCompleteA2UIOperations, + extractDataArrayItems, extractStringField, } from "../src/json-extract"; @@ -98,6 +99,19 @@ describe("extractCompleteItemsWithStatus", () => { arrayClosed: true, }); }); + + it("matches only the top-level key, not a nested same-named key", () => { + // Regression: a component may carry its own `components` field (e.g. + // catalog metadata) — the raw-indexOf scan would mis-target it. The + // top-level `components` array must always win. + const partial = + '{"surfaceId":"s","wrapper":{"components":[{"nested":true}]},"components":[{"id":"root","component":"Row"}]}'; + const result = extractCompleteItemsWithStatus(partial, "components"); + expect(result).toEqual({ + items: [{ id: "root", component: "Row" }], + arrayClosed: true, + }); + }); }); describe("extractCompleteObject", () => { @@ -169,6 +183,25 @@ describe("extractCompleteObject", () => { const partial = '{"surfaceId": "s1", "components": [{"id": "root"}], "data": {"form": {"name": "Mar'; expect(extractCompleteObject(partial, "data")).toBeNull(); }); + + it("ignores a nested `data` property on a component and matches only the top-level key", () => { + // Regression: a component may legitimately carry its own `data` field + // (e.g. a Chart with `{"id":"c","component":"Chart","data":{"series":[1]}}`). + // The earlier raw-indexOf locator would match that nested `"data"` token + // first and return the component's data — wrong. The top-level + // updateDataModel must always reflect the args' OUTER `data` value. + const partial = + '{"surfaceId":"s","components":[{"id":"c","component":"Chart","data":{"series":[1,2]}}],"data":{"series":[9]}}'; + expect(extractCompleteObject(partial, "data")).toEqual({ series: [9] }); + }); + + it("ignores `data` value strings that happen to match the key spelling", () => { + // A value like `{"label":"data"}` must not be mistaken for the key. The + // scanner only matches when the next non-whitespace after the string is + // a colon — value strings are followed by `,` or `}`. + const partial = '{"label":"data","data":{"ok":true}}'; + expect(extractCompleteObject(partial, "data")).toEqual({ ok: true }); + }); }); describe("extractStringField", () => { @@ -273,3 +306,30 @@ describe("extractCompleteA2UIOperations", () => { expect(extractCompleteA2UIOperations(outer)).toEqual(ops); }); }); + +describe("extractDataArrayItems", () => { + it("locates the top-level data object and streams its items", () => { + const partial = + '{"surfaceId":"s","components":[{"id":"root"}],"data":{"items":[{"name":"A"},{"name":"B"'; + const result = extractDataArrayItems(partial, "items"); + expect(result?.items).toEqual([{ name: "A" }]); + expect(result?.arrayClosed).toBe(false); + }); + + it("ignores a component's nested `data` field and uses the outer data object", () => { + // Regression: the previous raw-indexOf scoping would lock onto the + // component's `data` substring and stream `series` instead of the outer + // `items` array. + const partial = + '{"surfaceId":"s","components":[{"id":"c","component":"Chart","data":{"series":[1,2,3]}}],"data":{"items":[{"name":"A"}]}}'; + const result = extractDataArrayItems(partial, "items"); + expect(result?.items).toEqual([{ name: "A" }]); + expect(result?.arrayClosed).toBe(true); + }); + + it("returns null when the data value is not an object", () => { + // `data` is a string here, not an object — nothing to scope into. + const partial = '{"surfaceId":"s","data":"not-an-object"}'; + expect(extractDataArrayItems(partial, "items")).toBeNull(); + }); +}); diff --git a/middlewares/a2ui-middleware/__tests__/recovery-gate.test.ts b/middlewares/a2ui-middleware/__tests__/recovery-gate.test.ts new file mode 100644 index 0000000000..41a89fc8e6 --- /dev/null +++ b/middlewares/a2ui-middleware/__tests__/recovery-gate.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from "vitest"; +import { BaseEvent, EventType, RunAgentInput } from "@ag-ui/client"; +import { Observable, firstValueFrom, toArray } from "rxjs"; +import { A2UIMiddleware, A2UIActivityType } from "../src/index"; +import { AbstractAgent } from "@ag-ui/client"; + +// Minimal mock agent that replays a fixed event sequence. +class MockAgent extends AbstractAgent { + constructor(private events: BaseEvent[]) { + super(); + } + run(): Observable { + return new Observable((s) => { + for (const e of this.events) s.next(e); + s.complete(); + }); + } +} + +function input(): RunAgentInput { + return { threadId: "t", runId: "r", tools: [], context: [], forwardedProps: {}, state: {}, messages: [] }; +} +const collect = (o: Observable) => firstValueFrom(o.pipe(toArray())); + +// Inline JSON-Schema catalog (A2UIInlineCatalogSchema): Row requires children; +// HotelCard requires name + rating. +const CATALOG = { + catalogId: "https://a2ui.org/demos/dojo/dynamic_catalog.json", + components: { + Row: { type: "object", required: ["children"] }, + HotelCard: { type: "object", required: ["name", "rating"] }, + }, +}; + +const ROOT = { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }; +const GOOD_CARD = { id: "card", component: "HotelCard", name: { path: "name" }, rating: { path: "rating" } }; +const BAD_CARD = { id: "card", component: "HotelCard", name: { path: "name" } }; // missing required `rating` +const DATA = { items: [{ name: "Ritz", rating: 4.8 }] }; + +function streamRender(components: unknown[]) { + const args = JSON.stringify({ surfaceId: "hotels", components, data: DATA }); + return [ + { type: EventType.RUN_STARTED, runId: "r", threadId: "t" }, + { type: EventType.TOOL_CALL_START, toolCallId: "tc1", toolCallName: "render_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: "tc1", delta: args }, + { type: EventType.TOOL_CALL_END, toolCallId: "tc1" }, + { type: EventType.RUN_FINISHED, runId: "r", threadId: "t" }, + ] as BaseEvent[]; +} + +// The A2UI generation lifecycle now rides ONE `a2ui-surface` activity (OSS-162): +// pre-paint snapshots carry a `status`; the painted surface carries `a2ui_operations`. +const surfaceSnapshots = (events: BaseEvent[]) => + events.filter((e) => e.type === EventType.ACTIVITY_SNAPSHOT && (e as any).activityType === A2UIActivityType); +const paints = (events: BaseEvent[]) => + surfaceSnapshots(events).filter((e) => Array.isArray((e as any).content?.a2ui_operations)); +const lifecycle = (events: BaseEvent[]) => + surfaceSnapshots(events).filter((e) => typeof (e as any).content?.status === "string"); +const withStatus = (events: BaseEvent[], status: string) => + lifecycle(events).filter((e) => (e as any).content.status === status); + +describe("A2UI middleware — unified generation lifecycle gate (OSS-162)", () => { + it("suppresses a semantically-invalid streamed component tree (no faulty paint)", async () => { + const mw = new A2UIMiddleware({ schema: CATALOG }); + const events = await collect(mw.run(input(), new MockAgent(streamRender([ROOT, BAD_CARD])))); + // No surface painted for the invalid attempt... + expect(paints(events)).toHaveLength(0); + // ...and a "retrying" lifecycle status is surfaced on the surface activity. + expect(withStatus(events, "retrying").length).toBeGreaterThanOrEqual(1); + }); + + it("emits a surface for a valid streamed tree (existing behavior preserved)", async () => { + const mw = new A2UIMiddleware({ schema: CATALOG }); + const events = await collect(mw.run(input(), new MockAgent(streamRender([ROOT, GOOD_CARD])))); + const p = paints(events); + expect(p.length).toBeGreaterThanOrEqual(1); + expect((p[0] as any).content.a2ui_operations.length).toBeGreaterThanOrEqual(2); + // A valid tree never retries. + expect(withStatus(events, "retrying")).toHaveLength(0); + }); + + it("emits a 'building' skeleton when generation starts, sharing the paint's messageId", async () => { + const mw = new A2UIMiddleware({ schema: CATALOG }); + const events = await collect(mw.run(input(), new MockAgent(streamRender([ROOT, GOOD_CARD])))); + const building = withStatus(events, "building"); + expect(building.length).toBeGreaterThanOrEqual(1); + // In-place: the building skeleton and the painted surface share one messageId, + // so the surface replaces the skeleton rather than stacking beneath it. + const buildingId = (building[0] as any).messageId; + expect(paints(events).some((e) => (e as any).messageId === buildingId)).toBe(true); + }); + + it("does NOT over-suppress when no catalog is configured (structural-only)", async () => { + // No `schema` → catalog checks skipped; an unknown component type still paints. + const mw = new A2UIMiddleware(); + const unknown = [{ id: "root", component: "MysteryCard", children: { componentId: "card", path: "/items" } }, { id: "card", component: "MysteryCard", name: { path: "name" } }]; + const events = await collect(mw.run(input(), new MockAgent(streamRender(unknown)))); + expect(paints(events).length).toBeGreaterThanOrEqual(1); + }); + + it("a valid later attempt replaces the retrying skeleton in place (same messageId)", async () => { + const mw = new A2UIMiddleware({ schema: CATALOG }); + const badArgs = JSON.stringify({ surfaceId: "hotels", components: [ROOT, BAD_CARD], data: DATA }); + const goodArgs = JSON.stringify({ surfaceId: "hotels", components: [ROOT, GOOD_CARD], data: DATA }); + const events = await collect( + mw.run( + input(), + new MockAgent([ + { type: EventType.RUN_STARTED, runId: "r", threadId: "t" }, + // Outer generate_a2ui wraps two inner render_a2ui attempts. + { type: EventType.TOOL_CALL_START, toolCallId: "outer1", toolCallName: "generate_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: "outer1", delta: '{"intent":"create"}' }, + { type: EventType.TOOL_CALL_START, toolCallId: "tc1", toolCallName: "render_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: "tc1", delta: badArgs }, + { type: EventType.TOOL_CALL_END, toolCallId: "tc1" }, + { type: EventType.TOOL_CALL_START, toolCallId: "tc2", toolCallName: "render_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: "tc2", delta: goodArgs }, + { type: EventType.TOOL_CALL_END, toolCallId: "tc2" }, + { type: EventType.RUN_FINISHED, runId: "r", threadId: "t" }, + ] as BaseEvent[]), + ), + ); + const retrying = withStatus(events, "retrying"); + expect(retrying.length).toBeGreaterThanOrEqual(1); + const painted = paints(events); + expect(painted.length).toBeGreaterThanOrEqual(1); + // In-place replacement: the retrying skeleton and the painted surface share the + // one outer-call messageId (no leftover skeleton beneath the surface). + const retryId = (retrying[0] as any).messageId; + expect(painted.some((e) => (e as any).messageId === retryId)).toBe(true); + }); + + it("the retrying status carries the attempt count and the configured cap", async () => { + const mw = new A2UIMiddleware({ schema: CATALOG }); + const events = await collect(mw.run(input(), new MockAgent(streamRender([ROOT, BAD_CARD])))); + const retrying = withStatus(events, "retrying"); + expect(retrying.length).toBeGreaterThanOrEqual(1); + // First failure → we're heading into attempt 2 of the default 3. + expect((retrying[0] as any).content.attempt).toBe(2); + expect((retrying[0] as any).content.maxAttempts).toBe(3); + }); + + it("keeps the retry snapshot stable as the rejected attempt keeps streaming (no 1/N, errors persist)", async () => { + // Chunk the args so the components array closes (→ reject) and MORE deltas + // (the data tail) follow. Regression: those trailing deltas used to emit a + // counter-only "retrying" snapshot with the stale attempt (1) and no errors, + // which showed "1/3" and flickered the validation-issues detail away. + const mw = new A2UIMiddleware({ schema: CATALOG }); + const fullArgs = JSON.stringify({ surfaceId: "hotels", components: [ROOT, BAD_CARD], data: DATA }); + const deltas: BaseEvent[] = []; + for (let i = 0; i < fullArgs.length; i += 8) { + deltas.push({ type: EventType.TOOL_CALL_ARGS, toolCallId: "tc1", delta: fullArgs.substring(i, i + 8) } as BaseEvent); + } + const events = await collect( + mw.run( + input(), + new MockAgent([ + { type: EventType.RUN_STARTED, runId: "r", threadId: "t" }, + { type: EventType.TOOL_CALL_START, toolCallId: "tc1", toolCallName: "render_a2ui" }, + ...deltas, + { type: EventType.TOOL_CALL_END, toolCallId: "tc1" }, + { type: EventType.RUN_FINISHED, runId: "r", threadId: "t" }, + ] as BaseEvent[]), + ), + ); + const retrying = withStatus(events, "retrying"); + expect(retrying.length).toBeGreaterThanOrEqual(1); + for (const r of retrying) { + // The first retry is attempt 2 — attempt 1 is the initial try, never a retry. + expect((r as any).content.attempt).toBe(2); + // The dev detail (validation errors) persists on every retry snapshot. + expect(Array.isArray((r as any).content.errors)).toBe(true); + expect((r as any).content.errors.length).toBeGreaterThan(0); + } + }); + + it("emits a hard-failure lifecycle snapshot when the tool result is an exhausted envelope", async () => { + const mw = new A2UIMiddleware({ schema: CATALOG }); + const errorEnvelope = JSON.stringify({ error: "Failed to generate valid A2UI after 3 attempt(s)", code: "a2ui_recovery_exhausted", attempts: [{ attempt: 1, ok: false }] }); + const events = await collect( + mw.run( + input(), + new MockAgent([ + { type: EventType.RUN_STARTED, runId: "r", threadId: "t" }, + { type: EventType.TOOL_CALL_START, toolCallId: "outer1", toolCallName: "generate_a2ui" }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: "outer1", delta: '{"intent":"create"}' }, + { type: EventType.TOOL_CALL_END, toolCallId: "outer1" }, + { type: EventType.TOOL_CALL_RESULT, messageId: "m1", toolCallId: "outer1", content: errorEnvelope } as BaseEvent, + { type: EventType.RUN_FINISHED, runId: "r", threadId: "t" }, + ]), + ), + ); + expect(paints(events)).toHaveLength(0); + const failed = withStatus(events, "failed"); + expect(failed.length).toBe(1); + expect((failed[0] as any).content.error).toContain("Failed to generate"); + }); + + it("stamps server-configured recovery.debugExposure onto the lifecycle snapshot (OSS-162)", async () => { + // Server-side knob, applied to every wrapped agent (Python + TS) since this + // middleware is the single emitter of the generation lifecycle. + const mw = new A2UIMiddleware({ schema: CATALOG, recovery: { debugExposure: "hidden" } }); + const events = await collect(mw.run(input(), new MockAgent(streamRender([ROOT, BAD_CARD])))); + const retrying = withStatus(events, "retrying"); + expect(retrying.length).toBeGreaterThanOrEqual(1); + expect((retrying[0] as any).content.debugExposure).toBe("hidden"); + }); + + it("omits debugExposure when unconfigured, so the client default applies (OSS-162)", async () => { + const mw = new A2UIMiddleware({ schema: CATALOG }); + const events = await collect(mw.run(input(), new MockAgent(streamRender([ROOT, BAD_CARD])))); + const retrying = withStatus(events, "retrying"); + expect(retrying.length).toBeGreaterThanOrEqual(1); + expect((retrying[0] as any).content.debugExposure).toBeUndefined(); + }); + + it("carries a live progressTokens by default but omits it when showProgressTokens is false", async () => { + const on = new A2UIMiddleware({ schema: CATALOG }); + const onEvents = await collect(on.run(input(), new MockAgent(streamRender([ROOT, GOOD_CARD])))); + expect( + lifecycle(onEvents).some((e) => typeof (e as any).content.progressTokens === "number"), + ).toBe(true); + + const off = new A2UIMiddleware({ schema: CATALOG, recovery: { showProgressTokens: false } }); + const offEvents = await collect(off.run(input(), new MockAgent(streamRender([ROOT, GOOD_CARD])))); + expect( + lifecycle(offEvents).every((e) => (e as any).content.progressTokens === undefined), + ).toBe(true); + }); +}); diff --git a/middlewares/a2ui-middleware/package.json b/middlewares/a2ui-middleware/package.json index 0b30fa24b7..85112fa4ad 100644 --- a/middlewares/a2ui-middleware/package.json +++ b/middlewares/a2ui-middleware/package.json @@ -1,7 +1,8 @@ { "name": "@ag-ui/a2ui-middleware", "author": "Markus Ecker", - "version": "0.0.5", + "version": "0.0.10", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" @@ -31,6 +32,7 @@ "rxjs": "7.8.1" }, "dependencies": { + "@ag-ui/a2ui-toolkit": "workspace:*", "clarinet": "^0.12.6" }, "devDependencies": { diff --git a/middlewares/a2ui-middleware/src/index.ts b/middlewares/a2ui-middleware/src/index.ts index df5f9cca36..1093d2f69e 100644 --- a/middlewares/a2ui-middleware/src/index.ts +++ b/middlewares/a2ui-middleware/src/index.ts @@ -10,7 +10,6 @@ import { ToolMessage, ToolCall, ActivitySnapshotEvent, - ActivityDeltaEvent, ToolCallResultEvent, ToolCallStartEvent, ToolCallArgsEvent, @@ -24,7 +23,26 @@ import { A2UIUserAction, } from "./types"; import { RENDER_A2UI_TOOL, RENDER_A2UI_TOOL_NAME, RENDER_A2UI_TOOL_GUIDELINES, LOG_A2UI_EVENT_TOOL_NAME } from "./tools"; -import { getOperationSurfaceId, tryParseA2UIOperations, A2UI_OPERATIONS_KEY, extractCompleteItemsWithStatus, extractCompleteObject, extractStringField } from "./schema"; +import { getOperationSurfaceId, tryParseA2UIOperations, A2UI_OPERATIONS_KEY, extractCompleteItemsWithStatus, extractCompleteObject, extractDataArrayItems, extractStringField } from "./schema"; +import { validateA2UIComponents, MAX_A2UI_ATTEMPTS, type A2UIValidationCatalog } from "@ag-ui/a2ui-toolkit"; + +/** + * Detect a structured hard-failure envelope produced by the toolkit's recovery + * loop when it exhausts its retries, so the middleware can surface a (client- + * rendered) failure instead of silently dropping it. + */ +function tryParseRecoveryFailure(content: unknown): { error: string; attempts: unknown } | null { + if (typeof content !== "string") return null; + try { + const parsed = JSON.parse(content); + if (parsed && typeof parsed === "object" && (parsed as any).code === "a2ui_recovery_exhausted") { + return { error: String((parsed as any).error ?? "A2UI generation failed"), attempts: (parsed as any).attempts ?? [] }; + } + } catch { + // not JSON — nothing to surface + } + return null; +} // Re-exports export * from "./types"; @@ -43,6 +61,37 @@ export const A2UIActivityType = "a2ui-surface"; */ export const A2UI_SCHEMA_CONTEXT_DESCRIPTION = "A2UI Component Schema — available components for generating UI surfaces. Use these component names and properties when creating A2UI operations."; +/** + * Read the catalog id the frontend registered, from the A2UI schema context + * entry it ships on every run. + * + * The renderer sends `{ description: A2UI_SCHEMA_CONTEXT_DESCRIPTION, value: + * JSON.stringify({ catalogId, components }) }` as agent context (so the model + * knows the available components). The `catalogId` in that payload is, by + * construction, the id of the catalog the renderer actually registered — so a + * `createSurface` stamped with it provably resolves on the client. + * + * Used as the catalog fallback when the host did NOT configure an explicit + * `defaultCatalogId`, so a zero-config app whose only catalog declaration is the + * frontend `` never hits "Catalog not found". + * + * Returns undefined when the entry is absent or unparseable (the caller then + * falls back to the streamed/basic catalog as before). + */ +function extractFrontendCatalogId(input: RunAgentInput): string | undefined { + const entry = (input.context || []).find( + (c) => c.description === A2UI_SCHEMA_CONTEXT_DESCRIPTION, + ); + if (!entry || typeof entry.value !== "string") return undefined; + try { + const parsed = JSON.parse(entry.value); + const id = (parsed as { catalogId?: unknown } | null)?.catalogId; + return typeof id === "string" && id.length > 0 ? id : undefined; + } catch { + return undefined; + } +} + /** * Extract EventWithState type from Middleware.runNextWithState return type */ @@ -51,16 +100,30 @@ type RunNextWithStateReturn = ReturnType; type EventWithState = ExtractObservableType; /** - * Group operations by surfaceId. + * Derive the repeated-data array key from a component set. + * + * A2UI "list" surfaces repeat one template component over an array in the data + * model via structural children: `children: { componentId, path: "/items" }`. + * The data key is that path with its leading slash stripped (e.g. "items"). + * + * Returns the first such key found, or null when no structural repeat exists + * (e.g. a form or static composition), in which case the caller falls back to + * a sensible default and/or the final whole-object data emit. */ -function groupBySurface(ops: Array>): Map>> { - const groups = new Map>>(); - for (const op of ops) { - const sid = getOperationSurfaceId(op) ?? "default"; - if (!groups.has(sid)) groups.set(sid, []); - groups.get(sid)!.push(op); +function deriveRepeatedDataKey(components: Array>): string | null { + for (const comp of components) { + const children = (comp as any)?.children; + if ( + children && + typeof children === "object" && + !Array.isArray(children) && + typeof children.path === "string" && + children.path.length > 0 + ) { + return children.path.replace(/^\//, ""); + } } - return groups; + return null; } /** @@ -75,10 +138,63 @@ export class A2UIMiddleware extends Middleware { this.config = config; } + /** + * Extract the inline catalog (component name → JSON Schema with `required`) + * for semantic validation, when one is configured. Returns undefined for the + * legacy array form or no schema — validation then degrades to structural-only. + */ + private getValidationCatalog(): A2UIValidationCatalog | undefined { + const schema = this.config.schema; + if ( + schema && + !Array.isArray(schema) && + schema.components && + Object.keys(schema.components).length > 0 + ) { + return { components: schema.components as A2UIValidationCatalog["components"] }; + } + return undefined; + } + + /** + * Build a pre-paint lifecycle snapshot for the `a2ui-surface` activity (OSS-162). + * + * The WHOLE generative-UI lifecycle rides ONE stable messageId + * (`a2ui-surface-${key}`, key = outer call): `status: "building" | "retrying" | + * "failed"` pre-paint, then `a2ui_operations` on paint. Because every state + * `replace`s the same messageId, the painted surface supersedes the skeleton in + * place — no separate "resolved" signal, and never more than one loader. + * + * The lifecycle metadata lives on the AG-UI activity-content WRAPPER, never + * inside an A2UI envelope (the `a2ui_operations` elements stay strictly + * `{ version, }`, per the v0.9 envelope spec). + * + * `debugExposure` is stamped from server config so the client renderer honors + * it; applies to all wrapped agents (Python + TS) since this middleware is the + * single emitter. + */ + private buildLifecycleActivity(key: string, content: Record): ActivitySnapshotEvent { + const debugExposure = this.config.recovery?.debugExposure; + return { + type: EventType.ACTIVITY_SNAPSHOT, + messageId: `a2ui-surface-${key}`, + activityType: A2UIActivityType, + content: debugExposure ? { ...content, debugExposure } : content, + replace: true, + }; + } + /** * Main middleware run method */ run(input: RunAgentInput, next: AbstractAgent): Observable { + // Capture the frontend-registered catalog id BEFORE injectSchemaContext may + // replace the frontend schema entry with a server-side one — we want the id + // of the catalog the renderer actually registered, used as the zero-config + // catalog fallback when no `defaultCatalogId` is configured (see + // extractFrontendCatalogId and the catalogId resolution in processStream). + const frontendCatalogId = extractFrontendCatalogId(input); + // Process user action from forwardedProps (append synthetic messages) const enhancedInput = this.processUserAction(input); @@ -87,11 +203,11 @@ export class A2UIMiddleware extends Middleware { // Conditionally inject the render_a2ui tool and its usage guidelines const finalInput = this.config.injectA2UITool - ? this.injectToolGuidelines(this.injectTool(withSchema)) + ? this.injectToolGuidelines(this.injectToolAndFlag(withSchema)) : withSchema; // Process the event stream using runNextWithState for automatic message tracking - return this.processStream(this.runNextWithState(finalInput, next)); + return this.processStream(this.runNextWithState(finalInput, next), frontendCatalogId); } /** @@ -203,18 +319,23 @@ export class A2UIMiddleware extends Middleware { } /** - * Inject the A2UI rendering tool into the input. + * Inject the A2UI rendering tool + the "injectA2UITool" flag into the input. * Uses the configured name from `injectA2UITool` (string) or defaults to "render_a2ui". * Always replaces the tool if it already exists to ensure the correct parameter schema. */ - private injectTool(input: RunAgentInput): RunAgentInput { + private injectToolAndFlag(input: RunAgentInput): RunAgentInput { const toolName = typeof this.config.injectA2UITool === "string" ? this.config.injectA2UITool : RENDER_A2UI_TOOL_NAME; const tool: Tool = { ...RENDER_A2UI_TOOL, name: toolName }; - const filteredTools = input.tools.filter((t) => t.name !== toolName); + // Guard against undefined ``input.tools`` — the AG-UI shape allows it. + const filteredTools = (input.tools ?? []).filter((t) => t.name !== toolName); return { ...input, + forwardedProps: { + ...(input.forwardedProps ?? {}), + injectA2UITool: this.config.injectA2UITool, + }, tools: [...filteredTools, tool], }; } @@ -250,23 +371,92 @@ export class A2UIMiddleware extends Middleware { * Process the event stream, holding back RUN_FINISHED to process pending A2UI tool calls. * Uses runNextWithState for automatic message tracking. */ - private processStream(source: Observable): Observable { - // Tool names recognized as A2UI rendering tools + private processStream(source: Observable, frontendCatalogId?: string): Observable { + // Tool names recognized as A2UI rendering tools. When the middleware also + // INJECTS the rendering tool (config.injectA2UITool truthy), the injected + // name MUST be part of the intercept set — otherwise TOOL_CALL_START for + // it wouldn't open a streaming entry and the progressive-render path + // would silently degrade to result-only. + // + // Two cases to cover: + // - `injectA2UITool: true` → injected under the default + // RENDER_A2UI_TOOL_NAME (matches the default `a2uiToolNames`, but a + // host that ALSO overrides `a2uiToolNames` to something like + // `["foo"]` would lose the default — explicitly re-add). + // - `injectA2UITool: "myName"` → injected under that custom name. const a2uiToolNames = new Set(this.config.a2uiToolNames ?? [RENDER_A2UI_TOOL_NAME]); + if (this.config.injectA2UITool) { + const injectedName = + typeof this.config.injectA2UITool === "string" && this.config.injectA2UITool.length > 0 + ? this.config.injectA2UITool + : RENDER_A2UI_TOOL_NAME; + a2uiToolNames.add(injectedName); + } return new Observable((subscriber) => { let heldRunFinished: EventWithState | null = null; // Streaming tracker for dynamic render_a2ui tool calls. - // Schema is extracted from streaming args when updateComponents completes. + // + // Progressive emission strategy ("components atomic, data incremental"): + // 1. createSurface rides into the FIRST snapshot together with components + // (never on its own — an empty surface makes the renderer try to + // resolve a not-yet-present root component and throw). + // 2. updateComponents is computed ONCE, only after the components array + // is fully closed and every component carries a `component` type. It + // IS re-included in every subsequent cumulative snapshot for + // idempotency (the host filters duplicates by component id), but the + // components payload is the same byte-for-byte across snapshots. + // 3. updateDataModel is emitted INCREMENTALLY: as each item in the + // repeated data array (e.g. `data.items`) closes, a new snapshot + // carries the items-so-far. Because the repeated card reuses one + // already-emitted template component, growing the data array adds no + // new component references — so cards paint one-by-one with no throw. + // + // Each emitted snapshot is cumulative (createSurface + updateComponents + + // updateDataModel-so-far) with replace:true, so any single snapshot is + // self-sufficient even if the frontend coalesces renders. const streamingToolCalls = new Map> } | null; args: string; - emittedCount: number; - schemaEmitted: boolean; // whether schema has been sent to the renderer - dataEmitted: boolean; // whether data model has been sent + outerCallId: string | null; // the outer tool call this streaming inner was started inside (null if direct) + componentsEmitted: boolean; // updateComponents sent (atomic) + componentsRejected: boolean; // components closed but failed semantic validation (OSS-162) — never paint + dataItemsKey: string; // repeated-array key derived from components + dataItemsCount: number; // number of data items emitted so far + dataComplete: boolean; // full (closed) data model emitted }>(); + // OSS-162 generation-lifecycle config (server-side; covers Python + TS). + const showProgressTokens = this.config.recovery?.showProgressTokens !== false; // default true + const maxAttempts = this.config.recovery?.maxAttempts ?? MAX_A2UI_ATTEMPTS; + const TOKEN_EMIT_STEP = 20; // throttle: re-emit progressTokens per ~20 tokens of growth + + // Per outer-call lifecycle bookkeeping, keyed by `outerCallId ?? toolCallId` + // (the same key the surface messageId uses, so states swap in place): + // - retriedOuterKeys: keys that have entered "retrying" (a prior attempt's + // components were rejected) — so the building skeleton becomes the + // retrying skeleton and stays there until paint or hard-failure. + // - attemptCountByKey: number of render attempts seen (1 per render_a2ui call). + // - lastTokenEmitByKey: token count at the last throttled progress emit. + const retriedOuterKeys = new Set(); + const attemptCountByKey = new Map(); + const lastTokenEmitByKey = new Map(); + const estimateTokens = (args: string) => Math.round(args.length / 4); + + // Surfaces already painted via the streaming/progressive path this run, + // tracked by surfaceId. The final-envelope (TOOL_CALL_RESULT carrying + // ``a2ui_operations``) commonly re-wraps the SAME surface that an inner + // ``render_a2ui`` already streamed. The call-id linkage dedup below only + // catches that when the streamed entry's outerCallId equals the result's + // toolCallId — which breaks with ``injectA2UITool``, where ``generate_a2ui`` + // is itself an A2UI tool name so it never becomes the tracked outer call + // (the inner entry's outerCallId stays null). This surfaceId guard kills the + // redundant re-paint for ANY adapter: any final-envelope operation whose + // target surface is already in this set is dropped. Surfaces NOT in this + // set (unrelated tools in the same run) still paint normally. + const streamedSurfaceIds = new Set(); + // Outer tool call context. Any non-A2UI tool call (e.g. ``generate_a2ui`` // wrapping a subagent that emits ``render_a2ui`` calls) is treated as // the "outer" call. The outer id becomes the activity messageId @@ -296,9 +486,25 @@ export class A2UIMiddleware extends Middleware { // tool's TOOL_CALL_RESULT still works as a fallback. if (a2uiToolNames.has(startEvent.toolCallName)) { streamingToolCalls.set(startEvent.toolCallId, { - schema: null, args: "", emittedCount: 0, - schemaEmitted: false, dataEmitted: false, + schema: null, args: "", + outerCallId: currentOuterCallId, + componentsEmitted: false, + componentsRejected: false, + dataItemsKey: "items", dataItemsCount: 0, dataComplete: false, }); + + // OSS-162: this render attempt begins. Emit the pre-paint state on + // the surface activity so the skeleton shows immediately (the + // per-tool-call skeleton was retired). The FIRST attempt is + // "building"; a subsequent attempt means we're already "retrying" + // (a prior attempt's components were rejected), so keep that state. + const key = currentOuterCallId ?? startEvent.toolCallId; + const attempt = (attemptCountByKey.get(key) ?? 0) + 1; + attemptCountByKey.set(key, attempt); + lastTokenEmitByKey.set(key, 0); + if (!retriedOuterKeys.has(key)) { + subscriber.next(this.buildLifecycleActivity(key, { status: "building" })); + } } else if (!nonOuterToolNames.has(startEvent.toolCallName)) { // Any other tool call becomes the active outer-call context. // ``render_a2ui`` events that follow will dedup against this id. @@ -315,92 +521,222 @@ export class A2UIMiddleware extends Middleware { if (streaming) { streaming.args += argsEvent.delta; + // OSS-162: throttled live token estimate on the BUILDING skeleton. + // Only while still building this call's first attempt — once a prior + // attempt has been rejected (retrying) we must NOT emit here: doing so + // would overwrite the reject's rich "retrying" snapshot (correct + // attempt number + validation errors) with a counter-only one, which + // both reset the count to the wrong attempt and flickered the dev + // detail away. The retry snapshot owns the screen until paint / next + // reject / hard-failure. Throttled by token growth to avoid flooding. + const tokenKey = streaming.outerCallId ?? argsEvent.toolCallId; + if ( + showProgressTokens && + !streaming.componentsEmitted && + !streaming.componentsRejected && + !streaming.dataComplete && + !retriedOuterKeys.has(tokenKey) + ) { + const tokens = estimateTokens(streaming.args); + if (tokens - (lastTokenEmitByKey.get(tokenKey) ?? 0) >= TOKEN_EMIT_STEP) { + lastTokenEmitByKey.set(tokenKey, tokens); + subscriber.next( + this.buildLifecycleActivity(tokenKey, { + status: "building", + progressTokens: tokens, + }), + ); + } + } + // Performance: only attempt extraction when the delta contains // characters that could complete a JSON structure. Most deltas // are mid-string/mid-number and can't change parse results. const deltaHasClosingBrace = argsEvent.delta.includes("}"); const deltaHasClosingBracket = argsEvent.delta.includes("]"); const deltaHasStructuralChar = deltaHasClosingBrace || deltaHasClosingBracket; + // surfaceId completes as a string value (closing quote), not a + // brace/bracket — so also probe when the delta closes a string. + const deltaHasQuote = argsEvent.delta.includes('"'); - // For dynamic (render_a2ui): extract schema from the structured args. - // We wait for the components array to be fully closed before setting - // the schema, because partial components (e.g., only the root Column - // without its children) cause the Lit processor to fail validation. - if (deltaHasStructuralChar) { - const result = extractCompleteItemsWithStatus(streaming.args, "components"); + if (deltaHasStructuralChar || deltaHasQuote) { const surfaceId = extractStringField(streaming.args, "surfaceId"); - const rawCatalogId = extractStringField(streaming.args, "catalogId") ?? "basic"; - const catalogId = rawCatalogId === "basic" - ? "https://a2ui.org/specification/v0_9/basic_catalog.json" - : rawCatalogId; - - if (result && result.items.length > 0 && surfaceId) { - // Progressive component streaming: emit activity snapshots - // as components arrive, not just when the full array closes. - const newComponents = result.items.length > streaming.emittedCount; - - if (newComponents) { - if (!streaming.schema) { - // First emission — create the schema object - streaming.schema = { surfaceId, catalogId, components: result.items as any[] }; - } else { - // Update components in existing schema - streaming.schema.components = result.items as any[]; + + // Nothing actionable until we know which surface we're building. + if (surfaceId) { + // Catalog ownership: the host/factory decides the catalog, not + // the subagent. Resolution order: + // 1. configured defaultCatalogId — explicit host override. + // 2. frontendCatalogId — the id of the catalog the renderer + // actually registered (shipped on the run as the A2UI + // schema context entry). Zero-config: an app whose only + // catalog declaration is `` + // gets the right id with no server-side setting. + // 3. a streamed catalogId (legacy) or the basic catalog. + // This keeps the streamed createSurface from referencing a + // catalog the frontend never registered (e.g. "basic" when the + // app uses a custom catalog) — which throws "Catalog not found". + // + // Treat an empty-string defaultCatalogId as unset: a `??` + // alone would propagate "" into the emitted createSurface and + // surface as "Catalog not found: " in the renderer, hiding + // the real cause (misconfiguration). + const configCatalogId = + this.config.defaultCatalogId && this.config.defaultCatalogId.length > 0 + ? this.config.defaultCatalogId + : undefined; + const streamedCatalogId = extractStringField(streaming.args, "catalogId"); + const catalogId = + configCatalogId ?? + frontendCatalogId ?? + (streamedCatalogId && streamedCatalogId !== "basic" + ? streamedCatalogId + : "https://a2ui.org/specification/v0_9/basic_catalog.json"); + + // (2) Components — emit ONCE, only when the array is fully + // closed and every component has a `component` type. Partial + // or type-less components would throw in @a2ui/web_core. + if (!streaming.componentsEmitted && !streaming.componentsRejected) { + const result = extractCompleteItemsWithStatus(streaming.args, "components"); + if ( + result && + result.arrayClosed && + result.items.length > 0 && + result.items.every( + (c) => c && typeof c === "object" && typeof (c as any).component === "string", + ) + ) { + const components = result.items as Array>; + // Semantic gate (OSS-162): never paint an UNVALIDATED + // component tree. The structural check above only proves + // the array closed with typed items; here we enforce + // root/catalog/required-prop/child-ref validity against the + // catalog. Bindings are DEFERRED (validateBindings: false) — + // the data model has not streamed yet, so resolving them + // would false-positive; the adapter re-validates with + // bindings on the full args to drive the retry decision. + const validation = validateA2UIComponents({ + components, + catalog: this.getValidationCatalog(), + validateBindings: false, + }); + if (validation.valid) { + streaming.schema = { surfaceId, catalogId, components }; + streaming.dataItemsKey = deriveRepeatedDataKey(components) ?? "items"; + } else { + // Suppress: the faulty attempt never reaches the surface + // (no wipe). Surface a client-gated "retrying" status; the + // adapter's recovery loop regenerates and a later valid + // attempt supersedes via the outer-call-keyed messageId. + streaming.componentsRejected = true; + const recoveryKey = streaming.outerCallId ?? argsEvent.toolCallId; + retriedOuterKeys.add(recoveryKey); + // Show the attempt we're about to retry into (the failed + // one + 1), capped at the configured cap. Folds onto the + // surface activity so it replaces the building skeleton in + // place (same messageId) — no separate recovery activity. + const nextAttempt = Math.min( + (attemptCountByKey.get(recoveryKey) ?? 1) + 1, + maxAttempts, + ); + lastTokenEmitByKey.set(recoveryKey, 0); + subscriber.next( + this.buildLifecycleActivity(recoveryKey, { + status: "retrying", + attempt: nextAttempt, + maxAttempts, + errors: validation.errors, + }), + ); + } } + } - streaming.schemaEmitted = true; - streaming.emittedCount = result.items.length; + // (3) Data — incrementally surface complete items from the + // repeated data array (e.g. data.items) once components exist. + let dataItems: unknown[] | null = null; + let dataItemsAdvanced = false; + if (streaming.schema && !streaming.dataComplete) { + const itemsResult = extractDataArrayItems(streaming.args, streaming.dataItemsKey); + if (itemsResult && itemsResult.items.length > streaming.dataItemsCount) { + dataItems = itemsResult.items; + dataItemsAdvanced = true; + } + } - // Always include createSurface in every replace:true snapshot. - // If React batches renders and only processes a later snapshot, - // the surface must still be created. The frontend filters out - // duplicate createSurface when the surface already exists. + // Decide whether this delta advanced any emittable state. + // + // We deliberately do NOT emit createSurface on its own: an + // empty surface makes the renderer try to resolve the root + // component immediately, which throws "Component not found: + // root" until updateComponents arrives (a visible error + // flash). So the first snapshot always carries components. + // The loading skeleton during this window is provided by the + // render_a2ui tool-call progress indicator, not an empty surface. + const componentsAdvanced = !!streaming.schema && !streaming.componentsEmitted; + + if (componentsAdvanced || dataItemsAdvanced) { const ops: Array> = []; + // Always include createSurface — the frontend filters it out + // if the surface already exists, so snapshots stay self-sufficient. ops.push({ version: "v0.9", createSurface: { surfaceId, catalogId } }); - ops.push({ version: "v0.9", updateComponents: { surfaceId, components: result.items } }); - // Try to include data model if "data" object is available - const data = extractCompleteObject(streaming.args, "data"); - if (data) { - streaming.dataEmitted = true; - ops.push({ version: "v0.9", updateDataModel: { surfaceId, path: "/", value: data } }); + if (streaming.schema) { + ops.push({ version: "v0.9", updateComponents: { surfaceId, components: streaming.schema.components } }); + streaming.componentsEmitted = true; + // Record the surfaceId so the final envelope doesn't re-paint it. + streamedSurfaceIds.add(streaming.schema.surfaceId); + } + + if (dataItems && dataItems.length > 0) { + streaming.dataItemsCount = dataItems.length; + ops.push({ + version: "v0.9", + updateDataModel: { surfaceId, path: "/", value: { [streaming.dataItemsKey]: dataItems } }, + }); } const content: Record = { [A2UI_OPERATIONS_KEY]: ops }; + // OSS-162: key by the outer call only (no surfaceId), so this + // painted surface shares the messageId of the building/retrying + // skeleton and REPLACES it in place. The client groups ops by + // surfaceId from the content, so dropping it from the id is safe. const snapshotEvent: ActivitySnapshotEvent = { type: EventType.ACTIVITY_SNAPSHOT, - messageId: `a2ui-surface-${surfaceId}-${currentOuterCallId ?? argsEvent.toolCallId}`, + messageId: `a2ui-surface-${streaming.outerCallId ?? argsEvent.toolCallId}`, activityType: A2UIActivityType, content, replace: true, }; subscriber.next(snapshotEvent); + // A valid surface painted → it supersedes any building/retrying + // skeleton on this same messageId. No separate "resolved" needed. + retriedOuterKeys.delete(streaming.outerCallId ?? argsEvent.toolCallId); } - } - } - // Handle late-arriving data: if components were already emitted but - // data wasn't ready yet (streams after components), emit a new snapshot - // with the data once it becomes extractable. - if (deltaHasStructuralChar && streaming.schemaEmitted && !streaming.dataEmitted && streaming.schema) { - const data = extractCompleteObject(streaming.args, "data"); - if (data) { - streaming.dataEmitted = true; - const { surfaceId, catalogId } = streaming.schema; - const ops: Array> = [ - { version: "v0.9", createSurface: { surfaceId, catalogId } }, - { version: "v0.9", updateComponents: { surfaceId, components: streaming.schema.components } }, - { version: "v0.9", updateDataModel: { surfaceId, path: "/", value: data } }, - ]; - const content: Record = { [A2UI_OPERATIONS_KEY]: ops }; - const snapshotEvent: ActivitySnapshotEvent = { - type: EventType.ACTIVITY_SNAPSHOT, - messageId: `a2ui-surface-${surfaceId}-${currentOuterCallId ?? argsEvent.toolCallId}`, - activityType: A2UIActivityType, - content, - replace: true, - }; - subscriber.next(snapshotEvent); + // Final authoritative data emit once the whole data object + // closes. Covers non-array data keys (e.g. form objects) and + // guarantees the data model exactly matches the model's intent. + if (streaming.componentsEmitted && !streaming.dataComplete && deltaHasStructuralChar) { + const data = extractCompleteObject(streaming.args, "data"); + if (data) { + streaming.dataComplete = true; + const ops: Array> = [ + { version: "v0.9", createSurface: { surfaceId, catalogId } }, + { version: "v0.9", updateComponents: { surfaceId, components: streaming.schema!.components } }, + { version: "v0.9", updateDataModel: { surfaceId, path: "/", value: data } }, + ]; + const content: Record = { [A2UI_OPERATIONS_KEY]: ops }; + const snapshotEvent: ActivitySnapshotEvent = { + type: EventType.ACTIVITY_SNAPSHOT, + messageId: `a2ui-surface-${streaming.outerCallId ?? argsEvent.toolCallId}`, + activityType: A2UIActivityType, + content, + replace: true, + }; + subscriber.next(snapshotEvent); + } + } } } } @@ -424,40 +760,88 @@ export class A2UIMiddleware extends Middleware { const resultEvent = event as ToolCallResultEvent; const isStreaming = streamingToolCalls.has(resultEvent.toolCallId); - // Fallback: if a streaming tool call never emitted its schema (e.g. args - // didn't parse), fall through to auto-detection on the final result. + // Fallback: if a streaming tool call never emitted its components + // (e.g. args didn't parse), fall through to auto-detection on the + // final result. const streamingEntry = streamingToolCalls.get(resultEvent.toolCallId); - const streamingHandled = isStreaming && streamingEntry?.schemaEmitted; - - // Also check if ANY streaming entry already handled a surface. - // This covers the case where render_a2ui (inner tool) streamed the - // surface, but the TOOL_CALL_RESULT belongs to generate_a2ui (outer - // tool) — different toolCallId, but same surface already rendered. - let anyStreamingHandled = streamingHandled; - if (!anyStreamingHandled) { + const streamingHandled = isStreaming && streamingEntry?.componentsEmitted; + + // Also dedup against the SPECIFIC outer call this result belongs + // to: if an inner ``render_a2ui`` started inside the same outer + // call already streamed its surface, the outer's result (which + // typically wraps the same envelope) would re-emit the same + // surface. Earlier we used a blanket "any streaming entry handled" + // check, but that wrongly suppressed legitimate later + // ``a2ui_operations`` payloads from unrelated tools in the same + // run. Scope the dedup to entries whose outerCallId matches the + // result's tool-call id. + let outerHasStreamedSurface = !!streamingHandled; + if (!outerHasStreamedSurface) { for (const entry of streamingToolCalls.values()) { - if (entry.schemaEmitted) { - anyStreamingHandled = true; + if (entry.componentsEmitted && entry.outerCallId === resultEvent.toolCallId) { + outerHasStreamedSurface = true; break; } } } - // Skip if any streaming entry already rendered a surface (e.g., - // render_a2ui streamed the surface, and now generate_a2ui's result - // would duplicate it). - if (!anyStreamingHandled) { + if (!outerHasStreamedSurface) { const parsed = tryParseA2UIOperations(resultEvent.content); if (parsed) { - // Emit all operations at once. Unlike the streaming path - // (render_a2ui), explicit a2ui_operations arrive complete — - // splitting schema and data would cause the renderer to - // crash on unresolved path bindings before data exists. - for (const activityEvent of this.createA2UIActivityEvents( - parsed.operations, - currentOuterCallId ?? resultEvent.toolCallId, - )) { - subscriber.next(activityEvent); + // surfaceId-based dedup (framework-agnostic): drop any + // operation whose target surface was already painted via the + // streaming path this run. This kills the redundant final + // re-paint even when the call-id linkage above misses (e.g. + // injectA2UITool, where the inner render entry has a null + // outerCallId). Operations for surfaces NOT yet streamed + // (unrelated tools) pass through untouched. + const operationsToEmit = + streamedSurfaceIds.size > 0 + ? parsed.operations.filter((op) => { + const opSurfaceId = getOperationSurfaceId(op); + // Keep ops with no resolvable surface (can't be a dup) + // and ops targeting surfaces not yet streamed. + return opSurfaceId == null || !streamedSurfaceIds.has(opSurfaceId); + }) + : parsed.operations; + + // If filtering removed everything, the final envelope was + // entirely a re-paint of already-streamed surfaces — emit + // nothing. Otherwise emit the surviving operations. + if (operationsToEmit.length > 0) { + // Emit all operations at once. Unlike the streaming path + // (render_a2ui), explicit a2ui_operations arrive complete — + // splitting schema and data would cause the renderer to + // crash on unresolved path bindings before data exists. + for (const activityEvent of this.createA2UIActivityEvents( + operationsToEmit, + currentOuterCallId ?? resultEvent.toolCallId, + )) { + subscriber.next(activityEvent); + } + } + } else { + // Hard-failure path (OSS-162): an exhausted recovery loop + // returns a structured error envelope (no a2ui_operations). + // Surface it as a client-rendered failure rather than dropping + // it silently — the conversation stays usable. + const failure = tryParseRecoveryFailure(resultEvent.content); + if (failure) { + // Hard failure replaces the building/retrying skeleton in + // place (same surface messageId). `attempts.length` is the + // true cap reached; fall back to the configured cap. + const failKey = currentOuterCallId ?? resultEvent.toolCallId; + subscriber.next( + this.buildLifecycleActivity(failKey, { + status: "failed", + error: failure.error, + attempts: failure.attempts, + maxAttempts: Array.isArray(failure.attempts) + ? failure.attempts.length || maxAttempts + : maxAttempts, + }), + ); + retriedOuterKeys.delete(failKey); } } } @@ -562,9 +946,16 @@ export class A2UIMiddleware extends Middleware { // with partial operations that can break data binding resolution. for (const [surfaceId, surfaceOps] of operationsBySurface) { // Include toolCallId in messageId to ensure each tool invocation - // creates a distinct activity message, even for the same surfaceId + // creates a distinct activity message, even for the same surfaceId. + // OSS-162: for the common single-surface case, key by the outer call ONLY + // (no surfaceId) so this paint shares the messageId of any building/retrying + // skeleton emitted for the same call and replaces it in place. Multi-surface + // results keep the per-surface id (they never had a single lifecycle slot). + const singleSurface = operationsBySurface.size === 1; const messageId = toolCallId - ? `a2ui-surface-${surfaceId}-${toolCallId}` + ? singleSurface + ? `a2ui-surface-${toolCallId}` + : `a2ui-surface-${surfaceId}-${toolCallId}` : `a2ui-surface-${surfaceId}`; const content: Record = { [A2UI_OPERATIONS_KEY]: surfaceOps }; diff --git a/middlewares/a2ui-middleware/src/json-extract.ts b/middlewares/a2ui-middleware/src/json-extract.ts index b75b102c7e..4800246158 100644 --- a/middlewares/a2ui-middleware/src/json-extract.ts +++ b/middlewares/a2ui-middleware/src/json-extract.ts @@ -15,34 +15,99 @@ export function extractCompleteItems(partial: string, dataKey: string): unknown[ } /** - * Extract a complete JSON object value for a given key from partially-streamed JSON. - * Given partial JSON like `{"surfaceId": "s1", "data": {"form": {"name": "Alice"}}, "other":` - * and dataKey "data", returns the parsed object `{"form": {"name": "Alice"}}` or null if - * the object value is not yet fully closed. + * Locate the start of the value for a TOP-LEVEL (root-depth=1) key in partial JSON. + * + * Returns the byte index of the first non-whitespace character AFTER the + * key's colon, or -1 if the key hasn't been seen at the root level yet. + * + * This is JSON-aware (driven by clarinet, not raw `indexOf`), so a key with + * the same name nested inside a component object (e.g. a component carrying + * its own `data` field) is correctly ignored — only the top-level key at + * `{"": ...}` is matched. */ -export function extractCompleteObject(partial: string, dataKey: string): Record | null { - // Find the opening '{' of the target object value using string search - const keyPattern = `"${dataKey}"`; - const keyIdx = partial.indexOf(keyPattern); - if (keyIdx === -1) return null; - - // Skip past the key, colon, and whitespace to find the opening '{' - const afterKey = partial.indexOf(":", keyIdx + keyPattern.length); - if (afterKey === -1) return null; +function findTopLevelValueStart(partial: string, key: string): number { + const target = `"${key}"`; + let i = 0; + let objectDepth = 0; + let arrayDepth = 0; + let inString = false; + let escape = false; - let braceStart = -1; - for (let i = afterKey + 1; i < partial.length; i++) { + while (i < partial.length) { const ch = partial[i]; - if (ch === "{") { - braceStart = i; - break; + + if (escape) { + escape = false; + i++; + continue; } - if (ch !== " " && ch !== "\n" && ch !== "\r" && ch !== "\t") { - // Value is not an object (could be array, string, etc.) - return null; + + if (inString) { + if (ch === "\\") { + escape = true; + } else if (ch === '"') { + inString = false; + } + i++; + continue; + } + + if (ch === '"') { + // Opening quote of a string token. Only at the root level + // (objectDepth === 1, no enclosing array) can this be the top-level + // key we're looking for. We confirm by: + // 1. The substring at `i` equals `""`. + // 2. The next non-whitespace character after the closing quote is + // ':' — distinguishing this from a value string that happens to + // equal the key spelling. + if (objectDepth === 1 && arrayDepth === 0 && partial.startsWith(target, i)) { + let j = i + target.length; + while (j < partial.length && (partial[j] === " " || partial[j] === "\n" || partial[j] === "\r" || partial[j] === "\t")) { + j++; + } + if (j < partial.length && partial[j] === ":") { + // Skip the colon and any whitespace to land on the value's first + // non-whitespace character. + j++; + while (j < partial.length && (partial[j] === " " || partial[j] === "\n" || partial[j] === "\r" || partial[j] === "\t")) { + j++; + } + return j < partial.length ? j : -1; + } + } + inString = true; + i++; + continue; } + + if (ch === "{") objectDepth++; + else if (ch === "}") objectDepth--; + else if (ch === "[") arrayDepth++; + else if (ch === "]") arrayDepth--; + + i++; } + + return -1; +} + +/** + * Extract a complete JSON object value for a given key from partially-streamed JSON. + * Given partial JSON like `{"surfaceId": "s1", "data": {"form": {"name": "Alice"}}, "other":` + * and dataKey "data", returns the parsed object `{"form": {"name": "Alice"}}` or null if + * the object value is not yet fully closed. + * + * Only matches the key at the TOP LEVEL — a nested object that happens to + * carry the same key (e.g. a component with its own `data` property) is + * ignored. This keeps the streaming intercept correct even when component + * payloads contain JSON keys that overlap with the render_a2ui arg names. + */ +export function extractCompleteObject(partial: string, dataKey: string): Record | null { + const braceStart = findTopLevelValueStart(partial, dataKey); if (braceStart === -1) return null; + // findTopLevelValueStart returns the index of the value's opening token. + // For object values that's the '{' character. + if (partial[braceStart] !== "{") return null; // Use clarinet to find where the top-level object closes const substr = partial.substring(braceStart); @@ -99,13 +164,13 @@ export function extractCompleteItemsWithStatus( partial: string, dataKey: string, ): { items: unknown[]; arrayClosed: boolean } | null { - // Find the opening '[' of the target array using string search - const keyPattern = `"${dataKey}"`; - const keyIdx = partial.indexOf(keyPattern); - if (keyIdx === -1) return null; - - const bracketStart = partial.indexOf("[", keyIdx + keyPattern.length); + // Locate the opening '[' of the target array via a JSON-aware scan rather + // than raw indexOf — a component object that happens to contain a key with + // the same name (e.g. `"items"` deep in a component) must NOT be mistaken + // for the top-level array. + const bracketStart = findTopLevelValueStart(partial, dataKey); if (bracketStart === -1) return null; + if (partial[bracketStart] !== "[") return null; // Feed only the array portion to clarinet, so parser.position is relative to bracketStart const substr = partial.substring(bracketStart); @@ -169,6 +234,39 @@ export function extractCompleteItemsWithStatus( } } +/** + * Incrementally extract complete items from the array at `data.` + * inside partially-streamed render_a2ui args. + * + * The render_a2ui args look like: + * `{"surfaceId":"s","components":[...],"data":{"items":[{...},{...}` (still streaming) + * + * We scope the search to the `data` object region first (so a `"items"` token + * that appears inside a component's `path` string — e.g. `"path":"/items"` — + * is never mistaken for the data array), then reuse the array-item extractor + * to return every fully-closed item parsed so far. + * + * Returns `{ items, arrayClosed }` or null when the data array hasn't started + * or no complete item exists yet. + */ +export function extractDataArrayItems( + partial: string, + itemsKey: string, +): { items: unknown[]; arrayClosed: boolean } | null { + // Locate the TOP-LEVEL `data` object via clarinet so a component that + // carries its own `data` field (e.g. a Chart component with + // `{"id":"c","component":"Chart","data":{...}}`) doesn't get mis-scoped. + const dataBraceStart = findTopLevelValueStart(partial, "data"); + if (dataBraceStart === -1) return null; + if (partial[dataBraceStart] !== "{") return null; + + // Scope extraction to the data object substring so the items-array search + // (now also clarinet-driven for top-level keys) is rooted at the data + // object, never elsewhere in the args. + const dataSubstr = partial.substring(dataBraceStart); + return extractCompleteItemsWithStatus(dataSubstr, itemsKey); +} + /** * Extract a simple string field value from partial JSON. * Looks for `"key": "value"` and returns the value, or null if incomplete. diff --git a/middlewares/a2ui-middleware/src/schema.ts b/middlewares/a2ui-middleware/src/schema.ts index 0fe9618574..8a041bce51 100644 --- a/middlewares/a2ui-middleware/src/schema.ts +++ b/middlewares/a2ui-middleware/src/schema.ts @@ -848,28 +848,12 @@ export function tryParseA2UIOperations(text: string): A2UIParseResult | null { let parsed: unknown; try { parsed = JSON.parse(text); - } catch (e) { - // Try double-parse (in case the text is a JSON-encoded string) - try { - const inner = JSON.parse(JSON.parse(text)); - if ( - typeof inner === "object" && - inner !== null && - !Array.isArray(inner) && - Array.isArray((inner as Record)[A2UI_OPERATIONS_KEY]) - ) { - const obj = inner as Record; - const result: A2UIParseResult = { - operations: obj[A2UI_OPERATIONS_KEY] as Array< - Record - >, - }; - - return result; - } - } catch { - // Not double-encoded either - } + } catch { + // Not valid JSON at all. The legitimate "double-encoded" case is handled + // below — when ``parsed`` is a string after one successful JSON.parse, we + // try parsing it again. A second nested parse in this catch is dead code: + // ``JSON.parse(text)`` just threw, so calling it again on the same input + // throws the same way. return null; } @@ -880,9 +864,15 @@ export function tryParseA2UIOperations(text: string): A2UIParseResult | null { Array.isArray((parsed as Record)[A2UI_OPERATIONS_KEY]) ) { const obj = parsed as Record; - const result: A2UIParseResult = { - operations: obj[A2UI_OPERATIONS_KEY] as Array>, - }; + // Filter non-object entries — downstream consumers (getOperationSurfaceId, + // createA2UIActivityEvents) read properties off each op and would crash on + // ``null``, primitives, or arrays sitting in the array. + const rawOps = obj[A2UI_OPERATIONS_KEY] as Array; + const operations = rawOps.filter( + (op): op is Record => + typeof op === "object" && op !== null && !Array.isArray(op), + ); + const result: A2UIParseResult = { operations }; return result; } @@ -898,11 +888,12 @@ export function tryParseA2UIOperations(text: string): A2UIParseResult | null { Array.isArray((inner as Record)[A2UI_OPERATIONS_KEY]) ) { const obj = inner as Record; - const result: A2UIParseResult = { - operations: obj[A2UI_OPERATIONS_KEY] as Array< - Record - >, - }; + const rawOps = obj[A2UI_OPERATIONS_KEY] as Array; + const operations = rawOps.filter( + (op): op is Record => + typeof op === "object" && op !== null && !Array.isArray(op), + ); + const result: A2UIParseResult = { operations }; return result; } } catch { @@ -962,5 +953,6 @@ export { extractCompleteItemsWithStatus, extractCompleteObject, extractCompleteA2UIOperations, + extractDataArrayItems, extractStringField, } from "./json-extract"; diff --git a/middlewares/a2ui-middleware/src/tools.ts b/middlewares/a2ui-middleware/src/tools.ts index ded29fb0d7..d19fdb5333 100644 --- a/middlewares/a2ui-middleware/src/tools.ts +++ b/middlewares/a2ui-middleware/src/tools.ts @@ -13,8 +13,8 @@ export const LOG_A2UI_EVENT_TOOL_NAME = "log_a2ui_event"; /** * Tool definition for rendering A2UI surfaces. * This tool is injected into the agent's available tools when injectA2UITool is true. - * Uses structured parameters (surfaceId, catalogId, components, data) - * instead of a raw JSON string. + * Uses structured parameters (surfaceId, components, data) — the catalog id + * is owned by the middleware config, not chosen by the model. */ export const RENDER_A2UI_TOOL: Tool = { name: RENDER_A2UI_TOOL_NAME, @@ -28,10 +28,6 @@ export const RENDER_A2UI_TOOL: Tool = { type: "string", description: "Unique surface identifier.", }, - catalogId: { - type: "string", - description: "The catalog ID for the component catalog.", - }, components: { type: "array", description: @@ -61,10 +57,11 @@ export const RENDER_A2UI_TOOL_GUIDELINES = (toolName: string) => `\ You MUST provide ALL required arguments when calling ${toolName}: - **surfaceId** (string, required): Unique ID for the surface (e.g. "sales-dashboard"). -- **catalogId** (string): The catalog ID. Use the catalog ID from the available components context. - **components** (array, REQUIRED): A2UI v0.9 flat component array. NEVER omit this. - **data** (object, optional): Initial data model for path-bound component values. +Note: the catalog id is set by the host, not by you. Do not include a catalogId argument. + ### Component format (v0.9 flat) Components are a flat array — children are referenced by ID, not nested: @@ -78,7 +75,6 @@ Components are a flat array — children are referenced by ID, not nested: \`\`\`json { "surfaceId": "my-dashboard", - "catalogId": "copilotkit://app-dashboard-catalog", "components": [ { "id": "root", "component": "Column", "children": ["title", "row1"] }, { "id": "title", "component": "Title", "text": "Overview" }, diff --git a/middlewares/a2ui-middleware/src/types.ts b/middlewares/a2ui-middleware/src/types.ts index 49da534a08..00c2c69efa 100644 --- a/middlewares/a2ui-middleware/src/types.ts +++ b/middlewares/a2ui-middleware/src/types.ts @@ -40,6 +40,32 @@ export interface A2UIMiddlewareConfig { */ schema?: A2UIInlineCatalogSchema | A2UIComponentSchema[]; + /** + * A2UI generation-lifecycle options (OSS-162). A server-side knob applied to + * every agent this middleware wraps — Python and TypeScript alike, since the + * middleware is the single emitter of the generation lifecycle for all of them. + * Values are stamped onto the `a2ui-surface` activity's pre-paint content + * (`status: "building" | "retrying" | "failed"`) so the client renderer honors + * them. The whole lifecycle rides one stable messageId and is replaced in place + * by the painted surface. + * + * - `debugExposure` — how much retry/error detail the renderer surfaces: + * `"hidden"` (no expander), `"collapsed"` (expander present, closed), or + * `"verbose"` (expander open). When unset, the client default (`"collapsed"`) + * applies. + * - `showProgressTokens` — when `true` (default), the building skeleton carries + * a throttled live token estimate of the streamed UI spec. Set `false` for a + * countless skeleton (the CSS animation is unaffected either way). + * - `maxAttempts` — the retry cap shown in the "Retrying… (N/M attempts)" label. + * Defaults to the toolkit's `MAX_A2UI_ATTEMPTS`; set it to match the adapter's + * recovery cap if you override that. + */ + recovery?: { + debugExposure?: "hidden" | "collapsed" | "verbose"; + showProgressTokens?: boolean; + maxAttempts?: number; + }; + /** * Controls whether the middleware injects an A2UI rendering tool into * the agent's tool list. @@ -62,6 +88,22 @@ export interface A2UIMiddlewareConfig { */ a2uiToolNames?: string[]; + /** + * Catalog id used when the middleware creates a surface from a STREAMED + * render tool call. + * + * The streamed `render_a2ui` args no longer carry a catalogId — catalog + * choice belongs to the host/factory, not the subagent (the subagent must + * not be able to invent a catalog the frontend hasn't registered). Since + * the streaming `createSurface` op is emitted before the factory's final + * envelope is available, the middleware needs the catalog id up front. + * + * Set this to the same catalog id the factory's `defaultCatalogId` uses. + * When omitted, the middleware falls back to any catalogId present in the + * streamed args, then to the v0.9 basic catalog. + */ + defaultCatalogId?: string; + } /** diff --git a/middlewares/event-throttle-middleware/LICENSE b/middlewares/event-throttle-middleware/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/middlewares/event-throttle-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/middlewares/event-throttle-middleware/package.json b/middlewares/event-throttle-middleware/package.json index c923b0f8f0..983d005bbb 100644 --- a/middlewares/event-throttle-middleware/package.json +++ b/middlewares/event-throttle-middleware/package.json @@ -1,6 +1,11 @@ { "name": "@ag-ui/event-throttle-middleware", "version": "0.0.1", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ag-ui-protocol/ag-ui.git" + }, "publishConfig": { "access": "public" }, diff --git a/middlewares/mcp-apps-middleware/LICENSE b/middlewares/mcp-apps-middleware/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/middlewares/mcp-apps-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/middlewares/mcp-apps-middleware/package.json b/middlewares/mcp-apps-middleware/package.json index e25a75468d..e7cd4b8e99 100644 --- a/middlewares/mcp-apps-middleware/package.json +++ b/middlewares/mcp-apps-middleware/package.json @@ -2,6 +2,7 @@ "name": "@ag-ui/mcp-apps-middleware", "author": "Markus Ecker", "version": "0.0.2", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/middlewares/mcp-middleware/.gitignore b/middlewares/mcp-middleware/.gitignore new file mode 100644 index 0000000000..0ccb8df8de --- /dev/null +++ b/middlewares/mcp-middleware/.gitignore @@ -0,0 +1,141 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ diff --git a/middlewares/mcp-middleware/LICENSE b/middlewares/mcp-middleware/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/middlewares/mcp-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/middlewares/mcp-middleware/README.md b/middlewares/mcp-middleware/README.md new file mode 100644 index 0000000000..66f16a10f8 --- /dev/null +++ b/middlewares/mcp-middleware/README.md @@ -0,0 +1,66 @@ +# MCP Middleware + +AG-UI middleware that connects an agent run to one or more [MCP](https://modelcontextprotocol.io) +servers. It lists each server's tools, injects them into the run, executes the +resulting tool calls server-side, and loops the agent until no MCP tool calls +remain — all presented to the consumer as a single, continuous run. + +## Usage + +```ts +import { MCPMiddleware } from "@ag-ui/mcp-middleware"; + +agent.use( + new MCPMiddleware([ + { + type: "http", + url: "https://example.com/mcp", + serverId: "example", + headers: { Authorization: "Bearer " }, + }, + ]), +); +``` + +## Behavior + +- **Tool injection.** Every tool reported by a server is exposed to the agent + namespaced as `mcp__{serverId}__{tool}` (sanitized to `[a-zA-Z0-9_-]`, + truncated to 64 characters, and de-duplicated with a `_N` suffix on + collision). `serverId` defaults to `server{index}` when omitted. Listing + happens once per middleware instance and is cached. +- **Execution loop.** When a finished run leaves MCP tool calls open, the + middleware executes them (in parallel), emits a `TOOL_CALL_RESULT` for each, + and — if nothing else is open — starts another run with the results appended. + If non-MCP tool calls remain open (e.g. frontend tools), it stops and hands + off to the frontend. Tool calls that don't target an injected MCP tool are + never touched. +- **Single-run presentation.** The whole multi-iteration loop looks like one + run to the consumer: the first `RUN_STARTED` is forwarded, continuation + `RUN_STARTED` events are suppressed, and a single terminal `RUN_FINISHED` is + flushed only when the loop stops. +- **Runaway guard.** `maxIterations` (default `32`) caps the number of + tool-execution rounds. Values are clamped to a positive integer. + +## Configuration + +```ts +interface MCPClientConfig { + type: "http" | "sse"; + url: string; + serverId?: string; + headers?: Record; +} + +interface MCPMiddlewareOptions { + maxIterations?: number; // default 32 +} +``` + +Per-request auth is supported by constructing the middleware per request with +`headers` set — they're stamped on outbound MCP requests via the transport's +`requestInit`. + +> **SSE caveat:** for the `sse` transport, `headers` only apply to the POST +> channel; the SSE event stream uses `eventSourceInit`. Prefer the `http` +> (streamable) transport when headers must cover all traffic. diff --git a/middlewares/mcp-middleware/__tests__/mcp-middleware.test.ts b/middlewares/mcp-middleware/__tests__/mcp-middleware.test.ts new file mode 100644 index 0000000000..a3d2846027 --- /dev/null +++ b/middlewares/mcp-middleware/__tests__/mcp-middleware.test.ts @@ -0,0 +1,669 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + AbstractAgent, + BaseEvent, + EventType, + RunAgentInput, + Tool, +} from "@ag-ui/client"; +import { Observable, firstValueFrom, toArray } from "rxjs"; + +// --- Mock the MCP SDK --------------------------------------------------------- +const mockConnect = vi.fn(); +const mockClose = vi.fn(); +const mockListTools = vi.fn(); +const mockCallTool = vi.fn(); + +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ + Client: class MockClient { + connect = mockConnect; + close = mockClose; + listTools = mockListTools; + callTool = mockCallTool; + }, +})); +const sseTransportCalls: Array<{ url: URL; opts: unknown }> = []; +const httpTransportCalls: Array<{ url: URL; opts: unknown }> = []; + +vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ + SSEClientTransport: class { + constructor(public url: URL, public opts?: unknown) { + sseTransportCalls.push({ url, opts }); + } + }, +})); +vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ + StreamableHTTPClientTransport: class { + constructor(public url: URL, public opts?: unknown) { + httpTransportCalls.push({ url, opts }); + } + }, +})); + +import { MCPMiddleware } from "../src/index"; + +// --- Event builders (real streaming events; no MESSAGES_SNAPSHOT) ------------- +const THREAD = "t"; + +function runStarted(runId = "r"): BaseEvent { + return { type: EventType.RUN_STARTED, threadId: THREAD, runId } as BaseEvent; +} +function runFinished(runId = "r"): BaseEvent { + return { type: EventType.RUN_FINISHED, threadId: THREAD, runId } as BaseEvent; +} +function runError(message = "boom"): BaseEvent { + return { type: EventType.RUN_ERROR, message } as BaseEvent; +} + +/** Streaming events for one assistant tool call. `args` may be split into + * multiple deltas to simulate chunked argument streaming. */ +function toolCall( + toolCallId: string, + toolCallName: string, + args: string | string[] = "{}", +): BaseEvent[] { + const deltas = Array.isArray(args) ? args : [args]; + return [ + { type: EventType.TOOL_CALL_START, toolCallId, toolCallName } as BaseEvent, + ...deltas.map( + (delta) => + ({ type: EventType.TOOL_CALL_ARGS, toolCallId, delta }) as BaseEvent, + ), + { type: EventType.TOOL_CALL_END, toolCallId } as BaseEvent, + ]; +} + +function textMessage(messageId: string, text: string): BaseEvent[] { + return [ + { type: EventType.TEXT_MESSAGE_START, messageId, role: "assistant" } as BaseEvent, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta: text } as BaseEvent, + { type: EventType.TEXT_MESSAGE_END, messageId } as BaseEvent, + ]; +} + +// --- Mock agents -------------------------------------------------------------- +/** Replays a different batch of events on each successive run() call. */ +class BatchMockAgent extends AbstractAgent { + public runCalls: RunAgentInput[] = []; + private call = 0; + constructor(private batches: BaseEvent[][]) { + super(); + } + run(input: RunAgentInput): Observable { + this.runCalls.push(input); + const events = this.batches[this.call] ?? [runStarted(), runFinished()]; + this.call++; + return new Observable((subscriber) => { + for (const event of events) subscriber.next(event); + subscriber.complete(); + }); + } +} + +/** + * Emits a fresh batch on every run — the factory receives the run index + * so it can mint unique ids per iteration (a real looping agent never + * re-emits the same tool-call id, and the middleware now syncs prior + * results into `agent.messages`, which would resolve a re-used id). Used + * to exercise the runaway guard. + */ +class LoopingMockAgent extends AbstractAgent { + public runCount = 0; + constructor(private eventsFor: (run: number) => BaseEvent[]) { + super(); + } + run(): Observable { + const events = this.eventsFor(this.runCount); + this.runCount++; + return new Observable((subscriber) => { + for (const event of events) subscriber.next(event); + subscriber.complete(); + }); + } +} + +/** + * Decides what to emit based on its OWN `this.messages` (the downstream + * agent's persistent state) — mirroring how `defaultApplyEvents` seeds the + * apply chain from `agent.messages`, not from `input.messages`. While a + * matching tool call sits unresolved in `this.messages` it keeps re-emitting + * it; once a `role: "tool"` result is present it produces a final text + * answer. This is the only mock that reproduces the coupling the middleware's + * `next.messages.push(...)` defends against: if that sync is removed, the + * result never lands in `this.messages` and this agent loops forever + * (re-emitting the same call) instead of terminating after one execution. + */ +class StatefulMockAgent extends AbstractAgent { + public runCount = 0; + constructor(private toolCallName: string) { + super(); + } + run(): Observable { + this.runCount++; + const resolved = this.messages.some((m) => m.role === "tool"); + const events = resolved + ? [runStarted(`r${this.runCount}`), ...textMessage("m", "done"), runFinished(`r${this.runCount}`)] + : [runStarted(`r${this.runCount}`), ...toolCall("c1", this.toolCallName), runFinished(`r${this.runCount}`)]; + return new Observable((subscriber) => { + for (const event of events) subscriber.next(event); + subscriber.complete(); + }); + } +} + +function createRunAgentInput( + overrides: Partial = {}, +): RunAgentInput { + return { + threadId: THREAD, + runId: "r", + tools: [], + context: [], + forwardedProps: {}, + state: {}, + messages: [], + ...overrides, + }; +} + +async function collectEvents(o: Observable): Promise { + return firstValueFrom(o.pipe(toArray())); +} + +const weatherServer = (): { type: "http"; url: string; serverId: string } => ({ + type: "http", + url: "https://example.com/mcp", + serverId: "s", +}); + +beforeEach(() => { + mockConnect.mockReset().mockResolvedValue(undefined); + mockClose.mockReset().mockResolvedValue(undefined); + mockListTools.mockReset().mockResolvedValue({ tools: [] }); + mockCallTool + .mockReset() + .mockResolvedValue({ content: [{ type: "text", text: "ok" }] }); + sseTransportCalls.length = 0; + httpTransportCalls.length = 0; +}); + +// --- Tool injection ----------------------------------------------------------- +describe("MCPMiddleware — tool injection", () => { + async function injectedNames( + middleware: MCPMiddleware, + input: RunAgentInput, + ): Promise { + const next = new BatchMockAgent([[runStarted(), runFinished()]]); + await collectEvents(middleware.run(input, next)); + return next.runCalls[0].tools.map((t) => t.name); + } + + it("passes through untouched with no servers", async () => { + const names = await injectedNames(new MCPMiddleware(), createRunAgentInput()); + expect(names).toEqual([]); + expect(mockConnect).not.toHaveBeenCalled(); + }); + + it("prefixes injected tools as mcp__{server}__{tool}", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "list_issues", inputSchema: {} }] }); + const names = await injectedNames( + new MCPMiddleware([{ ...weatherServer(), serverId: "github" }]), + createRunAgentInput(), + ); + expect(names).toEqual(["mcp__github__list_issues"]); + }); + + it("falls back to server{index} without serverId", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "ping", inputSchema: {} }] }); + const names = await injectedNames( + new MCPMiddleware([{ type: "http", url: "https://example.com/mcp" }]), + createRunAgentInput(), + ); + expect(names).toEqual(["mcp__server0__ping"]); + }); + + it("merges MCP tools after existing input tools", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "ping", inputSchema: {} }] }); + const existing: Tool = { name: "existing", description: "", parameters: {} }; + const names = await injectedNames( + new MCPMiddleware([weatherServer()]), + createRunAgentInput({ tools: [existing] }), + ); + expect(names).toEqual(["existing", "mcp__s__ping"]); + }); + + it("dedupes colliding names", async () => { + mockListTools.mockResolvedValue({ + tools: [{ name: "dup", inputSchema: {} }, { name: "dup", inputSchema: {} }], + }); + const names = await injectedNames( + new MCPMiddleware([weatherServer()]), + createRunAgentInput(), + ); + expect(names).toEqual(["mcp__s__dup", "mcp__s__dup_1"]); + }); + + it("truncates names to 64 characters", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "t".repeat(80), inputSchema: {} }] }); + const names = await injectedNames( + new MCPMiddleware([weatherServer()]), + createRunAgentInput(), + ); + expect(names[0].length).toBe(64); + }); + + it("skips a server that fails to list, keeping the others", async () => { + mockListTools + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ tools: [{ name: "ok", inputSchema: {} }] }); + const names = await injectedNames( + new MCPMiddleware([ + { type: "http", url: "https://bad/mcp", serverId: "bad" }, + { type: "http", url: "https://good/mcp", serverId: "good" }, + ]), + createRunAgentInput(), + ); + expect(names).toEqual(["mcp__good__ok"]); + }); +}); + +// --- Execution loop ----------------------------------------------------------- +describe("MCPMiddleware — execution loop", () => { + it("does not interfere when no MCP tool calls are open", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const next = new BatchMockAgent([ + [runStarted(), ...textMessage("m1", "hi"), runFinished()], + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + expect(mockCallTool).not.toHaveBeenCalled(); + expect(next.runCalls).toHaveLength(1); + expect(received.map((e) => e.type)).toEqual([ + EventType.RUN_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.RUN_FINISHED, + ]); + }); + + it("ignores a call that matches the prefix but is not a known MCP tool", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const next = new BatchMockAgent([ + [runStarted(), ...toolCall("c1", "mcp__s__ghost"), runFinished()], + ]); + await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + expect(mockCallTool).not.toHaveBeenCalled(); + expect(next.runCalls).toHaveLength(1); + }); + + it("scenario 1: executes our tool, emits result, then runs again", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + mockCallTool.mockResolvedValue({ content: [{ type: "text", text: "sunny" }] }); + const next = new BatchMockAgent([ + [runStarted(), ...toolCall("c1", "mcp__s__weather", '{"city":"sf"}'), runFinished()], + [runStarted("r2"), ...textMessage("m2", "It is sunny."), runFinished("r2")], + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + expect(mockCallTool).toHaveBeenCalledTimes(1); + expect(mockCallTool).toHaveBeenCalledWith({ + name: "weather", + arguments: { city: "sf" }, + }); + const result = received.find((e) => e.type === EventType.TOOL_CALL_RESULT); + expect((result as unknown as { content: string }).content).toBe("sunny"); + expect(next.runCalls).toHaveLength(2); + expect(next.runCalls[1].messages.some((m) => m.role === "tool")).toBe(true); + }); + + it("scenario 2: stops when a non-MCP tool call is still open", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const next = new BatchMockAgent([ + [ + runStarted(), + ...toolCall("c1", "mcp__s__weather"), + ...toolCall("c2", "frontendTool"), + runFinished(), + ], + [runStarted("r2"), runFinished("r2")], + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + expect(mockCallTool).toHaveBeenCalledTimes(1); + expect(next.runCalls).toHaveLength(1); + expect(received.filter((e) => e.type === EventType.TOOL_CALL_RESULT)).toHaveLength(1); + }); + + it("assembles tool-call arguments streamed across multiple chunks", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const next = new BatchMockAgent([ + [runStarted(), ...toolCall("c1", "mcp__s__weather", ['{"ci', 'ty":', '"sf"}']), runFinished()], + [runStarted("r2"), ...textMessage("m2", "done"), runFinished("r2")], + ]); + await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + expect(mockCallTool).toHaveBeenCalledWith({ + name: "weather", + arguments: { city: "sf" }, + }); + }); + + it("loops multiple hops until no MCP calls remain", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const next = new BatchMockAgent([ + [runStarted(), ...toolCall("c1", "mcp__s__weather"), runFinished()], + [runStarted("r2"), ...toolCall("c2", "mcp__s__weather"), runFinished("r2")], + [runStarted("r3"), ...textMessage("m3", "finally done"), runFinished("r3")], + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + expect(mockCallTool).toHaveBeenCalledTimes(2); + expect(next.runCalls).toHaveLength(3); + + // Single-run presentation must hold across TWO hops (3 inner runs): the + // consumer sees exactly one RUN_STARTED and one RUN_FINISHED, and both + // tool results land before that single terminal RUN_FINISHED. + const types = received.map((e) => e.type); + expect(types.filter((t) => t === EventType.RUN_STARTED)).toHaveLength(1); + expect(types.filter((t) => t === EventType.RUN_FINISHED)).toHaveLength(1); + expect(types[0]).toBe(EventType.RUN_STARTED); + expect(types[types.length - 1]).toBe(EventType.RUN_FINISHED); + const lastResult = types.lastIndexOf(EventType.TOOL_CALL_RESULT); + expect(lastResult).toBeGreaterThan(-1); + expect(lastResult).toBeLessThan(types.length - 1); // before the RUN_FINISHED + }); + + it("syncs tool results into agent.messages so a state-seeded agent terminates", async () => { + // StatefulMockAgent emits based on its own `this.messages` (like the real + // apply chain). It only stops re-emitting the tool call once a tool result + // is present in those messages — which only happens because the middleware + // pushes results into `next.messages`. If that sync regresses, this agent + // loops to maxIterations instead of executing exactly once. + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + mockCallTool.mockResolvedValue({ content: [{ type: "text", text: "sunny" }] }); + const next = new StatefulMockAgent("mcp__s__weather"); + const received = await collectEvents( + new MCPMiddleware([weatherServer()], { maxIterations: 10 }).run( + createRunAgentInput(), + next, + ), + ); + expect(mockCallTool).toHaveBeenCalledTimes(1); // not maxIterations + expect(next.runCount).toBe(2); // tool round + final text round + const types = received.map((e) => e.type); + expect(types.filter((t) => t === EventType.RUN_FINISHED)).toHaveLength(1); + expect(received.some((e) => e.type === EventType.TEXT_MESSAGE_CONTENT)).toBe(true); + }); + + it("executes multiple MCP calls in one round, surfacing per-call failures", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + mockCallTool + .mockResolvedValueOnce({ content: [{ type: "text", text: "sunny" }] }) + .mockRejectedValueOnce(new Error("server exploded")); + const next = new BatchMockAgent([ + [ + runStarted(), + ...toolCall("c1", "mcp__s__weather"), + ...toolCall("c2", "mcp__s__weather"), + runFinished(), + ], + [runStarted("r2"), ...textMessage("m2", "ok"), runFinished("r2")], + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + const results = received.filter((e) => e.type === EventType.TOOL_CALL_RESULT); + expect(results).toHaveLength(2); + const contents = results.map((r) => (r as unknown as { content: string }).content); + expect(contents).toContain("sunny"); + expect(contents.some((c) => c.includes("Error executing tool weather"))).toBe(true); + expect(next.runCalls).toHaveLength(2); // still looped — failures don't block + }); + + it("stringifies non-text tool results", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + mockCallTool.mockResolvedValue({ + content: [{ type: "image", data: "base64..." }], + }); + const next = new BatchMockAgent([ + [runStarted(), ...toolCall("c1", "mcp__s__weather"), runFinished()], + [runStarted("r2"), runFinished("r2")], + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + const result = received.find((e) => e.type === EventType.TOOL_CALL_RESULT); + const content = (result as unknown as { content: string }).content; + expect(content).toContain("image"); + }); + + it("stops at maxIterations instead of looping forever", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + // This agent ALWAYS emits a fresh unresolved MCP tool call. + const next = new LoopingMockAgent((n) => [ + runStarted(), + ...toolCall(`c${n}`, "mcp__s__weather"), + runFinished(), + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()], { maxIterations: 3 }).run( + createRunAgentInput(), + next, + ), + ); + expect(mockCallTool).toHaveBeenCalledTimes(3); + // 3 execution rounds → 4 agent runs (the 4th detects the cap and stops). + expect(next.runCount).toBe(4); + expect(warn).toHaveBeenCalled(); + // Hitting the cap must still flush a terminal RUN_FINISHED — a consumer + // waiting on it would otherwise hang. + const types = received.map((e) => e.type); + expect(types.filter((t) => t === EventType.RUN_FINISHED)).toHaveLength(1); + expect(types[types.length - 1]).toBe(EventType.RUN_FINISHED); + warn.mockRestore(); + }); + + it("does not execute tools when the run errors", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const next = new BatchMockAgent([ + [runStarted(), ...toolCall("c1", "mcp__s__weather"), runError("kaboom")], + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + expect(mockCallTool).not.toHaveBeenCalled(); + expect(next.runCalls).toHaveLength(1); + expect(received.some((e) => e.type === EventType.RUN_ERROR)).toBe(true); + }); + + it("stops the loop when the subscription is cancelled mid-execution", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + let releaseCall: (v: unknown) => void = () => {}; + mockCallTool.mockImplementation( + () => new Promise((resolve) => (releaseCall = resolve)), + ); + const next = new BatchMockAgent([ + [runStarted(), ...toolCall("c1", "mcp__s__weather"), runFinished()], + [runStarted("r2"), runFinished("r2")], + ]); + const received: BaseEvent[] = []; + const sub = new MCPMiddleware([weatherServer()]) + .run(createRunAgentInput(), next) + .subscribe((e) => received.push(e)); + + // Wait until execution is in-flight (callTool invoked), then cancel. + await vi.waitFor(() => expect(mockCallTool).toHaveBeenCalledTimes(1)); + sub.unsubscribe(); + releaseCall({ content: [{ type: "text", text: "late" }] }); + await new Promise((r) => setTimeout(r, 10)); + + expect(received.some((e) => e.type === EventType.TOOL_CALL_RESULT)).toBe(false); + expect(next.runCalls).toHaveLength(1); // never looped + }); +}); + +// --- Headers + listTools caching ---------------------------------------------- +describe("MCPMiddleware — headers + caching", () => { + it("passes config headers to the streamable HTTP transport", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const next = new BatchMockAgent([[runStarted(), runFinished()]]); + await collectEvents( + new MCPMiddleware([ + { + type: "http", + url: "https://example.com/mcp", + serverId: "s", + headers: { + Authorization: "Bearer abc", + "X-Cpki-User-Id": "user-1", + }, + }, + ]).run(createRunAgentInput(), next), + ); + expect(httpTransportCalls).toHaveLength(1); + expect(httpTransportCalls[0].opts).toEqual({ + requestInit: { + headers: { Authorization: "Bearer abc", "X-Cpki-User-Id": "user-1" }, + }, + }); + }); + + it("omits transport options when no headers are configured", async () => { + mockListTools.mockResolvedValue({ tools: [] }); + const next = new BatchMockAgent([[runStarted(), runFinished()]]); + await collectEvents( + new MCPMiddleware([ + { type: "http", url: "https://example.com/mcp", serverId: "s" }, + ]).run(createRunAgentInput(), next), + ); + expect(httpTransportCalls).toHaveLength(1); + expect(httpTransportCalls[0].opts).toBeUndefined(); + }); + + it("also passes headers to the SSE transport", async () => { + mockListTools.mockResolvedValue({ tools: [] }); + const next = new BatchMockAgent([[runStarted(), runFinished()]]); + await collectEvents( + new MCPMiddleware([ + { + type: "sse", + url: "https://example.com/sse", + serverId: "s", + headers: { Authorization: "Bearer xyz" }, + }, + ]).run(createRunAgentInput(), next), + ); + expect(sseTransportCalls).toHaveLength(1); + expect(sseTransportCalls[0].opts).toEqual({ + requestInit: { headers: { Authorization: "Bearer xyz" } }, + }); + }); + + it("lists tools only once per middleware instance, across runs", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const middleware = new MCPMiddleware([weatherServer()]); + + const first = new BatchMockAgent([[runStarted(), runFinished()]]); + await collectEvents(middleware.run(createRunAgentInput(), first)); + + const second = new BatchMockAgent([[runStarted("r2"), runFinished("r2")]]); + await collectEvents(middleware.run(createRunAgentInput({ runId: "r2" }), second)); + + expect(mockListTools).toHaveBeenCalledTimes(1); + // The second run still received the cached tool injected. + expect(second.runCalls[0].tools.map((t) => t.name)).toContain("mcp__s__weather"); + }); + + it("does not retry a failed listing on the second run", async () => { + mockListTools.mockRejectedValue(new Error("listing died")); + const middleware = new MCPMiddleware([weatherServer()]); + + const first = new BatchMockAgent([[runStarted(), runFinished()]]); + await collectEvents(middleware.run(createRunAgentInput(), first)); + + const second = new BatchMockAgent([[runStarted("r2"), runFinished("r2")]]); + await collectEvents(middleware.run(createRunAgentInput({ runId: "r2" }), second)); + + // The failed listing is cached too — we don't keep hammering broken servers. + expect(mockListTools).toHaveBeenCalledTimes(1); + // No tools were injected on either run. + expect(first.runCalls[0].tools).toHaveLength(0); + expect(second.runCalls[0].tools).toHaveLength(0); + }); +}); + +// --- Run-lifecycle ordering --------------------------------------------------- +// AG-UI verify rejects events sent after RUN_FINISHED until a new RUN_STARTED. +// The middleware presents the whole tool loop as ONE run: a single +// RUN_STARTED first, a single RUN_FINISHED last, and every TOOL_CALL_RESULT +// in between — continuation runs' RUN_STARTED/RUN_FINISHED are hidden. +describe("MCPMiddleware — RUN_FINISHED ordering", () => { + it("presents a loop as one run: single STARTED/FINISHED, results inside", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + mockCallTool.mockResolvedValue({ content: [{ type: "text", text: "sunny" }] }); + const next = new BatchMockAgent([ + [runStarted(), ...toolCall("c1", "mcp__s__weather"), runFinished()], + [runStarted("r2"), ...textMessage("m2", "done"), runFinished("r2")], + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + + const types = received.map((e) => e.type); + const idxResult = types.indexOf(EventType.TOOL_CALL_RESULT); + const idxFinish = types.indexOf(EventType.RUN_FINISHED); + + // Exactly one RUN_STARTED and one RUN_FINISHED — the continuation's are hidden. + expect(types.filter((t) => t === EventType.RUN_STARTED)).toHaveLength(1); + expect(types.filter((t) => t === EventType.RUN_FINISHED)).toHaveLength(1); + // RUN_STARTED first, RUN_FINISHED last. + expect(types[0]).toBe(EventType.RUN_STARTED); + expect(types[types.length - 1]).toBe(EventType.RUN_FINISHED); + // The tool result lands inside the run, before the single RUN_FINISHED. + expect(idxResult).toBeGreaterThan(-1); + expect(idxFinish).toBeGreaterThan(idxResult); + }); + + it("emits TOOL_CALL_RESULTs before RUN_FINISHED in scenario 2 (stop)", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const next = new BatchMockAgent([ + [ + runStarted(), + ...toolCall("c1", "mcp__s__weather"), + ...toolCall("c2", "frontendTool"), + runFinished(), + ], + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + + const types = received.map((e) => e.type); + const idxResult = types.indexOf(EventType.TOOL_CALL_RESULT); + const idxFinish = types.indexOf(EventType.RUN_FINISHED); + expect(idxResult).toBeGreaterThan(-1); + expect(idxFinish).toBeGreaterThan(idxResult); + // Exactly one RUN_FINISHED — the held one, emitted after results. + expect(types.filter((t) => t === EventType.RUN_FINISHED)).toHaveLength(1); + }); + + it("non-interference: a single RUN_FINISHED still arrives last", async () => { + mockListTools.mockResolvedValue({ tools: [{ name: "weather", inputSchema: {} }] }); + const next = new BatchMockAgent([ + [runStarted(), ...textMessage("m1", "hi"), runFinished()], + ]); + const received = await collectEvents( + new MCPMiddleware([weatherServer()]).run(createRunAgentInput(), next), + ); + expect(received[received.length - 1].type).toBe(EventType.RUN_FINISHED); + }); +}); diff --git a/middlewares/mcp-middleware/package.json b/middlewares/mcp-middleware/package.json new file mode 100644 index 0000000000..93096d20f7 --- /dev/null +++ b/middlewares/mcp-middleware/package.json @@ -0,0 +1,52 @@ +{ + "name": "@ag-ui/mcp-middleware", + "author": "Markus Ecker ", + "version": "0.0.1", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ag-ui-protocol/ag-ui.git" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "files": [ + "dist/**" + ], + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "clean": "git clean -fdX --exclude=\"!.env\"", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest", + "test:exports": "publint --strict && attw --pack", + "link:global": "pnpm link --global", + "unlink:global": "pnpm unlink --global" + }, + "dependencies": { + "@ag-ui/client": "workspace:*", + "@modelcontextprotocol/sdk": "^1.0.0" + }, + "peerDependencies": { + "rxjs": "7.8.1" + }, + "devDependencies": { + "@types/node": "^20.11.19", + "@vitest/coverage-istanbul": "^4.0.18", + "publint": "^0.3.12", + "@arethetypeswrong/cli": "^0.17.4", + "vitest": "^4.0.18", + "tsdown": "^0.20.1", + "typescript": "^5.3.3" + }, + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs" + }, + "./package.json": "./package.json" + } +} diff --git a/middlewares/mcp-middleware/src/index.ts b/middlewares/mcp-middleware/src/index.ts new file mode 100644 index 0000000000..2267a452c9 --- /dev/null +++ b/middlewares/mcp-middleware/src/index.ts @@ -0,0 +1,579 @@ +import { + Middleware, + EventType, + type AbstractAgent, + type BaseEvent, + type Message, + type RunAgentInput, + type Tool, + type ToolCall, + type ToolCallResultEvent, +} from "@ag-ui/client"; +import { Observable, type Subscription } from "rxjs"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +/** + * MCP Client configuration for HTTP (streamable) transport. + */ +export interface MCPClientConfigHTTP { + type: "http"; + url: string; + headers?: Record; + serverId?: string; +} + +/** + * MCP Client configuration for SSE transport. + */ +export interface MCPClientConfigSSE { + type: "sse"; + url: string; + headers?: Record; + serverId?: string; +} + +/** + * MCP Client configuration — one of the supported transports. + */ +export type MCPClientConfig = MCPClientConfigHTTP | MCPClientConfigSSE; + +/** + * Maximum length of a tool name. Bounded by the strictest mainstream LLM + * provider constraint (OpenAI function names: `^[a-zA-Z0-9_-]{1,64}$`), + * which is also why `__` — not `:` or `/` — is used as the delimiter. + */ +export const MAX_TOOL_NAME_LENGTH = 64; + +/** + * The namespace prefix applied to every MCP-sourced tool. Mirrors the + * Claude Agent SDK convention: `mcp__{server}__{tool}`. + */ +export const MCP_TOOL_NAME_PREFIX = "mcp"; + +/** + * Default cap on the number of MCP tool-execution rounds in a single + * `run()`. Prevents a runaway loop (and unbounded cost) if the model keeps + * calling MCP tools forever. + */ +export const DEFAULT_MAX_ITERATIONS = 32; + +/** + * Options for {@link MCPMiddleware}. + */ +export interface MCPMiddlewareOptions { + /** + * Maximum number of MCP tool-execution rounds before the middleware stops + * looping and lets the run finish. Defaults to {@link DEFAULT_MAX_ITERATIONS}. + */ + maxIterations?: number; +} + +/** + * A tool resolved from an MCP server, carrying the metadata needed to map + * the exposed (prefixed) name back to its origin. The mapping is kept as a + * descriptor — never reconstructed by string-splitting the exposed name — + * so server ids or tool names containing `__` can't corrupt the round-trip. + */ +export interface ResolvedMCPTool { + /** The (prefixed, possibly truncated/deduped) tool exposed to the agent. */ + tool: Tool; + /** The original tool name as reported by the MCP server. */ + originalName: string; + /** The server this tool came from. */ + serverConfig: MCPClientConfig; +} + +/** + * Restrict a name segment to characters valid across LLM providers. + */ +function sanitizeSegment(segment: string): string { + return segment.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +/** + * Build a unique, length-bounded, namespaced tool name. + * + * Shape: `mcp__{serverId}__{toolName}` (sanitized), truncated to + * {@link MAX_TOOL_NAME_LENGTH}. If the truncated name collides with one + * already in `used`, a `_N` suffix is appended (and the base re-truncated to + * make room) until unique. + */ +function makeUniqueToolName( + serverId: string, + toolName: string, + used: Set, +): string { + const base = `${MCP_TOOL_NAME_PREFIX}__${sanitizeSegment(serverId)}__${sanitizeSegment(toolName)}`; + let candidate = base.slice(0, MAX_TOOL_NAME_LENGTH); + if (!used.has(candidate)) { + return candidate; + } + for (let i = 1; ; i++) { + const suffix = `_${i}`; + candidate = base.slice(0, MAX_TOOL_NAME_LENGTH - suffix.length) + suffix; + if (!used.has(candidate)) { + return candidate; + } + } +} + +/** + * Collect assistant tool calls that have no corresponding `role: "tool"` + * result message — i.e. the still-open tool calls. + */ +function getOpenToolCalls(messages: Message[]): ToolCall[] { + const allToolCalls: ToolCall[] = []; + for (const message of messages) { + if (message.role === "assistant" && "toolCalls" in message && message.toolCalls) { + allToolCalls.push(...message.toolCalls); + } + } + const resolvedIds = new Set(); + for (const message of messages) { + if (message.role === "tool" && "toolCallId" in message) { + resolvedIds.add(message.toolCallId); + } + } + return allToolCalls.filter((tc) => !resolvedIds.has(tc.id)); +} + +/** + * Close an MCP client without letting a `close()` failure escape — a throw + * here would otherwise clobber the value being returned from the enclosing + * `try`/`catch` (or abort the listing loop). Best-effort: log and move on. + */ +async function safeClose(client: Client | undefined): Promise { + if (!client) return; + try { + await client.close(); + } catch (error) { + console.error("[MCPMiddleware] Failed to close MCP client:", error); + } +} + +/** + * Extract text content from an MCP `callTool` result, falling back to a JSON + * stringification of the content when it isn't plain text. + */ +function extractTextContent(mcpResult: unknown): string { + const result = mcpResult as { content?: unknown }; + if (Array.isArray(result.content)) { + const text = result.content + .filter( + (c): c is { type: "text"; text: string } => + !!c && + typeof c === "object" && + (c as { type?: unknown }).type === "text" && + typeof (c as { text?: unknown }).text === "string", + ) + .map((c) => c.text) + .join("\n"); + return text || JSON.stringify(result.content); + } + return JSON.stringify(result.content ?? result); +} + +/** + * One MCP tool as returned by `listTools`, paired with the server it came + * from. Cached on the middleware instance so we only hit the network once. + */ +interface ListedTool { + mcpTool: { + name: string; + description?: string; + inputSchema?: Record; + }; + serverConfig: MCPClientConfig; + serverId: string; +} + +/** + * AG-UI middleware that lists tools from one or more MCP servers, injects + * them into the agent run (namespaced as `mcp__{server}__{tool}`), and + * executes the resulting MCP tool calls server-side. + * + * Loop, on each agent `RUN_FINISHED`: + * - Find open tool calls (assistant calls without a result message). + * - Of those, execute the ones that target our injected MCP tools and emit + * a `TOOL_CALL_RESULT` for each. + * - If no open tool calls remain afterwards, start another run with the new + * result messages appended (same threadId, fresh runId). + * - If open tool calls still remain (e.g. frontend tools), stop and let the + * frontend resolve them. + * + * If a run produces no open tool calls targeting our MCP tools, the + * middleware does not interfere at all — every event is forwarded verbatim. + */ +export class MCPMiddleware extends Middleware { + private readonly mcpServers: MCPClientConfig[]; + private readonly maxIterations: number; + /** + * Lazily-populated cache of the full `listTools` result across every + * configured server. Populated on the first `run()` and reused for the + * lifetime of the instance — so listing happens exactly once per + * middleware instance, no matter how many runs come through. + */ + private listingPromise: Promise | null = null; + + constructor( + mcpServers: MCPClientConfig[] = [], + options: MCPMiddlewareOptions = {}, + ) { + super(); + this.mcpServers = mcpServers; + // Clamp to a positive integer — a 0/negative/NaN cap would otherwise + // trip the runaway guard on the first round and silently disable tool + // execution entirely. + const requested = options.maxIterations ?? DEFAULT_MAX_ITERATIONS; + this.maxIterations = Number.isFinite(requested) + ? Math.max(1, Math.floor(requested)) + : DEFAULT_MAX_ITERATIONS; + } + + run(input: RunAgentInput, next: AbstractAgent): Observable { + if (this.mcpServers.length === 0) { + return this.runNext(input, next); + } + + return new Observable((subscriber) => { + let cancelled = false; + let activeSub: Subscription | undefined; + // Number of MCP tool-execution rounds performed so far in this run. + let toolRounds = 0; + + // Run the agent once; on completion decide whether to execute MCP tool + // calls and loop. `toolMap` (exposed name -> origin) is built once and + // reused across iterations. + // + // Run-lifecycle policy: from the consumer's perspective, the entire + // tool-execution loop is presented as a SINGLE run. We forward the + // first run's `RUN_STARTED` and suppress every subsequent + // `RUN_STARTED`. We buffer *every* run's `RUN_FINISHED` (each one + // replacing the prior) and flush only the final one when the loop + // actually stops. This keeps any downstream consumer (or persistence + // layer) that treats `RUN_FINISHED` as "the assistant turn is over" + // from prematurely closing things between iterations. + // + // Why we sync `next.messages`: `runNextWithState` uses + // `defaultApplyEvents`, which seeds its `messages` from + // `agent.messages` (the downstream agent's persistent state) — NOT + // from `input.messages`. So passing tool results only via + // `runInput.messages` makes them visible to the LLM call but + // INVISIBLE to the next iteration's apply chain, which then sees the + // assistant tool call as still-open and the model re-emits it. The + // chained-agent proxy exposes `.messages` as a getter returning the + // underlying array reference, so mutating it via `.push` is the way + // to keep both the model and the apply chain in sync. + const runOnce = ( + runInput: RunAgentInput, + toolMap: Map, + isContinuation: boolean, + ): void => { + let latestMessages: Message[] = runInput.messages; + let errored = false; + let bufferedRunFinished: BaseEvent | null = null; + + activeSub = this.runNextWithState(runInput, next).subscribe({ + next: ({ event, messages }) => { + latestMessages = messages; + if (event.type === EventType.RUN_ERROR) { + errored = true; + subscriber.next(event); + return; + } + if (event.type === EventType.RUN_FINISHED) { + // Always buffer; only flushed when the loop truly stops. + bufferedRunFinished = event; + return; + } + if (event.type === EventType.RUN_STARTED && isContinuation) { + // Hide continuation run boundary — consumer sees one run. + return; + } + subscriber.next(event); + }, + error: (err) => subscriber.error(err), + complete: () => { + // Route any rejection from the async continuation back onto the + // stream — otherwise it becomes an unhandled rejection and the + // observable silently never completes. + onRunComplete( + runInput, + latestMessages, + toolMap, + errored, + bufferedRunFinished, + ).catch((err) => subscriber.error(err)); + }, + }); + }; + + const onRunComplete = async ( + runInput: RunAgentInput, + messages: Message[], + toolMap: Map, + errored: boolean, + bufferedRunFinished: BaseEvent | null, + ): Promise => { + if (cancelled) return; + + // The run errored — do not execute tools or loop; the RUN_ERROR has + // already been forwarded. There's no RUN_FINISHED to flush. + if (errored) { + subscriber.complete(); + return; + } + + const openCalls = getOpenToolCalls(messages); + const ourCalls = openCalls.filter((tc) => toolMap.has(tc.function.name)); + + // Nothing for us — flush the buffered RUN_FINISHED untouched and stop. + if (ourCalls.length === 0) { + if (bufferedRunFinished) subscriber.next(bufferedRunFinished); + subscriber.complete(); + return; + } + + // Runaway guard: flush RUN_FINISHED and stop without executing more. + if (toolRounds >= this.maxIterations) { + console.warn( + `[MCPMiddleware] Reached maxIterations (${this.maxIterations}); ` + + `leaving ${ourCalls.length} MCP tool call(s) unexecuted.`, + ); + if (bufferedRunFinished) subscriber.next(bufferedRunFinished); + subscriber.complete(); + return; + } + toolRounds++; + + // Execute our MCP tool calls (in parallel), then emit results in + // their original order — *before* flushing the held RUN_FINISHED — + // so the stream stays valid under AG-UI verify. + const executed = await Promise.all( + ourCalls.map(async (tc) => { + const resolved = toolMap.get(tc.function.name)!; + const content = await this.executeToolCall(resolved, tc); + return { tc, content }; + }), + ); + if (cancelled) return; + + const resultMessages: Message[] = []; + for (const { tc, content } of executed) { + const messageId = crypto.randomUUID(); + const resultEvent: ToolCallResultEvent = { + type: EventType.TOOL_CALL_RESULT, + messageId, + toolCallId: tc.id, + content, + role: "tool", + }; + subscriber.next(resultEvent); + resultMessages.push({ + id: messageId, + role: "tool", + content, + toolCallId: tc.id, + }); + } + + const updatedMessages = [...messages, ...resultMessages]; + const stillOpen = getOpenToolCalls(updatedMessages); + + // Scenario 2: other (e.g. frontend) tool calls are still open — we + // don't trigger another run. Flush the buffered RUN_FINISHED and + // hand off to the frontend. + if (stillOpen.length > 0) { + if (bufferedRunFinished) subscriber.next(bufferedRunFinished); + subscriber.complete(); + return; + } + + // Sync our tool results into the downstream agent's persistent + // message state so the next iteration's `defaultApplyEvents` (which + // seeds from `agent.messages`, not `input.messages`) sees the tool + // calls as resolved instead of re-emitting them. + next.messages.push(...resultMessages); + + // Scenario 1: everything is resolved — start a continuation run + // WITHOUT flushing RUN_FINISHED. The continuation's own RUN_STARTED + // will be suppressed by `runOnce`, and its RUN_FINISHED will be + // buffered (and only flushed when the loop truly stops). The + // consumer sees one seamless run. + runOnce( + { ...runInput, runId: crypto.randomUUID(), messages: updatedMessages }, + toolMap, + true, + ); + }; + + // Bootstrap: list tools once, inject, run. + void (async () => { + try { + const resolved = await this.resolveTools( + new Set(input.tools.map((t) => t.name)), + ); + if (cancelled) return; + const toolMap = new Map( + resolved.map((r) => [r.tool.name, r]), + ); + runOnce( + { ...input, tools: [...input.tools, ...resolved.map((r) => r.tool)] }, + toolMap, + false, + ); + } catch (err) { + subscriber.error(err); + } + })(); + + return () => { + cancelled = true; + activeSub?.unsubscribe(); + }; + }); + } + + /** + * Resolve injectable tool descriptors for this run. Listing is cached + * per-instance (see {@link listingPromise}); only the name resolution + * (prefix / truncate / dedupe) is recomputed per run, since dedupe needs + * the current `input.tools` as its seed. + */ + private async resolveTools( + existingNames: Set, + ): Promise { + const listed = await this.listAllTools(); + const used = new Set(existingNames); + return listed.map((entry) => { + const name = makeUniqueToolName(entry.serverId, entry.mcpTool.name, used); + used.add(name); + return { + tool: { + name, + description: entry.mcpTool.description ?? "", + parameters: entry.mcpTool.inputSchema ?? { + type: "object", + properties: {}, + }, + }, + originalName: entry.mcpTool.name, + serverConfig: entry.serverConfig, + }; + }); + } + + /** + * List tools from every configured server, exactly once per instance. A + * server that fails to connect or list is logged and skipped — one bad + * server never blocks the other servers' tools. The failure is part of + * the cached result, so we don't keep retrying broken servers. + */ + private listAllTools(): Promise { + if (this.listingPromise === null) { + this.listingPromise = this.doListAllTools(); + } + return this.listingPromise; + } + + private async doListAllTools(): Promise { + const listed: ListedTool[] = []; + let index = 0; + for (const serverConfig of this.mcpServers) { + const serverId = serverConfig.serverId ?? `server${index}`; + index++; + + let client: Client | undefined; + try { + client = await this.connect(serverConfig); + const { tools } = await client.listTools(); + for (const mcpTool of tools) { + listed.push({ mcpTool, serverConfig, serverId }); + } + } catch (error) { + console.error( + `[MCPMiddleware] Failed to list tools from MCP server ${serverConfig.url}:`, + error, + ); + } finally { + await safeClose(client); + } + } + return listed; + } + + /** + * Execute a single MCP tool call against its origin server and return the + * result as text. Errors are caught and returned as the result content so + * the agentic loop can react rather than crash. + */ + private async executeToolCall( + resolved: ResolvedMCPTool, + toolCall: ToolCall, + ): Promise { + let args: Record = {}; + try { + args = toolCall.function.arguments + ? (JSON.parse(toolCall.function.arguments) as Record) + : {}; + } catch { + // Leave args empty if the model emitted malformed JSON, but surface it + // — running a tool with no arguments is rarely what the model intended. + console.warn( + `[MCPMiddleware] Malformed JSON arguments for ${resolved.originalName}; ` + + `executing with empty arguments.`, + ); + } + + let client: Client | undefined; + try { + client = await this.connect(resolved.serverConfig); + const result = await client.callTool({ + name: resolved.originalName, + arguments: args, + }); + return extractTextContent(result); + } catch (error) { + // The error is returned as the tool result so the agentic loop can + // react; also log it server-side so an operator has observability + // (the model-facing string is the only other trace of the failure). + console.error( + `[MCPMiddleware] Tool execution failed for ${resolved.originalName}:`, + error, + ); + return `Error executing tool ${resolved.originalName}: ${String(error)}`; + } finally { + await safeClose(client); + } + } + + /** + * Open a connected MCP client for a server config. If `headers` is set on + * the config, they're stamped on every outbound request via the + * transport's `requestInit`. This is the seam the runtime uses to forward + * per-request auth (e.g. `Authorization: Bearer …`, `X-Cpki-User-Id: …`): + * the middleware is constructed per request, so static headers in the + * config are effectively per-request. + * + * Caveat: for the SSE transport, `requestInit.headers` only applies to + * the POST channel — the SSE event stream uses `eventSourceInit`. For + * streamable HTTP (the typical case) it covers all traffic. + */ + private async connect(serverConfig: MCPClientConfig): Promise { + const opts = serverConfig.headers + ? { requestInit: { headers: serverConfig.headers } } + : undefined; + const transport = + serverConfig.type === "sse" + ? new SSEClientTransport(new URL(serverConfig.url), opts) + : new StreamableHTTPClientTransport(new URL(serverConfig.url), opts); + const client = new Client({ + name: "ag-ui-mcp-middleware", + version: "0.0.1", + }); + await client.connect(transport); + return client; + } +} diff --git a/middlewares/mcp-middleware/tsconfig.json b/middlewares/mcp-middleware/tsconfig.json new file mode 100644 index 0000000000..a7e91b190b --- /dev/null +++ b/middlewares/mcp-middleware/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node", + "skipLibCheck": true, + "strict": true, + "jsx": "react-jsx", + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["vitest/globals"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "stripInternal": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/middlewares/mcp-middleware/tsdown.config.ts b/middlewares/mcp-middleware/tsdown.config.ts new file mode 100644 index 0000000000..6f3030ec48 --- /dev/null +++ b/middlewares/mcp-middleware/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + exports: true, + fixedExtension: false, + sourcemap: true, + clean: true, + minify: true, +}); diff --git a/middlewares/mcp-middleware/vitest.config.ts b/middlewares/mcp-middleware/vitest.config.ts new file mode 100644 index 0000000000..5d97ce5f01 --- /dev/null +++ b/middlewares/mcp-middleware/vitest.config.ts @@ -0,0 +1,21 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["**/*.test.ts"], + passWithNoTests: true, + coverage: { + provider: "istanbul", + reporter: ["text", "json", "html"], + reportsDirectory: "./coverage", + }, + }, + resolve: { + alias: { + "@/": path.resolve(__dirname, "./src") + "/", + }, + }, +}); diff --git a/middlewares/middleware-starter/package.json b/middlewares/middleware-starter/package.json index b4222688d3..f1f2bf2129 100644 --- a/middlewares/middleware-starter/package.json +++ b/middlewares/middleware-starter/package.json @@ -2,6 +2,8 @@ "name": "@ag-ui/middleware-starter", "author": "Markus Ecker ", "version": "0.0.1", + "//": "private: this is a create-ag-ui-app scaffold template, intentionally excluded from the npm release pipeline (not a published package).", + "private": true, "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/nx.json b/nx.json index efa9f37449..1c1ab309af 100644 --- a/nx.json +++ b/nx.json @@ -14,10 +14,14 @@ "@ag-ui/encoder", "@ag-ui/proto", "create-ag-ui-app", + "@ag-ui/a2ui-toolkit", "@ag-ui/a2a", + "@ag-ui/adk", "@ag-ui/ag2", "@ag-ui/agno", + "@ag-ui/aws-strands", "@ag-ui/claude-agent-sdk", + "@ag-ui/cloudflare-agents", "@ag-ui/crewai", "@ag-ui/langchain", "@ag-ui/langgraph", @@ -28,7 +32,8 @@ "@ag-ui/watsonx", "@ag-ui/a2a-middleware", "@ag-ui/a2ui-middleware", - "@ag-ui/mcp-apps-middleware" + "@ag-ui/mcp-apps-middleware", + "@ag-ui/mcp-middleware" ] }, "namedInputs": { diff --git a/package.json b/package.json index d82f6f4a30..eede2bfc49 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "pnpm": { "overrides": { "langium": "3.2.0", + "@copilotkit/runtime>@ag-ui/a2ui-middleware": "0.0.8", "@copilotkit/runtime>@langchain/core": "0.3.80", + "@langchain/openai>@langchain/core": "0.3.80", "zod": "3.25.76", "@strands-agents/sdk>zod": "^4.4.3", "@ag-ui/aws-strands>zod": "^4.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 576724e6a3..e7712c0595 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,9 @@ settings: overrides: langium: 3.2.0 + '@copilotkit/runtime>@ag-ui/a2ui-middleware': 0.0.8 '@copilotkit/runtime>@langchain/core': 0.3.80 + '@langchain/openai>@langchain/core': 0.3.80 zod: 3.25.76 '@strands-agents/sdk>zod': ^4.4.3 '@ag-ui/aws-strands>zod': ^4.4.3 @@ -170,26 +172,26 @@ importers: specifier: ^0.2.58 version: 0.2.74(zod@3.25.76) '@copilotkit/a2ui-renderer': - specifier: 1.55.1 - version: 1.55.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: 1.60.1 + version: 1.60.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@copilotkit/react-core': - specifier: 1.55.1 - version: 1.55.1(@types/mdast@4.0.4)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(graphql@16.11.0)(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod@3.25.76) + specifier: 1.60.1 + version: 1.60.1(@types/mdast@4.0.4)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(graphql@16.11.0)(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod@3.25.76) '@copilotkit/react-ui': - specifier: 1.55.1 - version: 1.55.1(@ag-ui/core@sdks+typescript+packages+core)(@types/mdast@4.0.4)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(graphql@16.11.0)(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod@3.25.76) + specifier: 1.60.1 + version: 1.60.1(@ag-ui/core@sdks+typescript+packages+core)(@types/mdast@4.0.4)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(graphql@16.11.0)(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod@3.25.76) '@copilotkit/runtime': - specifier: 1.55.1 - version: 1.55.1(c3c32557d1ac98731bd405b9a6dd8f69) + specifier: 1.60.1 + version: 1.60.1(c82c70f68f9b62c68411914c7e649746) '@copilotkit/runtime-client-gql': - specifier: 1.55.1 - version: 1.55.1(@ag-ui/core@sdks+typescript+packages+core)(graphql@16.11.0)(react@19.2.1) + specifier: 1.60.1 + version: 1.60.1(@ag-ui/core@sdks+typescript+packages+core)(graphql@16.11.0)(react@19.2.1) '@copilotkit/shared': - specifier: 1.55.1 - version: 1.55.1(@ag-ui/core@sdks+typescript+packages+core) + specifier: 1.60.1 + version: 1.60.1(@ag-ui/core@sdks+typescript+packages+core) '@langchain/openai': specifier: 1.0.0 - version: 1.0.0(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(ws@8.18.3) + version: 1.0.0(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(ws@8.18.3) '@mastra/client-js': specifier: ^1.0.1 version: 1.0.1(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(arktype@2.1.27)(quansync@1.0.0)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(arktype@2.1.27)(quansync@1.0.0)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76))(@standard-schema/spec@1.1.0)(arktype@2.1.27)(openapi-types@12.1.3)(zod@3.25.76))(@types/json-schema@7.0.15)(openapi-types@12.1.3)(zod@3.25.76) @@ -513,6 +515,9 @@ importers: integrations/aws-strands/typescript: devDependencies: + '@ag-ui/a2ui-toolkit': + specifier: workspace:* + version: link:../../../sdks/typescript/packages/a2ui-toolkit '@ag-ui/client': specifier: workspace:* version: link:../../../sdks/typescript/packages/client @@ -757,9 +762,6 @@ importers: integrations/langchain/typescript: dependencies: - '@langchain/core': - specifier: ^0.3.80 - version: 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76)) rxjs: specifier: 7.8.1 version: 7.8.1 @@ -776,6 +778,9 @@ importers: '@arethetypeswrong/cli': specifier: ^0.17.4 version: 0.17.4 + '@langchain/core': + specifier: ^0.3.80 + version: 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76)) '@types/node': specifier: ^20.11.19 version: 20.19.21 @@ -797,6 +802,9 @@ importers: integrations/langgraph/typescript: dependencies: + '@ag-ui/a2ui-toolkit': + specifier: workspace:* + version: link:../../../sdks/typescript/packages/a2ui-toolkit '@langchain/core': specifier: ^1.1.40 version: 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) @@ -1197,6 +1205,9 @@ importers: middlewares/a2ui-middleware: dependencies: + '@ag-ui/a2ui-toolkit': + specifier: workspace:* + version: link:../../sdks/typescript/packages/a2ui-toolkit clarinet: specifier: ^0.12.6 version: 0.12.6 @@ -1287,6 +1298,40 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4) + middlewares/mcp-middleware: + dependencies: + '@ag-ui/client': + specifier: workspace:* + version: link:../../sdks/typescript/packages/client + '@modelcontextprotocol/sdk': + specifier: ^1.0.0 + version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) + rxjs: + specifier: 7.8.1 + version: 7.8.1 + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.17.4 + version: 0.17.4 + '@types/node': + specifier: ^20.11.19 + version: 20.19.21 + '@vitest/coverage-istanbul': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4)) + publint: + specifier: ^0.3.12 + version: 0.3.17 + tsdown: + specifier: ^0.20.1 + version: 0.20.1(publint@0.3.17)(typescript@5.9.3) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4) + middlewares/middleware-starter: dependencies: '@ag-ui/client': @@ -1318,6 +1363,30 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4) + sdks/typescript/packages/a2ui-toolkit: + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.17.4 + version: 0.17.4 + '@types/node': + specifier: ^20.11.19 + version: 20.19.21 + '@vitest/coverage-istanbul': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4)) + publint: + specifier: ^0.3.12 + version: 0.3.17 + tsdown: + specifier: ^0.20.1 + version: 0.20.1(publint@0.3.17)(typescript@5.9.3) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)(yaml@2.8.4) + sdks/typescript/packages/cli: dependencies: '@types/inquirer': @@ -1517,29 +1586,44 @@ packages: '@a2ui/web_core@0.9.0': resolution: {integrity: sha512-TsMWuEeuVDsScGIGPy/fWIZu+EOBRfhx6KwjKh3VwY1AwysRenQM8zDr8VrSk14Wck/aBgVxk2zWVrMCK2/s6A==} - '@ag-ui/a2ui-middleware@0.0.3': - resolution: {integrity: sha512-l2vCX9xyiJ76HmwyY0eMBpez7SyG18mLJUnId1M9u8diugc/hchEYvnEbkCDJFLFBIL0muWw6ZaUkhYZONGe8A==} + '@ag-ui/a2ui-middleware@0.0.8': + resolution: {integrity: sha512-YXabOMyNekshHWLc63fD166ndy/zOXp+UWbx1alYoGRhO2y2uZJzOlPLvBAkFY4PF3Lng78ByG4mNpxJlSLDvw==} peerDependencies: '@ag-ui/client': '>=0.0.40' rxjs: 7.8.1 + '@ag-ui/a2ui-toolkit@0.0.2': + resolution: {integrity: sha512-HFphlNxBxGSQfvxlI2LCQValSMDUTh3MAsaFMgYlF8sQXgCrXNiLJ70+Dz3uyOv4y/rfqdFafvlo1GKQtEVIVA==} + + '@ag-ui/a2ui-toolkit@0.0.3': + resolution: {integrity: sha512-bKjtuYQufGZ+vc2oTz1v5S6ab2gH/whQIIgbGfP+LMisdAkDV7bqeg4e+lZO3xNmdmkCa6nvkovtudMkqxmxEA==} + '@ag-ui/client@0.0.46': resolution: {integrity: sha512-9Bl6GN6N3NWa3Ewqgl8E3nJzo88prIB2LS50bTNgw35h5BxC1UY21c0SImqQWZ+VV5kbhs6AUrriypKEBB7F5A==} - '@ag-ui/client@0.0.52': - resolution: {integrity: sha512-U407VvDDwR5qs8TiyN1qY38x87qMWc2n0epw8iA5aa1qwzCKBBDgg3Fkm4JogQf0X4jwNsz8HUbIZrBB56mrpg==} + '@ag-ui/client@0.0.54': + resolution: {integrity: sha512-N5UVXEBV5gPHqTuMoR/21brconRn42URf+MB4L8OniCJKqLcl/qUJb5kMamK0nnfBhDfPs/uq7LxDn6bsDJzJg==} + + '@ag-ui/client@0.0.57': + resolution: {integrity: sha512-Xap2alG9Z0/j5kb3x4D7oTpe2sw1dfrC9rgJJr2NZu5vKcm8dzIPNd31mF2B4zS3BKqYIu245yxKPhEtT30MHw==} '@ag-ui/core@0.0.46': resolution: {integrity: sha512-5/gC9n20ImA10LMFLLYKOowqn2Btrr3UYXWGosmLc1+KJqREI0t35NXnwqoKlw7TWySznF1bpwY6uIvMtO/ZUg==} - '@ag-ui/core@0.0.52': - resolution: {integrity: sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==} + '@ag-ui/core@0.0.54': + resolution: {integrity: sha512-Ilx31OvRQaZfU7jSArGqz06JZKOsAt8zWiCPJljyp9zR6Tzl18oyfx8o6FsuGfAktGRe50GI9SCCxNXXysZwtA==} + + '@ag-ui/core@0.0.57': + resolution: {integrity: sha512-gho1OWjNE6E3Rl7ZEZ1wr2CEpUHjLFU0FqzCZZk439TicLu+BfLCMkMokB07bMGlRmbJ60hM6LW60iOVauCx+Q==} '@ag-ui/encoder@0.0.46': resolution: {integrity: sha512-XU6dTgUOFZsXeO+CxCMNl5R8NCbdUyifWP7sRNIi61Et3F/0d0JotLo1y1/9GMGfsJNnP7bjb4YYsx21R7YMlw==} - '@ag-ui/encoder@0.0.52': - resolution: {integrity: sha512-6GVDTb1dv2rjap7VVnmXYypDutZi6nrsTcdfxoP6ryDG5ynlXtmmS+FSDAt62JbIMD5CtEE963xNCb6d1iXw9g==} + '@ag-ui/encoder@0.0.54': + resolution: {integrity: sha512-0dPuE/eAeBRBDj/OOj5AW8SoP1r0dufmoOdrtKgmf+dlbVXKSNkDDHGrrvIWFPxwvPTWhHeN6wnsVUayWpUsGg==} + + '@ag-ui/encoder@0.0.57': + resolution: {integrity: sha512-ifD9NctR4xyPDR58xF9GK1bj/S8oECFkTeDfuYD8tXdbcOstIJ2TOqU2zhiCKnw7Vw+zR9Qv3TbsM9E7Gi9X3Q==} '@ag-ui/langgraph@0.0.24': resolution: {integrity: sha512-ebTYpUw28fvbmhqbpAbmfsDTfEqm1gSeZaBcnxMGHFivJLCzsJ/C9hYw6aV8yRKV3lMFBwh/QFxn1eRcr7yRkQ==} @@ -1547,8 +1631,8 @@ packages: '@ag-ui/client': '>=0.0.42' '@ag-ui/core': '>=0.0.42' - '@ag-ui/langgraph@0.0.27': - resolution: {integrity: sha512-sfUG985ngG4HAGIZK04POvZVDrsI3QaeWuqJ388QgBPGg9n/oi4+vxueW7O5PIQv6uOPCLsbxf43pZZRN3zZtg==} + '@ag-ui/langgraph@0.0.41': + resolution: {integrity: sha512-xo7ja/kuctmdPiH83QOUIpDs/AY3GzxW1fM37x9otK9fqwnKgi2JIcjfcdvAdGYdsCkXBn2WWQ2PVH+rdsLOzg==} peerDependencies: '@ag-ui/client': '>=0.0.42' '@ag-ui/core': '>=0.0.42' @@ -1558,11 +1642,19 @@ packages: peerDependencies: '@ag-ui/client': '>=0.0.40' + '@ag-ui/mcp-middleware@0.0.1': + resolution: {integrity: sha512-TayUu7kB+jXUTPRUJesNvJYrP+0weTL9F2VJJ8QQ4sWxY/Ihjo+GgFYgJZYNcLwbo1DKgmVJtdm2XUouPCbxeg==} + peerDependencies: + rxjs: 7.8.1 + '@ag-ui/proto@0.0.46': resolution: {integrity: sha512-+FfVhB1OP5A1+5BrEccQnwfODTbfBRWT3+NVnbW4RDFUDVmO9EUA+XPuO1ZxWcDfziTvQriwm0vNyaXGidSIhw==} - '@ag-ui/proto@0.0.52': - resolution: {integrity: sha512-+iCGzNUNL50YIoThVmsolWPjG4MJidl+R9k8QAGVwErEfHRtQ64KFyrdpeOXNVuWtM3SViJqPSgFyv7eGVS63A==} + '@ag-ui/proto@0.0.54': + resolution: {integrity: sha512-IPF+xeFaBAKKP2FO74MaVTkKUP8VaGGkbPzORCvC5TLDdGs+oQgQFqz+XoBeksQGE14+jgLWiAr9EPXdhqr1NA==} + + '@ag-ui/proto@0.0.57': + resolution: {integrity: sha512-pPENOZt0P6ibH8sCTgq05wLYXi5t3P9B5r/1bWYehXjUxtyOdnukSlWM++SsCIwUXsQdm/b3aBgGjEeTF7RenA==} '@ai-sdk/anthropic@2.0.23': resolution: {integrity: sha512-ZEBiiv1UhjGjBwUU63pFhLK5LCSlNDb1idY9K1oZHm5/Fda1cuTojf32tOp0opH0RPbPAN/F8fyyNjbU33n9Kw==} @@ -1600,12 +1692,6 @@ packages: peerDependencies: zod: 3.25.76 - '@ai-sdk/google@2.0.17': - resolution: {integrity: sha512-6LyuUrCZuiULg0rUV+kT4T2jG19oUntudorI4ttv1ARkSbwl8A39ue3rA487aDDy6fUScdbGFiV5Yv/o4gidVA==} - engines: {node: '>=18'} - peerDependencies: - zod: 3.25.76 - '@ai-sdk/google@2.0.67': resolution: {integrity: sha512-A7iZeJf3RbNIrFBKsskd2s4c52tK0S0nX4rGlehjVHSYBvIZzrX+RW3Oxe7WnpeI0aON+5dVsqfGLFNYNGWEXw==} engines: {node: '>=18'} @@ -2788,8 +2874,8 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@copilotkit/a2ui-renderer@1.55.1': - resolution: {integrity: sha512-qTfgMd8E8CHFFgcYLWvU/wzHkfMGx52lUnTibySn6FFsWQaM+PfiHhb+/6v1Hj0tpW6J1abYaoL5/IVbdL7cXg==} + '@copilotkit/a2ui-renderer@1.60.1': + resolution: {integrity: sha512-OGSy3a8Clew0Aow4EYZ63c4db6PAzTS7RzlU8TIBPxI7A/ZsAdSMlNBQ0ZYfAaqo6jAzrL4zSZ4ox6mSRNnGww==} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc @@ -2799,27 +2885,27 @@ packages: engines: {node: '>=20.15.0'} hasBin: true - '@copilotkit/core@1.55.1': - resolution: {integrity: sha512-Es25l3ozZpCJGZFER/jFd6oAFw/rWzbZl9MlMSa8x1uQs3aLE4xCYpo2e4sdK7ut13IIQWhRSC+ZQY59eldxzA==} + '@copilotkit/core@1.60.1': + resolution: {integrity: sha512-5IuY5PaCh1v4//pj7BRTiMGGJAEa97NHH1IE756ku5pE+ZEcSnhgWE3j5lXiFQZC1YzNuMo9amiZtV8DQ+wDMQ==} engines: {node: '>=18'} - '@copilotkit/license-verifier@0.0.1-a1': - resolution: {integrity: sha512-eTsupi14qPwDhpQBrFC6t4U5rxmHo9nPaHz60gOBPPCu7tTcA8GwEq5QfX/XGfmmKbCX2noL9yto1Pg0A8qQ9g==} + '@copilotkit/license-verifier@0.4.2': + resolution: {integrity: sha512-0+Rdtg4gOwOBFBpZFxYsjgwBcCLja5z03YC6WA3KEntHYhsnoJ2aqNG6c0we8ZExCNYlEO4M7kHIfG5LXzqMYQ==} - '@copilotkit/react-core@1.55.1': - resolution: {integrity: sha512-B8PQ0sjQjkTPsPkfUs6/Mi6I9/2LUiSGZk6tg82t0HCHU0paYCjN4DlwomSUiKyVY68iH41hebc6vHQReuRsRw==} + '@copilotkit/react-core@1.60.1': + resolution: {integrity: sha512-Ey2ZT1exap6HjTMsw1OCbhnrEpOVlVOJKHTzcFjirkDnVLBvyFAdfZZ3i4A7GFCj3U8dUHV9Ld3WrSIYqI2H9w==} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc zod: 3.25.76 - '@copilotkit/react-ui@1.55.1': - resolution: {integrity: sha512-qzA+Wj+TT2HiOnwudYeiy372I8BG6lMEhI4y1tYWb4Ij5jLZXfP87FJwk1xLQAEY8yJtchdBBjWPV3hDzJC0jg==} + '@copilotkit/react-ui@1.60.1': + resolution: {integrity: sha512-iGiHHBfKTEXkSh65DF/Ukjgyp11UOKVbBMewjabMIFb2DEa3SiaDYfQY3k5RRXcE8ALd7vg3yAr0ad0/g9HOkw==} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc - '@copilotkit/runtime-client-gql@1.55.1': - resolution: {integrity: sha512-nHimAHFnBjsPGbQ7Ds/MlI8B/bIMv7a311Al0qxllaEu0E7m5wwDEYPXECH1ZqEcfSvQtWs+5m1AzPkKNNv9bQ==} + '@copilotkit/runtime-client-gql@1.60.1': + resolution: {integrity: sha512-qTYohnh1fe23jC7jiUhY4qrScrZJbidYM9RjYhGw7zYlPTT/+fyafuvcUxoZuqPvI1FMWGrE5ucVr0/Yf+X1/A==} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc @@ -2856,8 +2942,8 @@ packages: openai: optional: true - '@copilotkit/runtime@1.55.1': - resolution: {integrity: sha512-KIayWvTHbeTpEX4Wr8OEVOT7csxkdaY127PBbg/TnZXb9yo3IG25LjnvY/oJ95OSj7H7q4c2FSYuzZFSYehBQA==} + '@copilotkit/runtime@1.60.1': + resolution: {integrity: sha512-c01IZfoiCOrZoK44G1XrRkIRJzD20UCcz8VN+ag+5dMDA/PQYjOU3Wdjb9UQ9q5GFqX+RHSqSZOT/FV+uT/WzQ==} peerDependencies: '@anthropic-ai/sdk': ^0.57.0 '@langchain/aws': '>=0.1.9' @@ -2868,6 +2954,7 @@ packages: '@langchain/openai': '>=0.4.2' groq-sdk: '>=0.3.0 <1.0.0' langchain: '>=0.3.3' + openai: ^4.85.1 || >=5.0.0 peerDependenciesMeta: '@anthropic-ai/sdk': optional: true @@ -2885,19 +2972,21 @@ packages: optional: true langchain: optional: true + openai: + optional: true '@copilotkit/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603': resolution: {integrity: sha512-b29dZR67mDq85v9h4ritwJ3dUVek8UpR4MZ0SHuFgZF7BYzMOGoGleh96H/8Mj1s6hTiQ781NVAPEJ6OiY4FDA==} peerDependencies: '@ag-ui/core': ^0.0.46 - '@copilotkit/shared@1.55.1': - resolution: {integrity: sha512-LjJPyOgyc5OZyCi4ZCmr8lxnFHYmI9+zznnCSApEUCd5ttpVkLe0nM8cf9H2R02ApBPcyVHV6eGnGwwAMlROYw==} + '@copilotkit/shared@1.60.1': + resolution: {integrity: sha512-8KTxK6xnuxmFjGy9pHkHOMDElQl5Smisx0q8hx8je1NEkwrxFKrCK653APqn83QyQLifdPBqExXEUpGkqhoGcw==} peerDependencies: '@ag-ui/core': '>=0.0.48' - '@copilotkit/web-inspector@1.55.1': - resolution: {integrity: sha512-p08LtdQ6fYhtxhZxu+C90+S32DNNC9LYZFjeXfnPUDiZhAwbrgRntu88rTylM7v/DN6xTQDeQZalD9EBbtB+YA==} + '@copilotkit/web-inspector@1.60.1': + resolution: {integrity: sha512-k9aJkrOWuyaNMpwNNRD/cYppiWeTMp8SaIPXTaaoNZmTQ8DlKLwSXuui2FTKEzvXzeVemMqBGKivd3K/Rie3KA==} engines: {node: '>=18'} '@copilotkitnext/agent@0.0.0-mme-ag-ui-0-0-46-20260227141603': @@ -2917,9 +3006,6 @@ packages: resolution: {integrity: sha512-tbw37m+MgOO58dxYsXvGTN9YqHt6DPLMqtDEQftJHrUrQkNqXOxhOporx4p2DG0R+RiQqWrT+r44D2eRCQhlkA==} engines: {node: '>=18'} - '@emnapi/core@1.5.0': - resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} - '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -4060,7 +4146,7 @@ packages: resolution: {integrity: sha512-olKEUIjb3HBOiD/NR056iGJz4wiN6HhQ/u65YmGWYadWWoKOcGwheBw/FE0x6SH4zDlI3QmP+vMhuQoaww19BQ==} engines: {node: '>=20'} peerDependencies: - '@langchain/core': ^1.0.0 + '@langchain/core': 0.3.80 '@libsql/client@0.15.15': resolution: {integrity: sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==} @@ -6001,12 +6087,24 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/devtools-event-client@0.4.3': + resolution: {integrity: sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/pacer@0.20.1': + resolution: {integrity: sha512-ZNQ1bIL6eUXVKdic0tiImvBVkWrg/IoSK6VIacTrO3d3HAGnd70qFJNJagR/YOJIOw4EKGWnodwpYZkN1pWuVQ==} + engines: {node: '>=18'} + '@tanstack/react-virtual@3.13.12': resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/virtual-core@3.13.12': resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} @@ -12465,12 +12563,17 @@ snapshots: zod: 3.25.76 zod-to-json-schema: 3.25.2(zod@3.25.76) - '@ag-ui/a2ui-middleware@0.0.3(@ag-ui/client@0.0.52)(rxjs@7.8.1)': + '@ag-ui/a2ui-middleware@0.0.8(@ag-ui/client@0.0.57)(rxjs@7.8.1)': dependencies: - '@ag-ui/client': 0.0.52 + '@ag-ui/a2ui-toolkit': 0.0.2 + '@ag-ui/client': 0.0.57 clarinet: 0.12.6 rxjs: 7.8.1 + '@ag-ui/a2ui-toolkit@0.0.2': {} + + '@ag-ui/a2ui-toolkit@0.0.3': {} + '@ag-ui/client@0.0.46': dependencies: '@ag-ui/core': 0.0.46 @@ -12484,11 +12587,24 @@ snapshots: uuid: 11.1.0 zod: 3.25.76 - '@ag-ui/client@0.0.52': + '@ag-ui/client@0.0.54': dependencies: - '@ag-ui/core': 0.0.52 - '@ag-ui/encoder': 0.0.52 - '@ag-ui/proto': 0.0.52 + '@ag-ui/core': 0.0.54 + '@ag-ui/encoder': 0.0.54 + '@ag-ui/proto': 0.0.54 + '@types/uuid': 10.0.0 + compare-versions: 6.1.1 + fast-json-patch: 3.1.1 + rxjs: 7.8.1 + untruncate-json: 0.0.1 + uuid: 11.1.0 + zod: 3.25.76 + + '@ag-ui/client@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@ag-ui/encoder': 0.0.57 + '@ag-ui/proto': 0.0.57 '@types/uuid': 10.0.0 compare-versions: 6.1.1 fast-json-patch: 3.1.1 @@ -12502,7 +12618,11 @@ snapshots: rxjs: 7.8.1 zod: 3.25.76 - '@ag-ui/core@0.0.52': + '@ag-ui/core@0.0.54': + dependencies: + zod: 3.25.76 + + '@ag-ui/core@0.0.57': dependencies: zod: 3.25.76 @@ -12511,10 +12631,15 @@ snapshots: '@ag-ui/core': 0.0.46 '@ag-ui/proto': 0.0.46 - '@ag-ui/encoder@0.0.52': + '@ag-ui/encoder@0.0.54': dependencies: - '@ag-ui/core': 0.0.52 - '@ag-ui/proto': 0.0.52 + '@ag-ui/core': 0.0.54 + '@ag-ui/proto': 0.0.54 + + '@ag-ui/encoder@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@ag-ui/proto': 0.0.57 '@ag-ui/langgraph@0.0.24(@ag-ui/client@0.0.46)(@ag-ui/core@0.0.46)(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': dependencies: @@ -12532,13 +12657,14 @@ snapshots: - react - react-dom - '@ag-ui/langgraph@0.0.27(@ag-ui/client@0.0.52)(@ag-ui/core@0.0.52)(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76))': + '@ag-ui/langgraph@0.0.41(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76))': dependencies: - '@ag-ui/client': 0.0.52 - '@ag-ui/core': 0.0.52 - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) - '@langchain/langgraph-sdk': 0.1.10(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - langchain: 1.2.32(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)) + '@ag-ui/a2ui-toolkit': 0.0.3 + '@ag-ui/client': 0.0.57 + '@ag-ui/core': 0.0.57 + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + '@langchain/langgraph-sdk': 1.8.8(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + langchain: 1.2.32(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)) partial-json: 0.1.7 rxjs: 7.8.1 transitivePeerDependencies: @@ -12554,9 +12680,19 @@ snapshots: - ws - zod-to-json-schema - '@ag-ui/mcp-apps-middleware@0.0.3(@ag-ui/client@0.0.52)(@cfworker/json-schema@4.1.1)(zod@3.25.76)': + '@ag-ui/mcp-apps-middleware@0.0.3(@ag-ui/client@0.0.57)(@cfworker/json-schema@4.1.1)(zod@3.25.76)': + dependencies: + '@ag-ui/client': 0.0.57 + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) + rxjs: 7.8.1 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + - zod + + '@ag-ui/mcp-middleware@0.0.1(@cfworker/json-schema@4.1.1)(rxjs@7.8.1)(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.52 + '@ag-ui/client': 0.0.54 '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) rxjs: 7.8.1 transitivePeerDependencies: @@ -12570,9 +12706,15 @@ snapshots: '@bufbuild/protobuf': 2.9.0 '@protobuf-ts/protoc': 2.11.1 - '@ag-ui/proto@0.0.52': + '@ag-ui/proto@0.0.54': + dependencies: + '@ag-ui/core': 0.0.54 + '@bufbuild/protobuf': 2.9.0 + '@protobuf-ts/protoc': 2.11.1 + + '@ag-ui/proto@0.0.57': dependencies: - '@ag-ui/core': 0.0.52 + '@ag-ui/core': 0.0.57 '@bufbuild/protobuf': 2.9.0 '@protobuf-ts/protoc': 2.11.1 @@ -12620,12 +12762,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@ai-sdk/google@2.0.17(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.10(zod@3.25.76) - zod: 3.25.76 - '@ai-sdk/google@2.0.67(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.1 @@ -13735,8 +13871,8 @@ snapshots: '@babel/generator@7.28.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -13827,7 +13963,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.27.1': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -13841,7 +13977,7 @@ snapshots: '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -13931,7 +14067,7 @@ snapshots: '@babel/helpers@7.28.4': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/parser@7.28.5': dependencies: @@ -14538,7 +14674,7 @@ snapshots: dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.28.6 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 esutils: 2.0.3 '@babel/preset-typescript@7.27.1(@babel/core@7.28.5)': @@ -14557,8 +14693,8 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@babel/template@7.28.6': dependencies: @@ -14569,11 +14705,11 @@ snapshots: '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 + '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -14648,21 +14784,22 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@copilotkit/a2ui-renderer@1.55.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@copilotkit/a2ui-renderer@1.60.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@a2ui/web_core': 0.9.0 clsx: 2.1.1 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) '@copilotkit/aimock@1.11.0': {} - '@copilotkit/core@1.55.1(@ag-ui/core@0.0.52)(zod@3.25.76)': + '@copilotkit/core@1.60.1(@ag-ui/core@0.0.57)(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.52 - '@copilotkit/shared': 1.55.1(@ag-ui/core@0.0.52) + '@ag-ui/client': 0.0.57 + '@copilotkit/shared': 1.60.1(@ag-ui/core@0.0.57) + '@tanstack/pacer': 0.20.1 phoenix: 1.8.5 rxjs: 7.8.1 zod-to-json-schema: 3.25.2(zod@3.25.76) @@ -14671,17 +14808,17 @@ snapshots: - encoding - zod - '@copilotkit/license-verifier@0.0.1-a1': {} + '@copilotkit/license-verifier@0.4.2': {} - '@copilotkit/react-core@1.55.1(@types/mdast@4.0.4)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(graphql@16.11.0)(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod@3.25.76)': + '@copilotkit/react-core@1.60.1(@types/mdast@4.0.4)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(graphql@16.11.0)(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.52 - '@ag-ui/core': 0.0.52 - '@copilotkit/a2ui-renderer': 1.55.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@copilotkit/core': 1.55.1(@ag-ui/core@0.0.52)(zod@3.25.76) - '@copilotkit/runtime-client-gql': 1.55.1(@ag-ui/core@0.0.52)(graphql@16.11.0)(react@19.2.1) - '@copilotkit/shared': 1.55.1(@ag-ui/core@0.0.52) - '@copilotkit/web-inspector': 1.55.1(@ag-ui/core@0.0.52)(zod@3.25.76) + '@ag-ui/client': 0.0.57 + '@ag-ui/core': 0.0.57 + '@copilotkit/a2ui-renderer': 1.60.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@copilotkit/core': 1.60.1(@ag-ui/core@0.0.57)(zod@3.25.76) + '@copilotkit/runtime-client-gql': 1.60.1(@ag-ui/core@0.0.57)(graphql@16.11.0)(react@19.2.1) + '@copilotkit/shared': 1.60.1(@ag-ui/core@0.0.57) + '@copilotkit/web-inspector': 1.60.1(@ag-ui/core@0.0.57)(zod@3.25.76) '@jetbrains/websandbox': 1.1.3 '@lit-labs/react': 2.1.3(@types/react@19.2.2) '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -14703,7 +14840,7 @@ snapshots: untruncate-json: 0.0.1 use-stick-to-bottom: 1.1.1(react@19.2.1) zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - '@types/mdast' - '@types/react' @@ -14714,11 +14851,11 @@ snapshots: - micromark-util-types - supports-color - '@copilotkit/react-ui@1.55.1(@ag-ui/core@sdks+typescript+packages+core)(@types/mdast@4.0.4)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(graphql@16.11.0)(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod@3.25.76)': + '@copilotkit/react-ui@1.60.1(@ag-ui/core@sdks+typescript+packages+core)(@types/mdast@4.0.4)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(graphql@16.11.0)(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod@3.25.76)': dependencies: - '@copilotkit/react-core': 1.55.1(@types/mdast@4.0.4)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(graphql@16.11.0)(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod@3.25.76) - '@copilotkit/runtime-client-gql': 1.55.1(@ag-ui/core@sdks+typescript+packages+core)(graphql@16.11.0)(react@19.2.1) - '@copilotkit/shared': 1.55.1(@ag-ui/core@sdks+typescript+packages+core) + '@copilotkit/react-core': 1.60.1(@types/mdast@4.0.4)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(graphql@16.11.0)(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod@3.25.76) + '@copilotkit/runtime-client-gql': 1.60.1(@ag-ui/core@sdks+typescript+packages+core)(graphql@16.11.0)(react@19.2.1) + '@copilotkit/shared': 1.60.1(@ag-ui/core@sdks+typescript+packages+core) '@headlessui/react': 2.2.9(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-markdown: 10.1.0(@types/react@19.2.2)(react@19.2.1) @@ -14739,9 +14876,9 @@ snapshots: - supports-color - zod - '@copilotkit/runtime-client-gql@1.55.1(@ag-ui/core@0.0.52)(graphql@16.11.0)(react@19.2.1)': + '@copilotkit/runtime-client-gql@1.60.1(@ag-ui/core@0.0.57)(graphql@16.11.0)(react@19.2.1)': dependencies: - '@copilotkit/shared': 1.55.1(@ag-ui/core@0.0.52) + '@copilotkit/shared': 1.60.1(@ag-ui/core@0.0.57) '@urql/core': 5.2.0(graphql@16.11.0) react: 19.2.1 untruncate-json: 0.0.1 @@ -14751,9 +14888,9 @@ snapshots: - encoding - graphql - '@copilotkit/runtime-client-gql@1.55.1(@ag-ui/core@sdks+typescript+packages+core)(graphql@16.11.0)(react@19.2.1)': + '@copilotkit/runtime-client-gql@1.60.1(@ag-ui/core@sdks+typescript+packages+core)(graphql@16.11.0)(react@19.2.1)': dependencies: - '@copilotkit/shared': 1.55.1(@ag-ui/core@sdks+typescript+packages+core) + '@copilotkit/shared': 1.60.1(@ag-ui/core@sdks+typescript+packages+core) '@urql/core': 5.2.0(graphql@16.11.0) react: 19.2.1 untruncate-json: 0.0.1 @@ -14812,25 +14949,26 @@ snapshots: - react-dom - supports-color - '@copilotkit/runtime@1.55.1(c3c32557d1ac98731bd405b9a6dd8f69)': + '@copilotkit/runtime@1.60.1(c82c70f68f9b62c68411914c7e649746)': dependencies: - '@ag-ui/a2ui-middleware': 0.0.3(@ag-ui/client@0.0.52)(rxjs@7.8.1) - '@ag-ui/client': 0.0.52 - '@ag-ui/core': 0.0.52 - '@ag-ui/encoder': 0.0.52 - '@ag-ui/langgraph': 0.0.27(@ag-ui/client@0.0.52)(@ag-ui/core@0.0.52)(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)) - '@ag-ui/mcp-apps-middleware': 0.0.3(@ag-ui/client@0.0.52)(@cfworker/json-schema@4.1.1)(zod@3.25.76) + '@ag-ui/a2ui-middleware': 0.0.8(@ag-ui/client@0.0.57)(rxjs@7.8.1) + '@ag-ui/client': 0.0.57 + '@ag-ui/core': 0.0.57 + '@ag-ui/encoder': 0.0.57 + '@ag-ui/langgraph': 0.0.41(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)) + '@ag-ui/mcp-apps-middleware': 0.0.3(@ag-ui/client@0.0.57)(@cfworker/json-schema@4.1.1)(zod@3.25.76) + '@ag-ui/mcp-middleware': 0.0.1(@cfworker/json-schema@4.1.1)(rxjs@7.8.1)(zod@3.25.76) '@ai-sdk/anthropic': 3.0.68(zod@3.25.76) '@ai-sdk/google': 3.0.61(zod@3.25.76) '@ai-sdk/google-vertex': 3.0.127(zod@3.25.76) '@ai-sdk/mcp': 1.0.35(zod@3.25.76) '@ai-sdk/openai': 3.0.37(zod@3.25.76) - '@copilotkit/license-verifier': 0.0.1-a1 - '@copilotkit/shared': 1.55.1(@ag-ui/core@0.0.52) + '@copilotkit/license-verifier': 0.4.2 + '@copilotkit/shared': 1.60.1(@ag-ui/core@0.0.57) '@graphql-yoga/plugin-defer-stream': 3.16.0(graphql-yoga@5.16.0(graphql@16.11.0))(graphql@16.11.0) - '@hono/node-server': 1.19.7(hono@4.11.5) - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) - '@modelcontextprotocol/sdk': 1.20.0 + '@hono/node-server': 1.19.14(hono@4.11.5) + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) '@remix-run/node-fetch-server': 0.13.0 '@scarf/scarf': 1.4.0 '@segment/analytics-node': 2.3.0 @@ -14844,7 +14982,6 @@ snapshots: graphql-scalars: 1.24.2(graphql@16.11.0) graphql-yoga: 5.16.0(graphql@16.11.0) hono: 4.11.5 - openai: 4.104.0(ws@8.18.3)(zod@3.25.76) partial-json: 0.1.7 phoenix: 1.8.5 pino: 9.13.1 @@ -14857,12 +14994,13 @@ snapshots: zod: 3.25.76 optionalDependencies: '@anthropic-ai/sdk': 0.57.0 - '@langchain/aws': 0.1.15(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))) - '@langchain/google-gauth': 0.1.8(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(zod@3.25.76) - '@langchain/langgraph-sdk': 1.8.8(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@langchain/openai': 1.0.0(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(ws@8.18.3) + '@langchain/aws': 0.1.15(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3)) + '@langchain/google-gauth': 0.1.8(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(zod@3.25.76) + '@langchain/langgraph-sdk': 1.8.8(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@langchain/openai': 1.0.0(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(ws@8.18.3) groq-sdk: 0.5.0 - langchain: 1.2.32(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)) + langchain: 1.2.32(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)) + openai: 4.104.0(ws@8.18.3)(zod@3.25.76) transitivePeerDependencies: - '@angular/core' - '@cfworker/json-schema' @@ -14901,11 +15039,11 @@ snapshots: transitivePeerDependencies: - encoding - '@copilotkit/shared@1.55.1(@ag-ui/core@0.0.52)': + '@copilotkit/shared@1.60.1(@ag-ui/core@0.0.57)': dependencies: - '@ag-ui/client': 0.0.52 - '@ag-ui/core': 0.0.52 - '@copilotkit/license-verifier': 0.0.1-a1 + '@ag-ui/client': 0.0.57 + '@ag-ui/core': 0.0.57 + '@copilotkit/license-verifier': 0.4.2 '@segment/analytics-node': 2.3.0 '@standard-schema/spec': 1.1.0 chalk: 4.1.2 @@ -14913,15 +15051,15 @@ snapshots: partial-json: 0.1.7 uuid: 11.1.0 zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - encoding - '@copilotkit/shared@1.55.1(@ag-ui/core@sdks+typescript+packages+core)': + '@copilotkit/shared@1.60.1(@ag-ui/core@sdks+typescript+packages+core)': dependencies: - '@ag-ui/client': 0.0.52 + '@ag-ui/client': 0.0.57 '@ag-ui/core': link:sdks/typescript/packages/core - '@copilotkit/license-verifier': 0.0.1-a1 + '@copilotkit/license-verifier': 0.4.2 '@segment/analytics-node': 2.3.0 '@standard-schema/spec': 1.1.0 chalk: 4.1.2 @@ -14929,14 +15067,14 @@ snapshots: partial-json: 0.1.7 uuid: 11.1.0 zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - encoding - '@copilotkit/web-inspector@1.55.1(@ag-ui/core@0.0.52)(zod@3.25.76)': + '@copilotkit/web-inspector@1.60.1(@ag-ui/core@0.0.57)(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.52 - '@copilotkit/core': 1.55.1(@ag-ui/core@0.0.52)(zod@3.25.76) + '@ag-ui/client': 0.0.57 + '@copilotkit/core': 1.60.1(@ag-ui/core@0.0.57)(zod@3.25.76) lit: 3.3.1 lucide: 0.525.0 marked: 12.0.2 @@ -14948,8 +15086,8 @@ snapshots: '@copilotkitnext/agent@0.0.0-mme-ag-ui-0-0-46-20260227141603(@cfworker/json-schema@4.1.1)': dependencies: '@ag-ui/client': 0.0.46 - '@ai-sdk/anthropic': 2.0.23(zod@3.25.76) - '@ai-sdk/google': 2.0.17(zod@3.25.76) + '@ai-sdk/anthropic': 2.0.74(zod@3.25.76) + '@ai-sdk/google': 2.0.67(zod@3.25.76) '@ai-sdk/mcp': 0.0.8(zod@3.25.76) '@ai-sdk/openai': 2.0.52(zod@3.25.76) '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) @@ -14979,12 +15117,6 @@ snapshots: partial-json: 0.1.7 uuid: 11.1.0 - '@emnapi/core@1.5.0': - dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 - optional: true - '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -15870,6 +16002,17 @@ snapshots: - aws-crt optional: true + '@langchain/aws@0.1.15(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))': + dependencies: + '@aws-sdk/client-bedrock-agent-runtime': 3.910.0 + '@aws-sdk/client-bedrock-runtime': 3.1044.0 + '@aws-sdk/client-kendra': 3.910.0 + '@aws-sdk/credential-provider-node': 3.972.39 + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + transitivePeerDependencies: + - aws-crt + optional: true + '@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))': dependencies: '@cfworker/json-schema': 4.1.1 @@ -15910,6 +16053,26 @@ snapshots: - '@opentelemetry/sdk-trace-base' - openai + '@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3)': + dependencies: + '@cfworker/json-schema': 4.1.1 + '@standard-schema/spec': 1.1.0 + ansi-styles: 5.2.0 + camelcase: 6.3.0 + decamelize: 1.2.0 + js-tiktoken: 1.0.21 + langsmith: 0.5.10(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + mustache: 4.2.0 + p-queue: 6.6.2 + uuid: 11.1.0 + zod: 3.25.76 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - ws + '@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3)': dependencies: '@cfworker/json-schema': 4.1.1 @@ -15939,6 +16102,15 @@ snapshots: - zod optional: true + '@langchain/google-common@0.1.8(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(zod@3.25.76)': + dependencies: + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + uuid: 10.0.0 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - zod + optional: true + '@langchain/google-gauth@0.1.8(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(zod@3.25.76)': dependencies: '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) @@ -15950,26 +16122,32 @@ snapshots: - zod optional: true + '@langchain/google-gauth@0.1.8(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(zod@3.25.76)': + dependencies: + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + '@langchain/google-common': 0.1.8(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(zod@3.25.76) + google-auth-library: 8.9.0 + transitivePeerDependencies: + - encoding + - supports-color + - zod + optional: true + '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))': dependencies: '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) uuid: 10.0.0 + optional: true - '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))': + '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))': dependencies: - '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) uuid: 10.0.0 - '@langchain/langgraph-sdk@0.1.10(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))': dependencies: - '@types/json-schema': 7.0.15 - p-queue: 6.6.2 - p-retry: 4.6.2 - uuid: 9.0.1 - optionalDependencies: - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + uuid: 10.0.0 '@langchain/langgraph-sdk@0.1.10(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': dependencies: @@ -15982,7 +16160,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.1(react@19.2.3) - '@langchain/langgraph-sdk@1.7.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@langchain/langgraph-sdk@1.7.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': dependencies: '@types/json-schema': 7.0.15 p-queue: 9.1.0 @@ -15990,20 +16168,20 @@ snapshots: uuid: 13.0.0 optionalDependencies: '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.1(react@19.2.3) + optional: true - '@langchain/langgraph-sdk@1.7.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': + '@langchain/langgraph-sdk@1.7.2(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@types/json-schema': 7.0.15 p-queue: 9.1.0 p-retry: 7.1.1 uuid: 13.0.0 optionalDependencies: - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) - react: 19.2.3 - react-dom: 19.2.1(react@19.2.3) - optional: true + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) '@langchain/langgraph-sdk@1.7.2(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': dependencies: @@ -16016,7 +16194,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.1(react@19.2.3) - '@langchain/langgraph-sdk@1.8.8(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@langchain/langgraph-sdk@1.8.8(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': dependencies: '@types/json-schema': 7.0.15 p-queue: 9.1.0 @@ -16024,21 +16202,20 @@ snapshots: uuid: 13.0.0 optionalDependencies: '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.1(react@19.2.3) optional: true - '@langchain/langgraph-sdk@1.8.8(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': + '@langchain/langgraph-sdk@1.8.8(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@types/json-schema': 7.0.15 p-queue: 9.1.0 p-retry: 7.1.1 uuid: 13.0.0 optionalDependencies: - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) - react: 19.2.3 - react-dom: 19.2.1(react@19.2.3) - optional: true + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) '@langchain/langgraph-sdk@1.8.8(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)': dependencies: @@ -16051,11 +16228,11 @@ snapshots: react: 19.2.3 react-dom: 19.2.1(react@19.2.3) - '@langchain/langgraph@1.2.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76)': + '@langchain/langgraph@1.2.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76)': dependencies: '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))) - '@langchain/langgraph-sdk': 1.7.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@langchain/langgraph-sdk': 1.7.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3) '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 3.25.76 @@ -16067,12 +16244,13 @@ snapshots: - react-dom - svelte - vue + optional: true - '@langchain/langgraph@1.2.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76)': + '@langchain/langgraph@1.2.2(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76)': dependencies: - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))) - '@langchain/langgraph-sdk': 1.7.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3) + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3)) + '@langchain/langgraph-sdk': 1.7.2(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 3.25.76 @@ -16084,7 +16262,6 @@ snapshots: - react-dom - svelte - vue - optional: true '@langchain/langgraph@1.2.2(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76)': dependencies: @@ -16111,6 +16288,16 @@ snapshots: zod: 3.25.76 transitivePeerDependencies: - ws + optional: true + + '@langchain/openai@1.0.0(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(ws@8.18.3)': + dependencies: + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + js-tiktoken: 1.0.21 + openai: 6.10.0(ws@8.18.3)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - ws '@libsql/client@0.15.15': dependencies: @@ -16452,7 +16639,7 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.5.0 + '@emnapi/core': 1.8.1 '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.10.1 optional: true @@ -18198,12 +18385,21 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.14 + '@tanstack/devtools-event-client@0.4.3': {} + + '@tanstack/pacer@0.20.1': + dependencies: + '@tanstack/devtools-event-client': 0.4.3 + '@tanstack/store': 0.9.3 + '@tanstack/react-virtual@3.13.12(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@tanstack/virtual-core': 3.13.12 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + '@tanstack/store@0.9.3': {} + '@tanstack/virtual-core@3.13.12': {} '@tiptap/core@2.26.3(@tiptap/pm@2.26.3)': @@ -22395,10 +22591,10 @@ snapshots: kolorist@1.8.0: {} - langchain@1.2.32(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)): + langchain@1.2.32(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)): dependencies: '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) - '@langchain/langgraph': 1.2.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76) + '@langchain/langgraph': 1.2.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76) '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))) langsmith: 0.5.10(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) uuid: 11.1.0 @@ -22415,12 +22611,13 @@ snapshots: - vue - ws - zod-to-json-schema + optional: true - langchain@1.2.32(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)): + langchain@1.2.32(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)): dependencies: - '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)) - '@langchain/langgraph': 1.2.2(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76)))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))) + '@langchain/core': 1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) + '@langchain/langgraph': 1.2.2(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3)) langsmith: 0.5.10(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3) uuid: 11.1.0 zod: 3.25.76 @@ -22436,7 +22633,6 @@ snapshots: - vue - ws - zod-to-json-schema - optional: true langchain@1.2.32(@langchain/core@1.1.40(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(ws@8.18.3))(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.10.0(ws@8.18.3)(zod@3.25.76))(react-dom@19.2.1(react@19.2.3))(react@19.2.3)(ws@8.18.3)(zod-to-json-schema@3.25.2(zod@3.25.76)): dependencies: diff --git a/render.yaml b/render.yaml index 5f41ad58a0..1b506a4b4d 100644 --- a/render.yaml +++ b/render.yaml @@ -135,6 +135,24 @@ projects: startCommand: poetry run dev autoDeployTrigger: commit rootDir: integrations/aws-strands/python/examples + - type: web + name: ag-ui-dojo-strands-typescript + runtime: node + repo: https://github.com/ag-ui-protocol/ag-ui + plan: standard + scaling: + minInstances: 1 + maxInstances: 3 + targetMemoryPercent: 70 + targetCPUPercent: 70 + envVars: + - key: OPENAI_API_KEY + sync: false + region: virginia + buildCommand: cd ../../../.. && pnpm install && npx nx run @ag-ui/aws-strands:build + startCommand: npx tsx server/server.ts + autoDeployTrigger: commit + rootDir: integrations/aws-strands/typescript/examples - type: web name: ag-ui-dojo-agno runtime: python @@ -386,7 +404,7 @@ projects: - key: ANTHROPIC_API_KEY sync: false region: virginia - buildCommand: cd ../../.. && npm install -g pnpm && pnpm install && npx nx run @ag-ui/claude-agent-sdk:build + buildCommand: cd ../../.. && pnpm install && npx nx run @ag-ui/claude-agent-sdk:build startCommand: npx tsx examples/server.ts autoDeployTrigger: commit rootDir: integrations/claude-agent-sdk/typescript @@ -445,6 +463,8 @@ projects: value: https://ag-ui-dojo-a2a-middleware-orchestrator.onrender.com - key: AWS_STRANDS_URL value: https://ag-ui-dojo-strands-python.onrender.com + - key: AWS_STRANDS_TYPESCRIPT_URL + value: https://ag-ui-dojo-strands-typescript.onrender.com - key: CLAUDE_AGENT_SDK_PYTHON_URL value: https://ag-ui-dojo-claude-agent-sdk-python.onrender.com - key: CLAUDE_AGENT_SDK_TYPESCRIPT_URL diff --git a/scripts/release/build-release-notification.test.ts b/scripts/release/build-release-notification.test.ts new file mode 100644 index 0000000000..cb88f8bc4d --- /dev/null +++ b/scripts/release/build-release-notification.test.ts @@ -0,0 +1,855 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildReleaseNotification } from "./lib/build-release-notification"; +import type { BuildReleaseNotificationInput } from "./lib/build-release-notification"; + +const RUN_URL = "https://github.com/ag-ui-protocol/ag-ui/actions/runs/123"; +const NPM_ORG_URL = "https://www.npmjs.com/org/ag-ui"; +const PY_BASE_URL = "https://pypi.org/project"; + +// A neutral baseline where nothing has acted. Each test overrides only the +// fields relevant to its truth-table row. +function base( + overrides: Partial = {}, +): BuildReleaseNotificationInput { + return { + mode: "", + npmResult: "skipped", + buildResult: "skipped", + npmIntended: "false", + tsPackages: [], + tsGroups: {}, + pyIntended: "false", + pyResult: "skipped", + pyBuildResult: "skipped", + pyPackages: [], + scope: "", + dryRun: false, + runUrl: RUN_URL, + npmOrgUrl: NPM_ORG_URL, + pyBaseUrl: PY_BASE_URL, + ...overrides, + }; +} + +// Convenience builders for the published-package sets. +function ts(...names: string[]): { name: string; version: string }[] { + return names.map((name) => ({ name, version: "0.0.41" })); +} +function py(...names: string[]): { name: string; version: string }[] { + return names.map((name) => ({ name, version: "0.0.11" })); +} + +// ---- dry-run ---------------------------------------------------------------- +test("suppresses dry-run — no post", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core"), + tsGroups: { latest: ["@ag-ui/core"] }, + dryRun: true, + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +// ---- npm lane: prerelease canary fully suppressed --------------------------- +test("suppresses prerelease (canary) success — no post", () => { + const r = buildReleaseNotification( + base({ + mode: "prerelease", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core"), + tsGroups: { canary: ["@ag-ui/core"] }, + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("suppresses prerelease (canary) npm failure — no post", () => { + const r = buildReleaseNotification( + base({ + mode: "prerelease", + npmIntended: "true", + npmResult: "failure", + buildResult: "success", + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("suppresses prerelease (canary) build failure — no post (would otherwise fire)", () => { + const r = buildReleaseNotification( + base({ + mode: "prerelease", + npmIntended: "true", + npmResult: "skipped", + buildResult: "failure", + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("suppresses prerelease (canary) PyPI success — no post (both lanes suppressed)", () => { + const r = buildReleaseNotification( + base({ + mode: "prerelease", + pyResult: "success", + pyBuildResult: "success", + pyPackages: py("ag-ui-protocol"), + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("suppresses prerelease (canary) PyPI failure — no post (both lanes suppressed)", () => { + const r = buildReleaseNotification( + base({ + mode: "prerelease", + pyIntended: "true", + pyResult: "failure", + pyBuildResult: "success", + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +// ---- npm lane: stable success ----------------------------------------------- +test("stable npm success → concise npm success line (N packages + names + dist-tag)", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core", "@ag-ui/client"), + tsGroups: { latest: ["@ag-ui/core", "@ag-ui/client"] }, + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + "🚀 *ag-ui release* · 2 npm packages published " + + "(`latest`: @ag-ui/core, @ag-ui/client) · " + + `<${NPM_ORG_URL}|npm>`, + ); +}); + +test("single npm package → '1 npm package' (pluralization)", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core"), + tsGroups: { latest: ["@ag-ui/core"] }, + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /1 npm package /); + assert.ok(!/1 npm packages/.test(r.message)); +}); + +test("npm dist-tags other than latest are rendered (alpha)", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: [{ name: "@ag-ui/core", version: "1.0.0-alpha.0" }], + tsGroups: { alpha: ["@ag-ui/core"] }, + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /`alpha`: @ag-ui\/core/); +}); + +test("npm success with multiple dist-tag groups → all groups rendered", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: [ + { name: "@ag-ui/core", version: "1.0.0" }, + { name: "@ag-ui/client", version: "1.0.0-beta.0" }, + ], + tsGroups: { latest: ["@ag-ui/core"], beta: ["@ag-ui/client"] }, + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /`latest`: @ag-ui\/core/); + assert.match(r.message, /`beta`: @ag-ui\/client/); + assert.match(r.message, /2 npm packages published/); +}); + +// ---- npm lane: count/name agreement (FIX 3) --------------------------------- +test("populated tsPackages + EMPTY tsGroups → flat name list (no dist-tag backticks)", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core", "@ag-ui/client"), + tsGroups: {}, + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /2 npm packages published/); + assert.match(r.message, /@ag-ui\/core, @ag-ui\/client/); + // Flat list: no dist-tag rendering (no backtick-wrapped tag labels). + assert.ok(!r.message.includes("`")); +}); + +test("tsGroups membership MISMATCHES tsPackages → falls back to flat list (count and names agree)", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + // Three packages published... + tsPackages: ts("@ag-ui/core", "@ag-ui/client", "@ag-ui/encoder"), + // ...but the groups dropped one (degraded ts_groups_json). Count (3) from + // tsPackages would disagree with names (2) from tsGroups — must fall back. + tsGroups: { latest: ["@ag-ui/core", "@ag-ui/client"] }, + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /3 npm packages published/); + // Flat fallback lists all three published names (count and names agree). + assert.match(r.message, /@ag-ui\/core, @ag-ui\/client, @ag-ui\/encoder/); + // Degraded groups are NOT rendered as dist-tag groups. + assert.ok(!r.message.includes("`latest`")); +}); + +test("tsGroups listing a name NOT in tsPackages → falls back to flat list", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core"), + // Group lists a phantom name absent from tsPackages → membership mismatch. + tsGroups: { latest: ["@ag-ui/core", "@ag-ui/phantom"] }, + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /1 npm package published/); + assert.ok(!r.message.includes("@ag-ui/phantom")); + assert.ok(!r.message.includes("`latest`")); +}); + +test("tsGroups with an EMPTY group array → falls back to flat list (no empty `tag`: fragment)", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core"), + // An empty-array group renders no names; total grouped count (1) still + // matches tsPackages.length (1) AND membership matches, but the empty + // group must never produce a malformed "`beta`: " fragment. The skip in + // renderNpmGroups drops it; here we additionally assert no empty fragment. + tsGroups: { latest: ["@ag-ui/core"], beta: [] }, + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /1 npm package published/); + // No malformed empty dist-tag fragment for the empty `beta` group. + // renderNpmGroups structurally filters out empty groups, so the absence of + // any "`beta`" substring fully covers it (a separate "`beta`:" regex would be + // always-true here and imply coverage it cannot provide). + assert.ok(!r.message.includes("`beta`")); +}); + +test("tsGroups with a name duplicated across two groups → falls back to flat list (count/name agreement)", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + // One published package... + tsPackages: ts("@ag-ui/core"), + // ...but it appears in TWO groups: deduped Set size (1) would match the + // package set, yet the TOTAL grouped count (2) disagrees with the count + // (1) from tsPackages. Must fall back to the flat list. + tsGroups: { latest: ["@ag-ui/core"], next: ["@ag-ui/core"] }, + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /1 npm package published/); + // Flat fallback: no dist-tag grouping rendered, name listed exactly once. + assert.ok(!r.message.includes("`latest`")); + assert.ok(!r.message.includes("`next`")); + assert.equal(r.message.split("@ag-ui/core").length - 1, 1); +}); + +// ---- npm lane: failure (lane-level wording) --------------------------------- +test("stable npm failure → lane-level red alert (NOT 'npm publish failed')", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmIntended: "true", + npmResult: "failure", + buildResult: "success", + // Detected package set is the authoritative "release attempted" signal: + // build succeeded and packages were detected, then publish failed. + tsPackages: ts("@ag-ui/core"), + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui npm release failed* · <${RUN_URL}|View run>`, + ); + assert.ok(!r.message.includes("publish failed")); +}); + +test("npm result failure beats a populated package set → FAILURE line, NOT success (if/else ordering)", () => { + // A populated tsPackages set does NOT force a success line: the publish job + // can have packages yet end in `failure` (e.g. a later tag/release step + // broke). The success arm requires npmResult === "success". + const r = buildReleaseNotification( + base({ + mode: "stable", + npmIntended: "true", + npmResult: "failure", + buildResult: "success", + tsPackages: ts("@ag-ui/core"), + tsGroups: { latest: ["@ag-ui/core"] }, + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui npm release failed* · <${RUN_URL}|View run>`, + ); + assert.ok(!r.message.includes("🚀")); + assert.ok(!r.message.includes("published")); +}); + +test("build failure (mode='', npm skipped) → lane-level npm/build red alert", () => { + const r = buildReleaseNotification( + base({ + mode: "", + npmIntended: "true", + npmResult: "skipped", + buildResult: "failure", + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui npm release failed* · <${RUN_URL}|View run>`, + ); + assert.ok(!r.message.includes("publish failed")); +}); + +test("npm publish failure with empty mode → lane-level red alert (no mode-coupling swallow)", () => { + const r = buildReleaseNotification( + base({ + mode: "", + npmIntended: "true", + npmResult: "failure", + buildResult: "success", + // Packages were detected (build OK) then publish failed → authoritative. + tsPackages: ts("@ag-ui/core"), + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui npm release failed* · <${RUN_URL}|View run>`, + ); +}); + +// ---- npm lane: EVENT-DERIVED intent gate ------------------------------------ +test("npmIntended='true' + buildResult='failure' → npm red ALERT (intent gate open)", () => { + const r = buildReleaseNotification( + base({ + mode: "", + npmIntended: "true", + npmResult: "skipped", + buildResult: "failure", + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui npm release failed* · <${RUN_URL}|View run>`, + ); +}); + +test("npmIntended='false' + buildResult='failure' → NEUTRAL (no npm release attempted)", () => { + const r = buildReleaseNotification( + base({ + mode: "", + npmIntended: "false", + npmResult: "skipped", + buildResult: "failure", + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +// ---- FIX 1: failure arms gate on the DETECTED PACKAGE SET ------------------- +// The detected package set (tsPackages/pyPackages) is the authoritative "this +// lane actually attempted a release" signal. Intent (compare-range) is only the +// build-failure fallback. + +test("dependabot-style: build+publish success, NO detected npm packages, npmIntended true → NO npm line (no false positive)", () => { + // A dependabot dependency bump touches package.json without bumping the + // package's OWN version. Intent (manifest touched) is true, but the build + // detected no published packages. With a successful build there is nothing to + // page about → the lane stays quiet. + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: [], + npmIntended: "true", + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("early build failure on an intended npm release (no detected packages yet) → npm failure line (fail toward paging)", () => { + // The build FAILED before detection could populate tsPackages. The + // event-derived npmIntended is the fallback that keeps an early build failure + // on a genuine release from being swallowed. + const r = buildReleaseNotification( + base({ + mode: "stable", + buildResult: "failure", + npmResult: "skipped", + tsPackages: [], + npmIntended: "true", + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui npm release failed* · <${RUN_URL}|View run>`, + ); +}); + +test("detected npm packages + publish failure with npmIntended FALSE → npm failure line (detected set is authoritative)", () => { + // The detected package set pages on its own failure REGARDLESS of intent: the + // build succeeded and detected packages, then the publish job failed. Intent + // for this lane is false (e.g. the push touched only the other ecosystem), + // yet the real publish failure must still page. + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "failure", + buildResult: "success", + tsPackages: ts("@ag-ui/core"), + npmIntended: "false", + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui npm release failed* · <${RUN_URL}|View run>`, + ); +}); + +test("cross-lane stale PyPI bump: detected py packages + publish failure, pyIntended FALSE → PyPI failure line (closes the silent-swallow)", () => { + // detect_py diffs LOCAL manifests against the REGISTRY, so a push that only + // touched package.json can still re-detect a STALE unpublished PyPI bump from + // a prior failed release. The compare-range intent for the PyPI lane is + // false. Under the old intent-only gate that real publish failure was + // SILENTLY SWALLOWED; gating on the detected set closes it. + const r = buildReleaseNotification( + base({ + mode: "stable", + pyResult: "failure", + pyBuildResult: "success", + pyPackages: py("ag-ui-protocol"), + pyIntended: "false", + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui PyPI release failed* · <${RUN_URL}|View run>`, + ); +}); + +test("build succeeded, NO detected PyPI packages, pyIntended true → NO PyPI line (no false positive)", () => { + // Symmetric with the dependabot npm case: a successful build that detected no + // PyPI packages does not page even though intent is true. + const r = buildReleaseNotification( + base({ + mode: "stable", + pyResult: "success", + pyBuildResult: "success", + pyPackages: [], + pyIntended: "true", + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +// ---- PyPI lane: stable success (mode-gated, symmetric with npm) ------------- +test("PyPI success → only PyPI line", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + pyResult: "success", + pyBuildResult: "success", + pyPackages: py("ag-ui-protocol"), + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + "🐍 *ag-ui release* · 1 PyPI package published (ag-ui-protocol) · " + + `<${PY_BASE_URL}/ag-ui-protocol/|PyPI>`, + ); +}); + +test("PyPI success with multiple packages → count + names + org-style link to flagship", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + pyResult: "success", + pyBuildResult: "success", + pyPackages: py("ag-ui-protocol", "ag-ui-langgraph"), + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /2 PyPI packages published/); + assert.match(r.message, /ag-ui-protocol, ag-ui-langgraph/); +}); + +test("PyPI flagship link targets ag-ui-protocol even when it is NOT first in pyPackages", () => { + const r = buildReleaseNotification( + base({ + pyResult: "success", + pyBuildResult: "success", + mode: "stable", + // ag-ui-protocol is deliberately NOT at index 0 — nothing sorts + // pyPackages, so the flagship must be selected explicitly by name. + pyPackages: py("ag-ui-langgraph", "ag-ui-protocol"), + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /2 PyPI packages published/); + // The link target is the flagship project page, ag-ui-protocol. + assert.match( + r.message, + //, + ); +}); + +test("PyPI flagship falls back to first package when ag-ui-protocol absent", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + pyResult: "success", + pyBuildResult: "success", + pyPackages: py("ag-ui-langgraph", "ag-ui-mastra"), + }), + ); + assert.equal(r.shouldPost, true); + assert.match( + r.message, + //, + ); +}); + +test("PyPI failure → lane-level PyPI red alert", () => { + const r = buildReleaseNotification( + base({ + pyIntended: "true", + pyResult: "failure", + pyBuildResult: "success", + // Build OK + packages detected, then publish failed → authoritative. + pyPackages: py("ag-ui-protocol"), + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui PyPI release failed* · <${RUN_URL}|View run>`, + ); +}); + +test("build-python failure during a REAL Python release (publish skipped) → PyPI red alert", () => { + const r = buildReleaseNotification( + base({ + pyIntended: "true", + pyResult: "skipped", + pyBuildResult: "failure", + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui PyPI release failed* · <${RUN_URL}|View run>`, + ); +}); + +test("build-python CANCELLED during a real Python release → NEUTRAL (no false red on deliberate cancel)", () => { + const r = buildReleaseNotification( + base({ + pyIntended: "true", + pyResult: "skipped", + pyBuildResult: "cancelled", + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("build-python failure WITHOUT intent → NO post (routine PR flake)", () => { + const r = buildReleaseNotification( + base({ + pyIntended: "false", + pyResult: "skipped", + pyBuildResult: "failure", + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("python release intended (pyIntended='true', pyBuildResult='failure') → PyPI red ALERT", () => { + const r = buildReleaseNotification( + base({ + pyIntended: "true", + pyResult: "skipped", + pyBuildResult: "failure", + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui PyPI release failed* · <${RUN_URL}|View run>`, + ); +}); + +// ---- prerelease + both lanes ------------------------------------------------ +test("prerelease + BOTH lanes failing → NO post (canary fully suppressed, both lanes)", () => { + const r = buildReleaseNotification( + base({ + mode: "prerelease", + npmIntended: "true", + npmResult: "failure", + buildResult: "failure", + pyIntended: "true", + pyResult: "failure", + pyBuildResult: "success", + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("prerelease + python success → NO post (canary fully suppressed, both lanes)", () => { + const r = buildReleaseNotification( + base({ + mode: "prerelease", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core"), + tsGroups: { canary: ["@ag-ui/core"] }, + pyResult: "success", + pyBuildResult: "success", + pyPackages: py("ag-ui-protocol"), + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +// ---- cancelled is NEUTRAL everywhere ---------------------------------------- +test("npm cancelled (mode=stable) → no line (neutral, no false red)", () => { + const r = buildReleaseNotification( + base({ mode: "stable", npmResult: "cancelled", buildResult: "success" }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("build cancelled → no line (neutral)", () => { + const r = buildReleaseNotification( + base({ mode: "", npmResult: "skipped", buildResult: "cancelled" }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("PyPI cancelled → no line (neutral)", () => { + const r = buildReleaseNotification( + base({ pyResult: "cancelled", pyBuildResult: "success" }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +// ---- skipped lanes contribute nothing --------------------------------------- +test("python-only run (npm lane skipped) → only PyPI line, NO false red", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "skipped", + buildResult: "skipped", + pyResult: "success", + pyBuildResult: "success", + pyPackages: py("ag-ui-protocol"), + }), + ); + assert.equal(r.shouldPost, true); + assert.ok(!r.message.includes("🔴")); + assert.match(r.message, /🐍/); +}); + +test("npm-only run (PyPI lane not acting) → only npm line", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core"), + tsGroups: { latest: ["@ag-ui/core"] }, + pyResult: "skipped", + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /🚀/); + assert.ok(!r.message.includes("🐍")); + assert.ok(!r.message.includes("🔴")); +}); + +// ---- both lanes ------------------------------------------------------------- +test("both lanes succeed → one message with both lines", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core", "@ag-ui/client"), + tsGroups: { latest: ["@ag-ui/core", "@ag-ui/client"] }, + pyResult: "success", + pyBuildResult: "success", + pyPackages: py("ag-ui-protocol"), + }), + ); + assert.equal(r.shouldPost, true); + const expected = + "🚀 *ag-ui release* · 2 npm packages published " + + "(`latest`: @ag-ui/core, @ag-ui/client) · " + + `<${NPM_ORG_URL}|npm>\n` + + "🐍 *ag-ui release* · 1 PyPI package published (ag-ui-protocol) · " + + `<${PY_BASE_URL}/ag-ui-protocol/|PyPI>`; + assert.equal(r.message, expected); +}); + +test("both lanes fail → one message with both lane-level red lines", () => { + const r = buildReleaseNotification( + base({ + mode: "stable", + npmIntended: "true", + npmResult: "failure", + buildResult: "success", + // Both lanes detected packages (build OK) then the shared publish failed. + tsPackages: ts("@ag-ui/core"), + pyIntended: "true", + pyResult: "failure", + pyBuildResult: "success", + pyPackages: py("ag-ui-protocol"), + }), + ); + assert.equal(r.shouldPost, true); + assert.equal( + r.message, + `🔴 *ag-ui npm release failed* · <${RUN_URL}|View run>\n` + + `🔴 *ag-ui PyPI release failed* · <${RUN_URL}|View run>`, + ); +}); + +// ---- nothing acted ---------------------------------------------------------- +test("nothing acted (npm skipped, PyPI not publishing) → no post, empty message", () => { + const r = buildReleaseNotification(base()); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +// ---- no-false-success guards ------------------------------------------------ +test("stable npm success with EMPTY package set → no npm success line (no false success)", () => { + // npmResult=success but no published packages is an anomalous state — do not + // claim success. With buildResult=success there is also no failure to report. + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: [], + tsGroups: {}, + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("PyPI success with EMPTY package set → no PyPI success line (no false success)", () => { + const r = buildReleaseNotification( + base({ pyResult: "success", pyBuildResult: "success", pyPackages: [] }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +test("npm success but mode not stable (defensive) → no npm success line", () => { + const r = buildReleaseNotification( + base({ + mode: "", + npmResult: "success", + buildResult: "success", + tsPackages: ts("@ag-ui/core"), + tsGroups: { latest: ["@ag-ui/core"] }, + }), + ); + assert.equal(r.shouldPost, false); + assert.equal(r.message, ""); +}); + +// ---- name-list truncation (keep the message concise) ------------------------ +test("npm success with many packages → name list truncates with '+N more'", () => { + const names = [ + "@ag-ui/core", + "@ag-ui/client", + "@ag-ui/encoder", + "@ag-ui/proto", + "create-ag-ui-app", + "@ag-ui/langgraph", + "@ag-ui/mastra", + ]; + const r = buildReleaseNotification( + base({ + mode: "stable", + npmResult: "success", + buildResult: "success", + tsPackages: names.map((name) => ({ name, version: "1.0.0" })), + tsGroups: { latest: names }, + }), + ); + assert.equal(r.shouldPost, true); + assert.match(r.message, /7 npm packages published/); + // Concise: do not dump all 7 names; cap and summarize the remainder. + assert.match(r.message, /\+\d+ more/); +}); diff --git a/scripts/release/build-release-notification.ts b/scripts/release/build-release-notification.ts new file mode 100644 index 0000000000..ac585a65fe --- /dev/null +++ b/scripts/release/build-release-notification.ts @@ -0,0 +1,292 @@ +#!/usr/bin/env -S pnpm tsx +/** + * CLI wrapper for the post-release #engr Slack notification builder. + * + * Thin glue around the pure buildReleaseNotification() function in + * ./lib/build-release-notification.ts. The truth-table logic lives (and is + * unit-tested) there; this file only: + * 1. reads the release signals from env vars (set by the notify job from + * needs.build.outputs / needs.publish.* results + workflow inputs + + * the event-derived intent step), + * 2. parses the JSON package/group arrays defensively (a cosmetic parse + * failure must never suppress a real alert), + * 3. calls the pure builder, and + * 4. writes `message=` and `should_post=` to GITHUB_OUTPUT. + * + * Env vars (all optional; absent → empty string / empty set): + * MODE needs.build.outputs.mode ("stable" | "prerelease" | "") + * NPM_RESULT needs.publish.result (shared publish job result) + * BUILD_RESULT needs.build.result (shared build job result) + * NPM_INTENDED notify-job event-derived npm release intent ("true" | ...) + * TS_PACKAGES needs.build.outputs.ts_packages (JSON [{name,version,path}]) + * TS_GROUPS needs.build.outputs.ts_groups_json (JSON {tag: [name,...]}) + * PY_INTENDED notify-job event-derived Python release intent ("true" | ...) + * PY_RESULT needs.publish.result (SAME value as NPM_RESULT) + * PY_BUILD_RESULT needs.build.result (SAME value as BUILD_RESULT) + * PY_PACKAGES needs.build.outputs.py_packages (JSON [{name,version,dir}]) + * + * NOTE: ag-ui runs ONE shared build job and ONE shared publish job spanning + * BOTH lanes. The workflow wires BOTH BUILD_RESULT and PY_BUILD_RESULT to the + * SAME needs.build.result, and BOTH NPM_RESULT and PY_RESULT to the SAME + * needs.publish.result. These are NOT distinct per-lane signals. Lane + * attribution comes from the detected package SETS (TS_PACKAGES vs + * PY_PACKAGES) + the per-lane intent gates, NOT from distinct per-lane + * build/publish results (see the lib interface doc, which says the same). + * SCOPE needs.build.outputs.scope + * DRY_RUN inputs.dry_run ("true" | "false" | "") + * RUN_URL this workflow run URL + * NPM_ORG_URL npm org page URL + * PY_BASE_URL PyPI project base URL + * + * Usage: pnpm tsx scripts/release/build-release-notification.ts + */ + +import fs from "node:fs"; +import path from "node:path"; +import { randomBytes } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { buildReleaseNotification } from "./lib/build-release-notification"; +import type { + ReleaseMode, + JobResult, + PublishedPackage, + DistTagGroups, + BuildReleaseNotificationResult, +} from "./lib/build-release-notification"; + +function env(name: string): string { + return process.env[name] ?? ""; +} + +const KNOWN_MODES: readonly ReleaseMode[] = ["stable", "prerelease", ""]; + +const KNOWN_JOB_RESULTS: readonly JobResult[] = [ + "success", + "failure", + "cancelled", + "skipped", + "", +]; + +/** + * Validate a raw GitHub Actions job-result env value against the known + * JobResult set, degrading LOUDLY to "failure" (page-on-uncertainty) on any + * unrecognized value. RESULT values drive FAILURE-gating; an unknown result is + * anomalous and must err toward PAGING. The failure arms are PACKAGE-SET-gated + * (the detected ts_packages/py_packages set is the primary gate; intent is only + * the build-failure fallback), so an unrecognized job-result value coerced to + * "failure" is the fail-toward-paging direction. In practice GitHub only ever + * emits success|failure|cancelled|skipped (plus "" when unset), so this + * coercion branch is defensive and not normally reached. + */ +export function resolveJobResultSafe(raw: string): JobResult { + if ((KNOWN_JOB_RESULTS as readonly string[]).includes(raw)) { + return raw as JobResult; + } + console.warn( + `::warning::resolveJobResultSafe: unrecognized job result "${raw}" (expected one of: success, failure, cancelled, skipped, or empty) — coercing to "failure" (page-on-uncertainty; the intent gates ensure this only pages on a real release).`, + ); + return "failure"; +} + +/** + * Validate the raw MODE env value, degrading LOUDLY to "" (neutral "npm lane + * did not run") on any unrecognized value. MODE drives the npm SUCCESS-gating; + * degrading a typo to "stable" would FALSELY claim a publish, so MODE degrades + * to "" — never inventing a success. This does NOT swallow failures: the + * npm-failure arm keys off the event-derived intent + job RESULTS. + */ +export function resolveModeSafe(raw: string): ReleaseMode { + if ((KNOWN_MODES as readonly string[]).includes(raw)) { + return raw as ReleaseMode; + } + console.warn( + `::warning::resolveModeSafe: unrecognized MODE "${raw}" (expected one of: stable, prerelease, or empty) — coercing to "" (treated as "npm lane did not run").`, + ); + return ""; +} + +/** + * Parse a JSON package array (ag-ui's ts_packages / py_packages output shape) + * into a clean PublishedPackage[], degrading to [] on ANY error or malformed + * entry. A cosmetic package set must NEVER throw and suppress a real alert. + * Entries missing a string `name` are dropped; `version` defaults to "". + */ +export function parsePackagesSafe(raw: string): PublishedPackage[] { + if (!raw) return []; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + // A non-empty raw input that parses to a non-array (e.g. an object) would + // otherwise silently yield no packages → no success line → no post, with + // no diagnostic. Warn before degrading to [] (mirrors the catch branch). + console.warn( + "::warning::parsePackagesSafe: package set parsed to a non-array — rendering without it.", + ); + return []; + } + const out: PublishedPackage[] = []; + for (const entry of parsed) { + if (entry && typeof entry === "object") { + const o = entry as Record; + if (typeof o.name === "string" && o.name.length > 0) { + out.push({ + name: o.name, + version: typeof o.version === "string" ? o.version : "", + }); + } + } + } + return out; + } catch (err) { + console.warn( + `::warning::parsePackagesSafe: failed to parse package set — rendering without it. ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return []; + } +} + +/** + * Parse the ts_groups_json dist-tag grouping object, degrading to {} on ANY + * error or non-object shape. Only string→string[] entries are kept. + */ +export function parseGroupsSafe(raw: string): DistTagGroups { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + // A non-empty raw input that parses to a non-object (e.g. an array or + // null) would otherwise silently yield no grouping with no diagnostic. + // Warn before degrading to {} (mirrors parsePackagesSafe's non-array + // branch and the catch branch below). + console.warn( + "::warning::parseGroupsSafe: dist-tag groups parsed to a non-object — rendering without grouping.", + ); + return {}; + } + const out: DistTagGroups = {}; + for (const [tag, names] of Object.entries( + parsed as Record, + )) { + if (Array.isArray(names) && names.every((n) => typeof n === "string")) { + out[tag] = names as string[]; + } + } + return out; + } catch (err) { + console.warn( + `::warning::parseGroupsSafe: failed to parse dist-tag groups — rendering without grouping. ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return {}; + } +} + +/** + * Serialize the builder result to a GITHUB_OUTPUT file using a per-write RANDOM + * heredoc delimiter (GitHub's documented pattern), so message content can never + * collide with / prematurely terminate the heredoc. + */ +export function writeGithubOutput( + outputPath: string, + result: BuildReleaseNotificationResult, +): void { + const delimiter = `EOF_${randomBytes(8).toString("hex")}`; + // Build BOTH the message heredoc block AND the should_post line into a SINGLE + // string and write them with ONE appendFileSync. A prior two-call form could + // leave GITHUB_OUTPUT with `message` but no `should_post` if the second call + // threw — the Post step's `should_post == 'true'` guard would then be false + // and a real alert would silently vanish. One write keeps the pair atomic. + const payload = + `message<<${delimiter}\n${result.message}\n${delimiter}\n` + + `should_post=${result.shouldPost}\n`; + try { + fs.appendFileSync(outputPath, payload); + } catch (err) { + // Fail LOUD: a notifier that cannot persist its outputs is broken, and + // silently no-op'ing would swallow a real release alert. The ::error:: + // annotation + non-zero exit routes to the workflow self-watchdog. + console.error( + `::error::Failed to write should_post/message to GITHUB_OUTPUT — cannot emit the release notification. ${ + err instanceof Error ? err.message : String(err) + }`, + ); + process.exit(1); + } +} + +function main(): void { + const result = buildReleaseNotification({ + mode: resolveModeSafe(env("MODE")), + npmResult: resolveJobResultSafe(env("NPM_RESULT")), + buildResult: resolveJobResultSafe(env("BUILD_RESULT")), + npmIntended: env("NPM_INTENDED"), + tsPackages: parsePackagesSafe(env("TS_PACKAGES")), + tsGroups: parseGroupsSafe(env("TS_GROUPS")), + pyIntended: env("PY_INTENDED"), + pyResult: resolveJobResultSafe(env("PY_RESULT")), + pyBuildResult: resolveJobResultSafe(env("PY_BUILD_RESULT")), + pyPackages: parsePackagesSafe(env("PY_PACKAGES")), + scope: env("SCOPE"), + dryRun: env("DRY_RUN") === "true", + runUrl: env("RUN_URL"), + npmOrgUrl: env("NPM_ORG_URL") || "https://www.npmjs.com/org/ag-ui", + pyBaseUrl: env("PY_BASE_URL") || "https://pypi.org/project", + }); + + const outputPath = process.env.GITHUB_OUTPUT; + if (outputPath) { + writeGithubOutput(outputPath, result); + } else if (process.env.GITHUB_ACTIONS === "true") { + // A status notifier that cannot write its should_post/message outputs is + // broken: the Post step gates on those outputs, so silently no-op'ing would + // swallow a real release alert. Fail loud under Actions. + console.error( + "::error::GITHUB_OUTPUT is unset under GitHub Actions — cannot emit should_post/message for the release notification.", + ); + process.exit(1); + } + + // Console echo (always useful in logs; the sole output channel for an + // explicit local/no-Actions invocation). + console.log(`should_post=${result.shouldPost}`); + if (result.message) { + console.log(`message:\n${result.message}`); + } +} + +// Only run when invoked directly as a CLI, not when imported by tests. Apply +// fs.realpathSync to BOTH sides so a symlinked checkout can't make main() +// silently not run. realpathSync THROWS (ENOENT) if argv[1] doesn't resolve on +// disk, which would crash before main() and swallow a real alert — so guard it +// with a path.resolve()-normalized compare on throw. +function isInvokedDirectly(): boolean { + if (process.argv[1] == null) return false; + const modulePath = fileURLToPath(import.meta.url); + try { + return fs.realpathSync(modulePath) === fs.realpathSync(process.argv[1]); + } catch (err) { + // ENOENT is the documented case: argv[1] (or the module path) does not + // resolve on disk. For that, keep the weaker path.resolve() fallback. + // For ANY OTHER error under GitHub Actions (e.g. EACCES, ELOOP), a wrong + // `false` here would mean main() never runs → no $GITHUB_OUTPUT written → + // the alert silently vanishes. Fail LOUD instead so the self-watchdog sees + // it, rather than degrading to a compare that could also wrongly skip. + const code = (err as NodeJS.ErrnoException)?.code; + if (code !== "ENOENT" && process.env.GITHUB_ACTIONS === "true") { + console.error( + `::error::isInvokedDirectly: realpathSync failed (${ + err instanceof Error ? err.message : String(err) + }) — cannot reliably determine direct invocation; refusing to silently skip the release notifier.`, + ); + process.exit(1); + } + return path.resolve(modulePath) === path.resolve(process.argv[1]); + } +} +if (isInvokedDirectly()) { + main(); +} diff --git a/scripts/release/build-release-notification.wrapper.test.ts b/scripts/release/build-release-notification.wrapper.test.ts new file mode 100644 index 0000000000..91c4b99c2a --- /dev/null +++ b/scripts/release/build-release-notification.wrapper.test.ts @@ -0,0 +1,431 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + writeGithubOutput, + resolveModeSafe, + resolveJobResultSafe, + parsePackagesSafe, + parseGroupsSafe, +} from "./build-release-notification"; + +const WRAPPER = join( + process.cwd(), + "scripts/release/build-release-notification.ts", +); + +const RUN_URL = "https://github.com/ag-ui-protocol/ag-ui/actions/runs/123"; + +function mkTmp(): string { + return mkdtempSync(join(tmpdir(), "release-notify-wrapper-")); +} + +/** + * The release-signal / intent env vars the wrapper reads. The helper DELETES + * these from the inherited env before applying caller overrides, so each test + * controls them exactly (a real GITHUB_OUTPUT / GITHUB_ACTIONS / MODE etc. set + * on the runner — e.g. under GitHub Actions — must not leak in and pollute the + * fail-loud "GITHUB_OUTPUT unset" test or the DRY_RUN coercion cases). HOME / + * PATH / pnpm / corepack vars pass THROUGH so `pnpm tsx` works in any CI image. + */ +const CONTROLLED_ENV_KEYS = [ + "GITHUB_OUTPUT", + "GITHUB_ACTIONS", + "DRY_RUN", + "MODE", + "NPM_RESULT", + "NPM_INTENDED", + "BUILD_RESULT", + "TS_PACKAGES", + "TS_GROUPS", + "PY_RESULT", + "PY_INTENDED", + "PY_BUILD_RESULT", + "PY_PACKAGES", + "SCOPE", + "RUN_URL", + "NPM_ORG_URL", + "PY_BASE_URL", +] as const; + +/** + * Run the wrapper CLI as a subprocess; returns { status, stdout, stderr }. + * + * Starts from a COPY of process.env (so HOME / PATH / pnpm / corepack vars pass + * through — stripping them can make `pnpm tsx` fail in some CI images), DELETES + * the controlled release/intent keys (so the suite's own GitHub Actions env + * can't leak into a test), THEN applies the caller-supplied overrides. + */ +async function runWrapper( + env: Record, +): Promise<{ status: number; stdout: string; stderr: string }> { + const cleanEnv: Record = { ...process.env }; + for (const key of CONTROLLED_ENV_KEYS) { + delete cleanEnv[key]; + } + for (const [k, v] of Object.entries(env)) { + if (v !== undefined) cleanEnv[k] = v; + else delete cleanEnv[k]; + } + return new Promise((resolve, reject) => { + const child = spawn("pnpm", ["tsx", WRAPPER], { env: cleanEnv }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (c) => (stdout += c.toString())); + child.stderr.on("data", (c) => (stderr += c.toString())); + child.on("error", reject); + child.on("exit", (code) => resolve({ status: code ?? 0, stdout, stderr })); + }); +} + +// ---- writeGithubOutput (in-process) ----------------------------------------- +test("writeGithubOutput round-trips a multi-line message through the GITHUB_OUTPUT heredoc", () => { + const dir = mkTmp(); + try { + const outputPath = join(dir, "out.txt"); + writeFileSync(outputPath, ""); + const message = "line one\nline two · "; + + writeGithubOutput(outputPath, { message, shouldPost: true }); + + const raw = readFileSync(outputPath, "utf8"); + const m = raw.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m); + assert.notEqual(m, null); + assert.equal(m![2], message); + assert.ok(raw.includes("should_post=true")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("writeGithubOutput uses a per-write RANDOM delimiter (not a fixed sentinel)", () => { + const dir = mkTmp(); + try { + const a = join(dir, "a.txt"); + const b = join(dir, "b.txt"); + writeFileSync(a, ""); + writeFileSync(b, ""); + + writeGithubOutput(a, { message: "x", shouldPost: true }); + writeGithubOutput(b, { message: "x", shouldPost: true }); + + const delimA = readFileSync(a, "utf8").match(/^message<<(\S+)/m)?.[1]; + const delimB = readFileSync(b, "utf8").match(/^message<<(\S+)/m)?.[1]; + + assert.ok(delimA); + assert.ok(delimB); + // The real delimiter shape is EOF_<16-hex> (randomBytes(8).toString("hex")). + assert.match(delimA!, /^EOF_[0-9a-f]{16}$/); + assert.match(delimB!, /^EOF_[0-9a-f]{16}$/); + assert.notEqual(delimA, delimB); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("writeGithubOutput does not corrupt output when the message contains a heredoc-like token", () => { + const dir = mkTmp(); + try { + const outputPath = join(dir, "out.txt"); + writeFileSync(outputPath, ""); + // Embed a token in the real delimiter shape (EOF_<16-hex>) so this actually + // exercises collision-safety against the implementation's format. + const message = "EOF_deadbeefdeadbeef\nstill the message"; + + writeGithubOutput(outputPath, { message, shouldPost: true }); + + const raw = readFileSync(outputPath, "utf8"); + const m = raw.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m); + assert.notEqual(m, null); + assert.equal(m![2], message); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ---- resolveModeSafe -------------------------------------------------------- +for (const mode of ["stable", "prerelease", ""] as const) { + test(`resolveModeSafe passes through the known mode "${mode}" unchanged`, () => { + assert.equal(resolveModeSafe(mode), mode); + }); +} + +test('resolveModeSafe coerces an unknown MODE (typo) to "" (degrade, no crash)', () => { + assert.doesNotThrow(() => resolveModeSafe("stabel")); + assert.equal(resolveModeSafe("stabel"), ""); +}); + +// ---- resolveJobResultSafe --------------------------------------------------- +for (const result of [ + "success", + "failure", + "cancelled", + "skipped", + "", +] as const) { + test(`resolveJobResultSafe passes through the known job result "${result}" unchanged`, () => { + assert.equal(resolveJobResultSafe(result), result); + }); +} + +test('resolveJobResultSafe coerces an unknown job result to "failure" (page-on-uncertainty)', () => { + assert.doesNotThrow(() => resolveJobResultSafe("succeeded")); + assert.equal(resolveJobResultSafe("succeeded"), "failure"); +}); + +// ---- parsePackagesSafe ------------------------------------------------------ +test("parsePackagesSafe parses a valid JSON array of {name,version}", () => { + const parsed = parsePackagesSafe( + '[{"name":"@ag-ui/core","version":"1.0.0","path":"x"}]', + ); + assert.deepEqual(parsed, [{ name: "@ag-ui/core", version: "1.0.0" }]); +}); + +test("parsePackagesSafe degrades to [] on empty / malformed JSON (cosmetic, never crashes)", () => { + assert.deepEqual(parsePackagesSafe(""), []); + assert.deepEqual(parsePackagesSafe("not json"), []); + assert.deepEqual(parsePackagesSafe("{}"), []); + // Entries missing a name are dropped. + assert.deepEqual(parsePackagesSafe('[{"version":"1.0.0"}]'), []); +}); + +// ---- parseGroupsSafe -------------------------------------------------------- +test("parseGroupsSafe parses a valid dist-tag grouping object", () => { + assert.deepEqual(parseGroupsSafe('{"latest":["@ag-ui/core"]}'), { + latest: ["@ag-ui/core"], + }); +}); + +test("parseGroupsSafe degrades to {} on empty / malformed JSON", () => { + assert.deepEqual(parseGroupsSafe(""), {}); + assert.deepEqual(parseGroupsSafe("not json"), {}); + assert.deepEqual(parseGroupsSafe("[]"), {}); +}); + +// ---- wrapper CLI fail-loud (subprocess) ------------------------------------- +test( + "fails loud (non-zero + ::error::) when running under Actions with GITHUB_OUTPUT unset", + { timeout: 30_000 }, + async () => { + const { status, stderr } = await runWrapper({ + GITHUB_ACTIONS: "true", + GITHUB_OUTPUT: undefined, + MODE: "stable", + NPM_RESULT: "success", + BUILD_RESULT: "success", + TS_PACKAGES: '[{"name":"@ag-ui/core","version":"1.0.0"}]', + TS_GROUPS: '{"latest":["@ag-ui/core"]}', + }); + assert.notEqual(status, 0); + assert.match(stderr, /::error::/); + }, +); + +test( + "writes output and exits 0 when GITHUB_OUTPUT is set", + { timeout: 30_000 }, + async () => { + const dir = mkTmp(); + try { + const out = join(dir, "gho.txt"); + writeFileSync(out, ""); + const { status } = await runWrapper({ + GITHUB_ACTIONS: "true", + GITHUB_OUTPUT: out, + MODE: "stable", + NPM_RESULT: "success", + BUILD_RESULT: "success", + TS_PACKAGES: '[{"name":"@ag-ui/core","version":"1.0.0"}]', + TS_GROUPS: '{"latest":["@ag-ui/core"]}', + }); + assert.equal(status, 0); + const raw = readFileSync(out, "utf8"); + assert.ok(raw.includes("should_post=true")); + assert.match(raw, /^message<<\S+/m); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, +); + +// ---- wrapper CLI DRY_RUN string coercion (subprocess) ----------------------- +async function postFor( + dryRun: string, +): Promise<{ status: number; raw: string }> { + const dir = mkTmp(); + try { + const out = join(dir, "gho.txt"); + writeFileSync(out, ""); + const { status } = await runWrapper({ + GITHUB_ACTIONS: "true", + GITHUB_OUTPUT: out, + MODE: "stable", + NPM_RESULT: "success", + BUILD_RESULT: "success", + TS_PACKAGES: '[{"name":"@ag-ui/core","version":"1.0.0"}]', + TS_GROUPS: '{"latest":["@ag-ui/core"]}', + DRY_RUN: dryRun, + }); + return { status, raw: readFileSync(out, "utf8") }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test( + 'DRY_RUN="true" → should_post=false (suppressed)', + { timeout: 30_000 }, + async () => { + const { status, raw } = await postFor("true"); + assert.equal(status, 0); + assert.ok(raw.includes("should_post=false")); + }, +); + +test( + 'DRY_RUN="false" → posts on an otherwise-successful stable run', + { timeout: 30_000 }, + async () => { + const { status, raw } = await postFor("false"); + assert.equal(status, 0); + assert.ok(raw.includes("should_post=true")); + }, +); + +test( + 'DRY_RUN="" (empty) → posts on an otherwise-successful stable run', + { timeout: 30_000 }, + async () => { + const { status, raw } = await postFor(""); + assert.equal(status, 0); + assert.ok(raw.includes("should_post=true")); + }, +); + +// ---- wrapper CLI end-to-end message rendering (subprocess) ------------------ +test( + "mixed lane: npm success + PyPI failure → one 🚀 line and one 🔴 line in one message", + { timeout: 30_000 }, + async () => { + const dir = mkTmp(); + try { + const out = join(dir, "gho.txt"); + writeFileSync(out, ""); + const { status } = await runWrapper({ + GITHUB_ACTIONS: "true", + GITHUB_OUTPUT: out, + MODE: "stable", + NPM_RESULT: "success", + BUILD_RESULT: "success", + NPM_INTENDED: "true", + TS_PACKAGES: '[{"name":"@ag-ui/core","version":"1.0.0"}]', + TS_GROUPS: '{"latest":["@ag-ui/core"]}', + PY_INTENDED: "true", + PY_RESULT: "failure", + PY_BUILD_RESULT: "success", + // Build succeeded and detected a PyPI package, then the shared publish + // job failed: the detected package set is the authoritative + // "release attempted" signal the failure arm now gates on. + PY_PACKAGES: '[{"name":"ag-ui-protocol","version":"1.0.0"}]', + RUN_URL, + }); + assert.equal(status, 0); + const m = readFileSync(out, "utf8").match( + /^message<<(\S+)\n([\s\S]*?)\n\1\n/m, + ); + assert.notEqual(m, null); + const message = m![2]; + assert.ok(message.includes("🚀")); + assert.ok(message.includes("🔴")); + assert.ok(message.includes("PyPI release failed")); + assert.equal(message.split("\n").length, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, +); + +test( + "PyPI build failure during a real release (PY_BUILD_RESULT=failure, publish skipped) → 🔴 PyPI alert", + { timeout: 30_000 }, + async () => { + const dir = mkTmp(); + try { + const out = join(dir, "gho.txt"); + writeFileSync(out, ""); + const { status } = await runWrapper({ + GITHUB_ACTIONS: "true", + GITHUB_OUTPUT: out, + PY_INTENDED: "true", + PY_RESULT: "skipped", + PY_BUILD_RESULT: "failure", + RUN_URL, + }); + assert.equal(status, 0); + const raw = readFileSync(out, "utf8"); + assert.ok(raw.includes("should_post=true")); + const m = raw.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m); + assert.notEqual(m, null); + assert.ok(m![2].includes("🔴")); + assert.ok(m![2].includes("PyPI release failed")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, +); + +test( + "routine merge (MODE='', no intent, build skipped) → should_post=false (no false red)", + { timeout: 30_000 }, + async () => { + const dir = mkTmp(); + try { + const out = join(dir, "gho.txt"); + writeFileSync(out, ""); + const { status } = await runWrapper({ + GITHUB_ACTIONS: "true", + GITHUB_OUTPUT: out, + MODE: "", + BUILD_RESULT: "skipped", + NPM_RESULT: "skipped", + NPM_INTENDED: "false", + PY_INTENDED: "false", + PY_RESULT: "skipped", + PY_BUILD_RESULT: "skipped", + RUN_URL, + }); + assert.equal(status, 0); + assert.ok(readFileSync(out, "utf8").includes("should_post=false")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, +); + +test( + "npm success with empty TS_PACKAGES (degraded) → no false success line", + { timeout: 30_000 }, + async () => { + const dir = mkTmp(); + try { + const out = join(dir, "gho.txt"); + writeFileSync(out, ""); + const { status } = await runWrapper({ + GITHUB_ACTIONS: "true", + GITHUB_OUTPUT: out, + MODE: "stable", + NPM_RESULT: "success", + BUILD_RESULT: "success", + TS_PACKAGES: "", + TS_GROUPS: "", + }); + assert.equal(status, 0); + assert.ok(readFileSync(out, "utf8").includes("should_post=false")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/scripts/release/lib/build-release-notification.ts b/scripts/release/lib/build-release-notification.ts new file mode 100644 index 0000000000..2186a96644 --- /dev/null +++ b/scripts/release/lib/build-release-notification.ts @@ -0,0 +1,406 @@ +/** + * Pure message-builder for the post-release #engr Slack notification. + * + * This is the load-bearing truth table for what (if anything) gets posted to + * Slack after the publish-release.yml workflow runs. It is deliberately a PURE + * function of its inputs so the full truth table can be unit-tested without any + * GitHub Actions / network involvement. The thin CLI wrapper + * (scripts/release/build-release-notification.ts) parses env vars, calls this + * function, and writes the result to GITHUB_OUTPUT. + * + * Ported from CopilotKit's scripts/release/lib/build-release-notification.ts. + * The truth table (suppression rules, lane independence, intent gating, + * cancelled-is-neutral, page-on-uncertainty) is preserved verbatim in spirit; + * the only divergence is the SUCCESS message shape. CopilotKit resolves a + * package COUNT from release.config.json and renders one summary line; ag-ui + * instead receives the actual published-package SETS from the build job's + * registry-diff outputs (ts_packages + ts_groups_json for npm, py_packages for + * PyPI), so the success line is rendered directly from those sets — count, + * package names, and (for npm) dist-tag grouping — as ONE concise message per + * lane, never one-per-package. + * + * Build/publish job topology — SINGLE SHARED JOBS (ag-ui divergence): + * + * ag-ui runs ONE build job and ONE publish job spanning BOTH lanes. The + * workflow wires BUILD_RESULT and PY_BUILD_RESULT both to needs.build.result, + * and NPM_RESULT and PY_RESULT both to needs.publish.result. So the per-lane + * build-vs-publish-skip distinction from CopilotKit (which had separate + * build-python / publish-python jobs, where a build-stage failure skipped the + * matching publish job and produced a distinct per-lane signal) does NOT apply + * here — a build failure reds BOTH lanes' build signal, a publish failure reds + * BOTH lanes' publish signal. Lane attribution therefore comes from the + * published-package SETS (tsPackages vs pyPackages) — the AUTHORITATIVE + * "this lane actually attempted a release" signal — with the event-derived + * intent gates (npmIntended / pyIntended) used only as a BUILD-FAILURE + * FALLBACK (when the build failed before detection could populate the set), + * NOT from which job result is red. + * (CopilotKit lineage: the buildResult/pyBuildResult split that once let a + * single ecosystem's build failure page just that lane no longer applies.) + * KNOWN LIMITATION: because npm and PyPI share ONE publish job, a single-lane + * publish failure reds the shared result for BOTH lanes; if both lanes + * detected packages, both red lines may show (safe over-report). True + * per-lane attribution needs the publish job to emit per-lane outputs. The + * shared BUILD job has the SAME coupling: a build failure in one ecosystem's + * steps sets needs.build.result=failure for BOTH lanes, so a TS-only build + * failure can red the PyPI lane (and vice-versa) when the other lane also + * detected packages or was intended. Same safe over-report direction; true + * per-lane attribution needs the build job to emit per-lane results. + * + * Failure model — TWO INDEPENDENT LANES (npm + PyPI): + * + * - dry-run → no post (entirely suppressed). + * + * - canary (mode === "prerelease") → no post AT ALL, BOTH lanes (success AND + * failure). A canary run is fully suppressed: canaries are noise and we want + * exactly one concise message per stable release, never canary spam. This + * matches the npm-lane canary suppression and now extends it to the PyPI + * lane, so a canary PyPI publish-failure never pages while a canary success + * posts nothing — consistent silence on both lanes. + * + * - npm lane (stable only — canary already short-circuited above): + * • SUCCESS line when mode==stable && npmResult==success && ≥1 published + * package. Rendered from tsPackages (count + names) grouped by dist-tag + * via tsGroups. An EMPTY published set is anomalous (success result but + * nothing published) and renders NO success line — never a false + * success. + * • FAILURE alert (lane-level, NOT step-level) when + * (npmResult==failure || buildResult==failure) AND (tsPackages.length>0 + * OR (buildResult==failure && npmIntended)). PRIMARY gate is the + * detected package set (tsPackages) — the authoritative attempted-a- + * release signal; event-derived npmIntended is only the BUILD-FAILURE + * FALLBACK so an early build failure (before detection ran) on an + * intended release still pages. NOT additionally gated on mode==stable + * (which would swallow a real stable publish failure whose mode output + * came back empty). The publish step may have succeeded with a LATER + * tag/release step failing, so the wording is "release failed", never + * "publish failed". + * + * - PyPI lane (stable only — canary already short-circuited above): + * • SUCCESS line when mode==stable && pyResult==success && ≥1 published + * package, rendered from pyPackages (count + names). Symmetric with the + * npm SUCCESS arm: BOTH lanes require mode==="stable" so neither claims a + * success on a degraded/empty MODE (the canary mode==="prerelease" case + * is already fully suppressed by the early-return above; this gate guards + * the mode==="" degraded case). py_packages is only populated on a stable + * release, so this never suppresses a legitimate post. + * • FAILURE alert when (pyResult==failure || pyBuildResult==failure) AND + * (pyPackages.length>0 OR (pyBuildResult==failure && pyIntended)). + * Symmetric with the npm lane: PRIMARY gate is the detected PyPI package + * set (pyPackages); event-derived pyIntended is only the BUILD-FAILURE + * FALLBACK. The pyBuildResult arm closes the gap where the build job + * FAILS during a genuine release → publish is skipped → pyResult is + * "skipped", so a bare pyResult check would post nothing. The + * build-failure fallback to pyIntended catches a build failure that + * never emitted publish outputs, and keeps routine non-Python merges + * quiet. + * + * - cancelled is NEUTRAL everywhere — never a failure line. (GitHub has no + * timeout-specific result; a job hitting timeout-minutes reports + * "cancelled", which correctly stays neutral.) + * + * - a skipped lane contributes NOTHING (no false red). + * - shouldPost is true iff ≥1 line (success OR failure) was emitted; an empty + * message never posts. + * + * See build-release-notification.test.ts for the exhaustive truth table. + */ + +export type ReleaseMode = "stable" | "prerelease" | ""; + +/** + * GitHub Actions `result` values for a needed job. These are the ONLY values + * GitHub emits: success | failure | cancelled | skipped (plus "" when unset). + * We only treat "success" and "failure" as actionable; + * "skipped"/"cancelled"/"" are neutral. + */ +export type JobResult = "success" | "failure" | "skipped" | "cancelled" | ""; + +/** A published package, as carried in the build job's package arrays. */ +export interface PublishedPackage { + name: string; + version: string; +} + +/** dist-tag → package-name list (ag-ui's ts_groups_json shape). */ +export type DistTagGroups = Record; + +export interface BuildReleaseNotificationInput { + /** needs.build.outputs.mode — "stable" | "prerelease" | "". */ + mode: ReleaseMode; + /** needs.publish.result — the shared publish job result (npm lane view). */ + npmResult: JobResult; + /** + * needs.build.result — the shared build job result (npm lane view). ag-ui has + * ONE build job spanning both lanes, so this is the SAME value as + * pyBuildResult (both wired to needs.build.result). The CopilotKit per-lane + * build-vs-publish distinction does NOT apply here. Catches build-stage + * failures on the npm side. + */ + buildResult: JobResult; + /** + * NPM_INTENDED — "true" when the notify job determined an npm release was + * actually attempted (a push whose compare-range touched a package.json, or + * a workflow_dispatch stable lane). FALLBACK signal for the npm FAILURE arm: + * used only when the BUILD failed before the detected package set could be + * populated, so an early build failure on a genuine npm release still pages. + * The primary failure gate is the detected package set (tsPackages). + */ + npmIntended: string; + /** needs.build.outputs.ts_packages — the published npm package set. */ + tsPackages: PublishedPackage[]; + /** needs.build.outputs.ts_groups_json — dist-tag groupings for the npm set. */ + tsGroups: DistTagGroups; + /** + * PY_INTENDED — "true" when the notify job determined a Python release was + * intended (a push whose compare-range touched a pyproject.toml, or a + * workflow_dispatch stable lane). FALLBACK signal for the PyPI FAILURE arm: + * used only when the BUILD failed before the detected package set could be + * populated. The primary failure gate is the detected package set + * (pyPackages). + */ + pyIntended: string; + /** + * needs.publish.result mapped to the PyPI lane. ag-ui publishes BOTH lanes + * from the SAME publish job, so this is the SAME value as npmResult. + */ + pyResult: JobResult; + /** + * needs.build.result mapped to the PyPI lane. ag-ui has ONE build job, so + * this is the SAME value as buildResult. The CopilotKit lineage where a + * separate build-python failure pages only the PyPI lane no longer applies. + */ + pyBuildResult: JobResult; + /** needs.build.outputs.py_packages — the published PyPI package set. */ + pyPackages: PublishedPackage[]; + /** needs.build.outputs.scope. Reserved for future use; not rendered today. */ + scope: string; + /** inputs.dry_run — true on a dry-run dispatch. */ + dryRun: boolean; + /** URL to this workflow run (for failure "View run" links). */ + runUrl: string; + /** URL to the npm org page (https://www.npmjs.com/org/ag-ui). */ + npmOrgUrl: string; + /** Base URL for PyPI project pages (https://pypi.org/project). */ + pyBaseUrl: string; +} + +export interface BuildReleaseNotificationResult { + /** The combined Slack message (mrkdwn). Empty when shouldPost is false. */ + message: string; + /** True iff there is ≥1 success line OR ≥1 failure line. */ + shouldPost: boolean; +} + +/** Maximum package names to list inline before collapsing to "+N more". */ +const MAX_NAMES = 5; + +function pluralize(count: number, noun: string): string { + return count === 1 ? `1 ${noun}` : `${count} ${noun}s`; +} + +/** Render a capped, comma-joined name list with a "+N more" overflow tail. */ +function renderNameList(names: string[]): string { + if (names.length <= MAX_NAMES) { + return names.join(", "); + } + const shown = names.slice(0, MAX_NAMES); + const remaining = names.length - MAX_NAMES; + return `${shown.join(", ")}, +${remaining} more`; +} + +/** + * Render the npm dist-tag breakdown for the success line. Each group is shown + * as "``: ". Groups are sorted with "latest" first, then + * alphabetically, so the most common case reads naturally. Empty-array groups + * are skipped so a degraded ts_groups_json never renders a malformed "`tag`: " + * fragment with no names. + */ +function renderNpmGroups(groups: DistTagGroups): string { + const tags = Object.keys(groups) + .filter((tag) => groups[tag].length > 0) + .sort((a, b) => { + if (a === "latest") return -1; + if (b === "latest") return 1; + return a.localeCompare(b); + }); + return tags + .map((tag) => `\`${tag}\`: ${renderNameList(groups[tag])}`) + .join(" · "); +} + +/** + * Build the #engr Slack message for a release run. Pure function: same inputs + * always produce the same output. + */ +export function buildReleaseNotification( + input: BuildReleaseNotificationInput, +): BuildReleaseNotificationResult { + const empty: BuildReleaseNotificationResult = { + message: "", + shouldPost: false, + }; + + // Dry-run never posts (no real publish happened on any lane). + if (input.dryRun) { + return empty; + } + + // Canary (mode === "prerelease") is fully suppressed on BOTH lanes — success + // AND failure. Canaries are noise; we want exactly one concise message per + // stable release. This sits at the same level as the dry-run early-return so + // neither a success nor a failure line is produced for any canary run. + if (input.mode === "prerelease") { + return empty; + } + + // Event-derived intent signals computed in the notify job. These are now the + // BUILD-FAILURE FALLBACK for each failure arm (used only when the build + // failed before the detected package set could populate); the PRIMARY failure + // gate is the detected package set (tsPackages / pyPackages). + const npmIntended = input.npmIntended === "true"; + const pyIntended = input.pyIntended === "true"; + + const lines: string[] = []; + + // --- npm lane (stable only — canary already short-circuited above) ------ + if ( + input.mode === "stable" && + input.npmResult === "success" && + input.tsPackages.length > 0 + ) { + const count = input.tsPackages.length; + // Prefer the dist-tag grouping (carries tag context); fall back to a flat + // name list from tsPackages if groups came back empty/degraded. When groups + // ARE populated, validate that they agree with the tsPackages set on TWO + // axes, because the count (from tsPackages) and the rendered names (from + // tsGroups) must never disagree: + // 1. TOTAL count — the sum of grouped names ACROSS all groups, INCLUDING + // duplicates, must equal tsPackages.length. A name appearing in two + // groups would dedupe to the same Set size yet render more names than + // the count claims, so a Set-only check would miss it. + // 2. Set membership — the deduped set of grouped names must exactly match + // the tsPackages name set (catches a dropped group, or a phantom name + // listed that isn't in tsPackages). + // On ANY mismatch, warn and fall back to the flat list so count and names + // always agree. (renderNpmGroups already skips empty-array groups; this + // total-count axis additionally catches the multi-group-duplicate case.) + let breakdown: string; + if (Object.keys(input.tsGroups).length > 0) { + const groupNames = new Set(); + let totalGroupedNames = 0; + for (const names of Object.values(input.tsGroups)) { + for (const n of names) { + groupNames.add(n); + totalGroupedNames += 1; + } + } + const packageNames = new Set(input.tsPackages.map((p) => p.name)); + const sameTotalCount = totalGroupedNames === input.tsPackages.length; + const sameMembership = + groupNames.size === packageNames.size && + [...groupNames].every((n) => packageNames.has(n)); + if (sameTotalCount && sameMembership) { + breakdown = renderNpmGroups(input.tsGroups); + } else { + console.warn( + "::warning::npm dist-tag groups (ts_groups_json) disagree with the published package set (ts_packages) — falling back to a flat name list so the count and names agree.", + ); + breakdown = renderNameList(input.tsPackages.map((p) => p.name)); + } + } else { + breakdown = renderNameList(input.tsPackages.map((p) => p.name)); + } + lines.push( + `🚀 *ag-ui release* · ${pluralize(count, "npm package")} published ` + + `(${breakdown}) · ` + + `<${input.npmOrgUrl}|npm>`, + ); + } else if ( + (input.npmResult === "failure" || input.buildResult === "failure") && + (input.tsPackages.length > 0 || + (input.buildResult === "failure" && npmIntended)) + ) { + // FAILURE gating keys off the DETECTED PACKAGE SET, not the event-derived + // intent. The detected set (tsPackages.length > 0) is the authoritative + // "this lane actually attempted a release" signal → page on its failure + // regardless of which manifest the push touched. This closes a + // silent-swallow: detect_ts diffs LOCAL manifests against the REGISTRY, so + // a push that only touched the OTHER ecosystem's manifest can still + // re-detect a STALE unpublished bump from a prior failed release; intent + // (compare-range) for THIS lane is then false, which under the old + // intent-only gate would shut the arm and swallow a real publish failure. + // When the BUILD failed before detection could populate the package set, we + // fall back to the event-derived intent (npmIntended) so an early build + // failure on an intended release still pages — never toward silence. When + // the build SUCCEEDED but detected no packages (e.g. a dependabot dep bump + // that touched package.json without bumping the package's own version), + // this lane does NOT page (fixes the prior false-positive). + // + // KNOWN LIMITATION (out of scope — needs a publish-job change): npm and + // PyPI share ONE publish job, so npmResult and pyResult are both + // needs.publish.result. A single-lane publish failure therefore marks the + // shared result "failure"; if the OTHER lane also detected packages, both + // red lines may show. This is the safe over-report direction; true per-lane + // attribution requires the publish job to emit per-lane outputs. The shared + // BUILD job has the same coupling: buildResult is needs.build.result for + // BOTH lanes, so a TS-only build failure can red the PyPI lane (and + // vice-versa) when the other lane also detected packages or was intended. + // + // Lane-level wording: a later tag/release step may have failed while + // publish itself succeeded, so never say "npm publish failed". + lines.push(`🔴 *ag-ui npm release failed* · <${input.runUrl}|View run>`); + } + // cancelled / skipped on the npm lane are NEUTRAL → no line. + + // --- PyPI lane (stable only — canary already short-circuited above) ----- + if ( + input.mode === "stable" && + input.pyResult === "success" && + input.pyPackages.length > 0 + ) { + const count = input.pyPackages.length; + const names = renderNameList(input.pyPackages.map((p) => p.name)); + // Link to the flagship project page. ag-ui's flagship is ag-ui-protocol; + // select it explicitly by name if present (nothing sorts pyPackages, so we + // must not assume index 0 is the flagship), else fall back to the first + // published package. + const flagship = + input.pyPackages.find((p) => p.name === "ag-ui-protocol")?.name ?? + input.pyPackages[0].name; + lines.push( + `🐍 *ag-ui release* · ${pluralize(count, "PyPI package")} published ` + + `(${names}) · ` + + `<${input.pyBaseUrl}/${flagship}/|PyPI>`, + ); + } else if ( + (input.pyResult === "failure" || input.pyBuildResult === "failure") && + (input.pyPackages.length > 0 || + (input.pyBuildResult === "failure" && pyIntended)) + ) { + // Symmetric with the npm failure arm: gate on the DETECTED PACKAGE SET + // (pyPackages.length > 0) as the authoritative "this lane attempted a + // release" signal → page on its failure regardless of which manifest the + // push touched (closes the stale-cross-lane silent-swallow where detect_py + // re-detects a stale unpublished bump on an npm-only push). When the BUILD + // failed before detection could populate the package set, fall back to the + // event-derived pyIntended so an early build failure on an intended release + // still pages. When the build SUCCEEDED but detected no packages, this lane + // does NOT page. Use pyBuildResult === "failure" (NOT "skipped"): a + // CANCELLED build reports "cancelled" and stays NEUTRAL, so a deliberate + // cancel never false-REDs. + // + // Same KNOWN LIMITATION as the npm arm: the shared publish job means a + // single-lane publish failure reds both lanes' result; safe over-report. + // The shared BUILD job has the same coupling: pyBuildResult is + // needs.build.result for BOTH lanes, so a PyPI-only build failure can red + // the npm lane (and vice-versa) when the other lane also detected packages + // or was intended. + lines.push(`🔴 *ag-ui PyPI release failed* · <${input.runUrl}|View run>`); + } + + if (lines.length === 0) { + return empty; + } + + return { message: lines.join("\n"), shouldPost: true }; +} diff --git a/scripts/release/release.config.json b/scripts/release/release.config.json index 42b93cc01c..7125481e7a 100644 --- a/scripts/release/release.config.json +++ b/scripts/release/release.config.json @@ -9,10 +9,30 @@ { "name": "@ag-ui/core", "path": "sdks/typescript/packages/core", "ecosystem": "typescript" }, { "name": "@ag-ui/client", "path": "sdks/typescript/packages/client", "ecosystem": "typescript" }, { "name": "@ag-ui/encoder", "path": "sdks/typescript/packages/encoder", "ecosystem": "typescript" }, - { "name": "@ag-ui/proto", "path": "sdks/typescript/packages/proto", "ecosystem": "typescript" }, + { "name": "@ag-ui/proto", "path": "sdks/typescript/packages/proto", "ecosystem": "typescript" } + ] + }, + "create-ag-ui-app": { + "description": "create-ag-ui-app CLI (independently versioned)", + "sharedVersion": false, + "packages": [ { "name": "create-ag-ui-app", "path": "sdks/typescript/packages/cli", "ecosystem": "typescript" } ] }, + "sdk-ts-a2ui-toolkit": { + "description": "A2UI Toolkit (standalone TypeScript SDK package, independently versioned)", + "sharedVersion": false, + "packages": [ + { "name": "@ag-ui/a2ui-toolkit", "path": "sdks/typescript/packages/a2ui-toolkit", "ecosystem": "typescript" } + ] + }, + "sdk-py-a2ui-toolkit": { + "description": "A2UI Toolkit (standalone Python SDK package, independently versioned)", + "sharedVersion": false, + "packages": [ + { "name": "ag-ui-a2ui-toolkit", "path": "sdks/python/a2ui_toolkit", "ecosystem": "python", "buildSystem": "uv" } + ] + }, "sdk-py": { "description": "Python SDK", "sharedVersion": false, @@ -27,7 +47,14 @@ { "name": "@ag-ui/a2a", "path": "integrations/a2a/typescript", "ecosystem": "typescript" } ] }, - "integration-adk": { + "integration-adk-ts": { + "description": "ADK integration (TypeScript)", + "sharedVersion": false, + "packages": [ + { "name": "@ag-ui/adk", "path": "integrations/adk-middleware/typescript", "ecosystem": "typescript" } + ] + }, + "integration-adk-py": { "description": "ADK Middleware integration (Python)", "sharedVersion": false, "packages": [ @@ -55,7 +82,14 @@ { "name": "@ag-ui/agno", "path": "integrations/agno/typescript", "ecosystem": "typescript" } ] }, - "integration-aws-strands": { + "integration-aws-strands-ts": { + "description": "AWS Strands integration (TypeScript)", + "sharedVersion": false, + "packages": [ + { "name": "@ag-ui/aws-strands", "path": "integrations/aws-strands/typescript", "ecosystem": "typescript" } + ] + }, + "integration-aws-strands-py": { "description": "AWS Strands integration (Python)", "sharedVersion": false, "packages": [ @@ -69,6 +103,20 @@ { "name": "@ag-ui/claude-agent-sdk", "path": "integrations/claude-agent-sdk/typescript", "ecosystem": "typescript" } ] }, + "integration-claude-agent-sdk-py": { + "description": "Claude Agent SDK integration (Python)", + "sharedVersion": false, + "packages": [ + { "name": "ag-ui-claude-sdk", "path": "integrations/claude-agent-sdk/python", "ecosystem": "python", "buildSystem": "uv" } + ] + }, + "integration-cloudflare-agents": { + "description": "Cloudflare Agents integration (community, TypeScript)", + "sharedVersion": false, + "packages": [ + { "name": "@ag-ui/cloudflare-agents", "path": "integrations/community/cloudflare-agents/typescript", "ecosystem": "typescript" } + ] + }, "integration-crewai-ts": { "description": "CrewAI integration (TypeScript)", "sharedVersion": false, @@ -108,7 +156,7 @@ "description": "Langroid integration (Python)", "sharedVersion": false, "packages": [ - { "name": "ag_ui_langroid", "path": "integrations/langroid/python", "ecosystem": "python", "buildSystem": "uv" } + { "name": "ag-ui-langroid", "path": "integrations/langroid/python", "ecosystem": "python", "buildSystem": "uv" } ] }, "integration-llama-index": { @@ -173,6 +221,13 @@ "packages": [ { "name": "@ag-ui/mcp-apps-middleware", "path": "middlewares/mcp-apps-middleware", "ecosystem": "typescript" } ] + }, + "middleware-mcp": { + "description": "MCP Middleware (TypeScript)", + "sharedVersion": false, + "packages": [ + { "name": "@ag-ui/mcp-middleware", "path": "middlewares/mcp-middleware", "ecosystem": "typescript" } + ] } } } diff --git a/scripts/release/verify-config-manifest-names.sh b/scripts/release/verify-config-manifest-names.sh new file mode 100755 index 0000000000..cd764543d9 --- /dev/null +++ b/scripts/release/verify-config-manifest-names.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# scripts/release/verify-config-manifest-names.sh +# +# Verifies that the `name` declared for each package in +# scripts/release/release.config.json matches the actual package name in that +# package's on-disk manifest (package.json for TypeScript, pyproject.toml for +# Python — either PEP 621 `[project]` or poetry `[tool.poetry]`). +# +# Why this matters: release.config.json is the single source of truth that the +# version-bumper (prepare-release.ts), the dropdown guard, the nx allowlist +# guard, and the notify-job ecosystem map all key off. The `name` field is used +# in PR bodies, release-notes attribution and human-facing summaries. If it +# drifts from the real published name nobody notices until a release ships with +# the wrong label — exactly what happened with the langroid integration, whose +# config `name` was the underscore form `ag_ui_langroid` while its pyproject +# (and the actual PyPI distribution) is the hyphenated `ag-ui-langroid`. +# +# This guard cross-checks every config package `name` against its manifest at +# `path` and fails CI on any divergence. +# +# Note on `path`: each package's `path` is also validated transitively here — +# a missing/typo'd path surfaces as a missing manifest (loud failure below). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +CONFIG="$REPO_ROOT/scripts/release/release.config.json" + +if [ ! -f "$CONFIG" ]; then + echo "ERROR: $CONFIG not found" >&2 + exit 1 +fi + +# Emit one TSV line per package: "\t\t\t\t". +PACKAGES=$(jq -r ' + .scopes | to_entries[] + | .key as $s + | .value.packages[] + | [$s, .name, .path, .ecosystem, (.buildSystem // "-")] | @tsv +' "$CONFIG") + +rc=0 +while IFS=$'\t' read -r scope name path ecosystem build_system; do + [ -z "$scope" ] && continue + + if [ "$ecosystem" = "typescript" ]; then + manifest="$REPO_ROOT/$path/package.json" + if [ ! -f "$manifest" ]; then + echo "ERROR: [$scope] $name: package.json not found at $path" >&2 + rc=1 + continue + fi + actual=$(jq -r '.name // empty' "$manifest") + else + manifest="$REPO_ROOT/$path/pyproject.toml" + if [ ! -f "$manifest" ]; then + echo "ERROR: [$scope] $name: pyproject.toml not found at $path" >&2 + rc=1 + continue + fi + # Extract the name using tomllib (3.11+) or the tomli backport, mirroring + # detect-py-version-changes.sh. uv/PEP 621 packages live under [project]; + # poetry packages under [tool.poetry]. + actual=$(MANIFEST="$manifest" BUILD_SYSTEM="$build_system" python3 -c " +import os, sys +try: + import tomllib +except ImportError: + import tomli as tomllib +with open(os.environ['MANIFEST'], 'rb') as f: + cfg = tomllib.load(f) +bs = os.environ['BUILD_SYSTEM'] +try: + if bs == 'poetry': + print(cfg['tool']['poetry']['name']) + else: + print(cfg['project']['name']) +except KeyError as e: + print(f'ERROR: missing key {e} in {os.environ[\"MANIFEST\"]}', file=sys.stderr) + sys.exit(1) +") || { echo "ERROR: [$scope] $name: could not read name from $path/pyproject.toml" >&2; rc=1; continue; } + fi + + if [ -z "$actual" ]; then + echo "ERROR: [$scope] $name: manifest at $path has no package name" >&2 + rc=1 + continue + fi + + if [ "$name" != "$actual" ]; then + echo "ERROR: [$scope] release.config.json name '$name' != manifest name '$actual' at $path" >&2 + rc=1 + fi +done <<< "$PACKAGES" + +if [ "$rc" -ne 0 ]; then + echo "" >&2 + echo "Fix: make each release.config.json package 'name' exactly match the name in" >&2 + echo "its manifest (package.json '.name', or pyproject.toml [project]/[tool.poetry] name)." >&2 + exit 1 +fi + +echo "OK: every release.config.json package name matches its on-disk manifest" +exit 0 diff --git a/scripts/release/verify-release-scope-dropdowns.sh b/scripts/release/verify-release-scope-dropdowns.sh new file mode 100755 index 0000000000..3b0db56470 --- /dev/null +++ b/scripts/release/verify-release-scope-dropdowns.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# scripts/release/verify-release-scope-dropdowns.sh +# +# Verifies that the hand-maintained `workflow_dispatch` `scope` choice +# dropdowns in the release workflows match the authoritative set of release +# scopes declared in scripts/release/release.config.json (`.scopes` keys). +# +# Why this matters: the release workflows expose a `scope` input as a +# `type: choice` with a hard-coded `options:` list. That list is supposed to +# be "regenerated from release.config.json", but nothing enforced it — so as +# packages were enrolled/renamed in release.config.json the dropdowns drifted +# (newly-enrolled packages weren't canary-selectable; stale scopes lingered). +# This guard fails CI whenever a dropdown diverges from the config. +# +# Three files are checked: +# .github/workflows/publish-release.yml — canary/prerelease `scope` input +# .github/workflows/prepare-release.yml — create-pr `scope` input +# .github/workflows/canary.yml — one-click canary orchestrator `scope` input +# +# Sentinel exception: neither workflow uses a non-scope sentinel option (no +# `all` / `canary` pseudo-scope — an empty/omitted scope is handled outside +# the options list). If a sentinel is ever introduced, add it to +# SENTINELS below so it is excluded from the equality check. +# +# THIRD scope projection guarded here: publish-release.yml's `notify` job has a +# `Compute release intent` step whose `case "$SCOPE"` maps a dispatch scope to +# its ecosystem (PyPI vs npm) for FAILURE paging. That step runs before +# checkout, so it cannot read release.config.json at runtime and instead carries +# a static list of the python scopes that do NOT end in `-py`. check_notify_case +# below asserts that list (and the `*-py` glob) projects every config scope to +# the correct ecosystem, so this hand-maintained list cannot drift. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +CONFIG="$REPO_ROOT/scripts/release/release.config.json" +PUBLISH_WF="$REPO_ROOT/.github/workflows/publish-release.yml" +PREPARE_WF="$REPO_ROOT/.github/workflows/prepare-release.yml" +CANARY_WF="$REPO_ROOT/.github/workflows/canary.yml" + +# Documented non-scope sentinel options to ignore (none today). Space-separated. +SENTINELS="" + +for f in "$CONFIG" "$PUBLISH_WF" "$PREPARE_WF" "$CANARY_WF"; do + if [ ! -f "$f" ]; then + echo "ERROR: $f not found" >&2 + exit 1 + fi +done + +# Authoritative scope set from release.config.json. +CONFIG_SCOPES=$(jq -r '.scopes | keys[]' "$CONFIG" | sort -u) + +# Extract the `options:` list belonging to the `scope:` input from a workflow. +# Uses yq when available (the CI path on ubuntu-latest), otherwise a robust awk +# pass (the local-dev fallback): +# - find the `scope:` input key (an `inputs:` child, indented 6 spaces), +# - within that block find its `options:` line, +# - collect the `- value` list items until indentation drops back out. +# +# yq path: the `on` key is quoted as .["on"] so it is read as the literal map +# key and never YAML-1.1-boolean-coerced (`on`/`off`/`yes`/`no` → true/false). +# The result is emitted on stdout; callers MUST treat zero options as a PARSER +# failure (loud), distinct from a real drift mismatch — see check_workflow. +extract_scope_options() { + local file="$1" + if command -v yq >/dev/null 2>&1; then + yq -r '.["on"].workflow_dispatch.inputs.scope.options[]' "$file" | sort -u + return + fi + awk ' + # Match the scope input key: " scope:" (6-space indent under inputs:). + /^ scope:[[:space:]]*$/ { in_scope = 1; next } + in_scope && /^ [a-zA-Z0-9_-]+:[[:space:]]*$/ { in_scope = 0 } # next sibling input + in_scope && /^ options:[[:space:]]*$/ { in_opts = 1; next } + in_opts { + # An options list item: " - value" + if (match($0, /^[[:space:]]*-[[:space:]]+/)) { + val = $0 + sub(/^[[:space:]]*-[[:space:]]+/, "", val) + sub(/[[:space:]]+$/, "", val) + print val + next + } + # Any non-list-item line ends the options block. + in_opts = 0 + in_scope = 0 + } + ' "$file" | sort -u +} + +# Strip documented sentinels from an option set before comparing. +strip_sentinels() { + local opts="$1" + if [ -z "$SENTINELS" ]; then + printf '%s\n' "$opts" + return + fi + local filtered="$opts" + for s in $SENTINELS; do + filtered=$(printf '%s\n' "$filtered" | grep -vx "$s" || true) + done + printf '%s\n' "$filtered" +} + +check_workflow() { + local name="$1" file="$2" + local opts + opts=$(extract_scope_options "$file") + opts=$(strip_sentinels "$opts") + + # Zero options means the PARSER could not locate the scope options block (a + # yq/awk extraction failure or a structural change to the workflow), NOT that + # the dropdown drifted. Fail LOUD and distinctly so this is never mistaken for + # a real drift mismatch (which prints a diff below). + if [ -z "$opts" ]; then + echo "ERROR: parser could not find scope options in $file ($name)." >&2 + echo " Extracted ZERO options via $(command -v yq >/dev/null 2>&1 && echo yq || echo 'awk fallback')." >&2 + echo " This is a PARSER failure (not a drift mismatch): the 'scope' input's" >&2 + echo " 'options:' list could not be located. Check the workflow structure or" >&2 + echo " the extractor in this script." >&2 + return 1 + fi + + if [ "$opts" = "$CONFIG_SCOPES" ]; then + echo "OK: $name scope dropdown matches release.config.json scopes" + return 0 + fi + + echo "ERROR: $name scope dropdown is out of sync with release.config.json." >&2 + echo "" >&2 + echo "--- diff (release.config.json scopes vs $name options) ---" >&2 + diff <(printf '%s\n' "$CONFIG_SCOPES") <(printf '%s\n' "$opts") >&2 || true + echo "" >&2 + echo "Fix: update the 'scope' input 'options:' list in $file to exactly match" >&2 + echo "the keys of '.scopes' in scripts/release/release.config.json" >&2 + echo "(plus any documented sentinel listed in SENTINELS within this script)." >&2 + return 1 +} + +# Verify the notify-job ecosystem projection in publish-release.yml's +# `Compute release intent` step. That step's `case "$SCOPE"` maps a scope to its +# ecosystem (PyPI vs npm) for FAILURE paging using a static list of python +# scopes that do NOT end in `-py`, plus a `*-py` glob; everything else is npm. +# Because the step runs before checkout it cannot consult release.config.json at +# runtime, so this guard asserts the static projection still matches config: +# (1) the explicit list extracted from the case == the config's set of python +# scopes that do not end in `-py`, AND +# (2) the full projection (explicit-list OR `*-py` glob → python; else npm) +# maps EVERY config scope to its real ecosystem (catches e.g. a typescript +# scope that happens to end in `-py`, or a python scope missing from both). +check_notify_case() { + local file="$1" + local ecosystem scope + + # ecosystem-per-scope from config: " " lines. A scope is + # python iff ANY of its packages is python (matches the workflow's intent: + # any python package in the scope should page the PyPI lane on failure). + local config_eco + config_eco=$(jq -r ' + .scopes | to_entries[] + | .key as $s + | (if any(.value.packages[]; .ecosystem == "python") then "python" else "typescript" end) + | "\($s) \(.)" + ' "$CONFIG" | sort) + + # EXPECTED explicit list: python scopes whose name does NOT end in -py. + local expected_explicit + expected_explicit=$(printf '%s\n' "$config_eco" \ + | awk '$2 == "python" && $1 !~ /-py$/ { print $1 }' | sort -u) + + # ACTUAL explicit list from the case: the alternation arm immediately + # preceding `PY_INTENDED=true` that is NOT the `*-py` glob arm. Pull the + # `a|b|c)` pattern line and split on `|`, stripping the trailing `)`. + local actual_explicit + actual_explicit=$(awk ' + /case[[:space:]]+"\$SCOPE"[[:space:]]+in/ { in_case = 1; next } + in_case && /esac/ { in_case = 0 } + in_case && /\|.*\)[[:space:]]*$/ && !/\*-py/ { + line = $0 + sub(/[[:space:]]*\)[[:space:]]*$/, "", line) # drop trailing ")" + sub(/^[[:space:]]+/, "", line) # drop leading indent + n = split(line, arr, "|") + for (i = 1; i <= n; i++) print arr[i] + } + ' "$file" | sort -u) + + local rc_local=0 + + if [ "$actual_explicit" != "$expected_explicit" ]; then + echo "ERROR: publish-release.yml notify-job ecosystem case is out of sync with release.config.json." >&2 + echo "" >&2 + echo "--- diff (expected non-'-py' python scopes vs case explicit list) ---" >&2 + diff <(printf '%s\n' "$expected_explicit") <(printf '%s\n' "$actual_explicit") >&2 || true + echo "" >&2 + echo "Fix: update the explicit python-scope alternation in the 'Compute release" >&2 + echo "intent' step's case to exactly the config python scopes NOT ending in '-py'." >&2 + rc_local=1 + fi + + # Independently validate the full projection against config, so a scope that + # is mapped to the WRONG lane (e.g. a typescript scope ending in -py, or a + # python scope absent from BOTH the list and the -py glob) is caught even if + # the explicit list itself happens to match. + local projection_mismatch="" + while read -r scope ecosystem; do + [ -z "$scope" ] && continue + local projected="typescript" + case "$scope" in + *-py) projected="python" ;; + *) + if printf '%s\n' "$actual_explicit" | grep -qx "$scope"; then + projected="python" + fi + ;; + esac + if [ "$projected" != "$ecosystem" ]; then + projection_mismatch+=" $scope: case projects '$projected' but config says '$ecosystem'"$'\n' + fi + done <<< "$config_eco" + + if [ -n "$projection_mismatch" ]; then + echo "ERROR: publish-release.yml notify-job ecosystem case mis-projects scope(s):" >&2 + printf '%s' "$projection_mismatch" >&2 + echo "Fix: the case (explicit list + '*-py' glob) must map every release.config.json" >&2 + echo "scope to its real ecosystem." >&2 + rc_local=1 + fi + + if [ "$rc_local" -eq 0 ]; then + echo "OK: publish-release.yml notify-job ecosystem case matches release.config.json" + fi + return "$rc_local" +} + +rc=0 +check_workflow "publish-release.yml" "$PUBLISH_WF" || rc=1 +check_workflow "prepare-release.yml" "$PREPARE_WF" || rc=1 +check_workflow "canary.yml" "$CANARY_WF" || rc=1 +check_notify_case "$PUBLISH_WF" || rc=1 + +if [ "$rc" -ne 0 ]; then + exit 1 +fi + +echo "OK: all release scope dropdowns match release.config.json; notify-job ecosystem case matches too" +exit 0 diff --git a/sdks/community/dart/CHANGELOG.md b/sdks/community/dart/CHANGELOG.md index ace79c7841..d5c426dc18 100644 --- a/sdks/community/dart/CHANGELOG.md +++ b/sdks/community/dart/CHANGELOG.md @@ -5,6 +5,579 @@ All notable changes to the AG-UI Dart 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). +## [0.3.0] + +### Breaking Changes (review-fix pass) +- **`StateDeltaEvent.delta` and `ActivityDeltaEvent.patch` are now + `List>` instead of `List`.** RFC 6902 JSON + Patch operations are always objects. Using `requireListField>` surfaces non-object elements as `AGUIValidationError` at the + decoder boundary with a `field: 'delta[$i]'` / `field: 'patch[$i]'` index, + rather than leaking a downstream `TypeError` at the first `op['op']` access. + Direct consumers of `event.delta[i]` who are already casting to Map are + unaffected; consumers storing the list as `List` will need a type + annotation update. + **Migration:** change `List` type annotations on `event.delta` / + `event.patch` to `List>`. Code that already accesses + `op['op']` / `op['path']` without an explicit cast is already correct. +- **`SseParser.maxDataBytes` renamed to `maxDataCodeUnits`.** The field + already measured UTF-16 code units, not bytes — the rename corrects the + misleading name. `SseParser(maxDataBytes: ...)` call sites must be updated + to `SseParser(maxDataCodeUnits: ...)`. + +### Fixed (review-fix pass) +- **`ActivityMessage.fromJson` now silently strips `encryptedValue` / + `encrypted_value` instead of throwing `AGUIValidationError`.** `ActivityMessage` + is not a `BaseMessage` extension in the canonical protocol, so the field + does not apply. Dart was the only SDK that tore down the stream on encountering + the field; TS strips silently (zod default) and Python preserves it. The + change restores forward compatibility when a proxy emits the field. +- **`ReasoningEncryptedValueEvent.fromJson` no longer stores the cipher + payload in `BaseEvent.rawEvent`.** Previously `rawEvent: _readRawEvent(json)` + stored the full wire JSON (including `encryptedValue`) in the inherited + `rawEvent` field, undoing the cipher-data scrubbing in every error path. + `rawEvent` is now always `null` for this event type; proxies that need the + raw wire form should retain it before calling `fromJson`. +- **`RunStartedEvent.fromJson` no longer attaches the offending payload to +`AGUIValidationError.json` on rethrow.** The full outer payload (and the inner +`RunAgentInput`, which can carry `encryptedValue` via `input.messages[*]`) +are both omitted, so cipher data cannot leak through validation errors — +matching the existing scrub in `MessagesSnapshotEvent.fromJson`. +- **`MessagesSnapshotEvent.fromJson` rethrow now drops `json:` entirely.** + Forwarding `e.json` previously exposed the inner Message map on the outer + error; for Tool/Reasoning subtypes that map can carry `encryptedValue`. Drops + `json:` to match `AssistantMessage.fromJson`'s tool-call IIFE, which already + uses the cautious default. +- **`JsonDecoder.requireEitherField` now distinguishes "key present but null" + from "key absent".** Previously both cases produced the same + "Missing required field 'X' (or 'Y')" message, misleading consumers into + thinking the snake_case alias might work when the camelCase key was + explicitly null. Now: key-present-but-null produces "Required field 'X' is + present but null"; both-absent still produces the dual-key error. +- **`copyWith` sentinel sweep completed.** `ThinkingStartEvent.title`, + `ToolCallResultEvent.role`, `StateSnapshotEvent.snapshot`, and + `RunErrorEvent.code` now use the `kUnsetSentinel` pattern so callers can + clear these nullable fields via `copyWith(field: null)`. The "Known parity + gaps" list is now empty for payload fields. +- **`EventEncoder.acceptsProtobuf` and `EventDecoder.decodeBinary` now carry + explicit dartdoc warnings** that protobuf is not yet implemented end-to-end. + A client negotiating `application/vnd.ag-ui.event+proto` would receive a + misleading "Invalid UTF-8 data" error; the docs now direct consumers to use + SSE transport until protobuf support lands. +- **`groupRelatedEvents` dartdoc now documents the `ReasoningStart` / + `ReasoningEnd` asymmetry.** Phase-level reasoning events are emitted as + standalone singletons; only message-level `REASONING_MESSAGE_*` events are + grouped. Consumers that need to associate phase-level markers with message + groups must track phase boundaries in their own state. +- **`processChunk` resets `errorRoutedInChunk` after the for-loop.** The flag + was previously only set inside the loop; future throw sites after the loop + body could have silently swallowed unrelated errors. +- **`SseParser` error message corrected.** The OOM-guard error now says + "code-unit limit" (not "byte limit") to match what the cap actually measures. +- **`SseParser._processField` now uses `write('\\n')` instead of `writeln()`** + for the inter-`data:` separator. `writeln()` is equivalent on all Dart + platforms but the explicit form removes any ambiguity about whether a + platform line terminator is emitted. +- **`EventType.fromString` dartdoc strengthened** with an explicit contract + note: callers must not change the throw type from `ArgumentError`, because + `BaseEvent.fromJson` uses a narrow `on ArgumentError` catch to distinguish + unknown event types from factory bugs. + +### Fixed (review pass — protocol parity) +- **`encryptedValue` is now plumbed through every BaseMessage subtype** + (`DeveloperMessage`, `SystemMessage`, `AssistantMessage`, + `UserMessage`) on the base `Message` class. Mirrors canonical TS + `BaseMessageSchema.encryptedValue: z.string().optional()` and Python + `BaseMessage.encrypted_value: Optional[str]`. Previously the field + was only present on `ToolMessage` and `ReasoningMessage`, so a Dart + proxy decoding a `MESSAGES_SNAPSHOT` whose assistant or user message + carried `encryptedValue` from a TS or Python server silently dropped + the value at decode and could not re-emit it on the next hop. Decode + accepts both `encryptedValue` (TS-canonical) and `encrypted_value` + (Python-canonical); `toJson` emits camelCase; each subtype's + `copyWith` accepts an explicit-null clear via the sentinel pattern. + The `ToolMessage` and `ReasoningMessage` field declarations were + removed in favor of inheriting from the base — the wire shape is + unchanged. +- **`raw_event` (snake_case) is now preserved on every event factory.** + All ~30 `BaseEvent` subclasses now read `rawEvent` via a centralized + `_readRawEvent` helper that uses `containsKey` precedence: the + camelCase key wins when present (even when explicitly `null`), and + the snake_case key is consulted only when camelCase is absent. + Previously every factory read `json['rawEvent']` directly, silently + dropping Python-style `raw_event` payloads. `toJson` continues to + emit camelCase only. +- **`REASONING_ENCRYPTED_VALUE` no longer rejects empty + `entityId` / `encryptedValue` strings.** Canonical TS uses + `z.string()` and Python uses `str` — neither imposes a minimum + length. The Dart-only empty-string rejection (in both + `ReasoningEncryptedValueEvent.fromJson` and `EventDecoder.validate`) + was over-strict and would reject payloads that the canonical SDKs + accept. The strict subtype discriminator stays — unknown subtypes + still throw. +- **`SseParser._processField` now matches the WHATWG SSE spec for + empty leading `data:` lines and repeated `event:` lines.** The + `data:` case used `_dataBuffer.isNotEmpty` as a "have we written + data yet?" heuristic, which collapsed `data:\ndata: x\n\n` to `"x"` + instead of the spec-correct `"\nx"`. Now uses the `_hasDataField` + flag (mirroring the `inDataBlock` pattern in + `EventStreamAdapter.appendDataLine`). The `event:` case appended on + every `event:` line; per spec it must REPLACE. +- **`EventStreamAdapter.fromRawSseStream` now propagates downstream + cancellation, pause, and resume to the upstream raw SSE + subscription.** Previously the upstream `rawStream.listen(...)` + subscription was fire-and-forget — a consumer that cancelled the + adapted stream early left the upstream draining indefinitely + (a real resource leak on long-lived agent streams). +- **`SseParser.parseBytes` now flushes any final unterminated event + on stream close.** Routed through `parseLines` so the final + `_dispatchEvent()` flush in `parseLines` fires for byte-stream + sources too. A byte source that ended without a trailing blank line + previously lost its last buffered message. +- **`copyWith` sentinel sweep.** `RawEvent.source`, + `RunAgentInput.state`, `RunAgentInput.forwardedProps`, and + `Run.result` previously used the standard `?? this.field` pattern, + so a caller could not clear them via `copyWith(field: null)`. They + now use the existing sentinel pattern. The "Known parity gaps" + list below has been updated. +- **`JsonDecoder.optionalEitherField` now resolves on KEY presence, + not value-non-null.** A payload carrying both `camelKey: null` and + `snake_key: ` previously fell through to the snake_case + value; the documented contract on `requireEitherField` is that + camelCase wins when its key is present (even when explicitly + `null`). The implementation now matches the dartdoc. The inline + `forwardedProps` decode in `context.dart` was migrated to the same + `containsKey` rule for consistency. +- **`ToolMessage.fromJson` and `ToolResult.fromJson` now use + `requireEitherField`** instead of the older + `optionalEitherField + manual null-check + custom throw` pattern, + matching the migration already done for `RunAgentInput.fromJson` + and `Run.fromJson`. +- **`Validators.validateMessageContent` is now `String`-only.** The + pre-0.2.0 permissive `Map`/`List` branches were dead code (no caller + in the SDK passed those types) and disagreed with canonical + `BaseMessage.content: Optional[str]`. Multimodal `UserMessage.content` + remains a tracked parity gap. +- **`Validators.validateUrl` now rejects URLs containing C0 control + characters or DEL** (`\x00`–`\x1f`, `\x7f`). `Uri.parse` is + permissive with embedded `\n` / `\r` / `\t`, which can flow into + HTTP request lines as a header-injection vector. +- **`JsonDecoder.requireField` and `optionalField` transform-failure + paths now preserve `cause: e`** when wrapping an inner exception + in `AGUIValidationError`. The structured cause was previously + flattened into the message via `'$e'` interpolation only. + +### Documented +- `AGUIValidationError.json` dartdoc now carries an explicit + sensitive-data warning: the field captures the entire wire payload + including cipher fields. `toString()` does not emit it (safe by + default), but reflection-based serializers used by some logging + frameworks will leak. Prefer `.field` and `.value` on log lines + shipped to external sinks. +- `EventDecoder.validate` dartdoc now documents the dual-source + error class asymmetry: `validate()` raises + `client/errors.dart`'s `ValidationError`; `fromJson`-side eager + rejections raise `types/base.dart`'s `AGUIValidationError`. Both + surface uniformly as `DecodingError` through the public + `decode` / `decodeJson` boundary; both extend `AGUIError`. +- `BaseEvent.rawEvent` dartdoc now notes the round-trip emission + consequence — anything assigned to this field WILL be re-emitted + on the next `encode`. Set `rawEvent: null` on the in-flight event + if a proxy doesn't want the upstream payload echoed downstream. +- README adds a "Proxy notes: wire-spelling normalization" paragraph + documenting that the SDK accepts both camelCase and snake_case on + `fromJson` but always emits camelCase on `toJson`. The Error + Handling section is refreshed to use the current error-hierarchy + class names (`TransportError`, `DecodingError`, `ValidationError`, + `CancellationError`, all under `AGUIError`). +- `AgUiClient.runAgent` dartdoc `Throws:` list refreshed to match + the current error hierarchy. +- `EventStreamAdapter.groupRelatedEvents` dartdoc now carries an + explicit unbounded-state warning — open groups (where `*Start` was + received but `*End` has not arrived) are held in memory until the + matching end event or stream completion. Same caveat applies to + `accumulateTextMessages`. + +### Fixed (review pass — behavior) +- **`Tool.copyWith(parameters: null)` now correctly clears `parameters`.** + The previous `parameters ?? this.parameters` pattern silently kept the + existing value when `null` was passed; the field now uses the `_unsetTool` + sentinel pattern, consistent with `ToolCall.encryptedValue` and every + other nullable field in the SDK. This gap was omitted from the 0.2.0 + "Known parity gaps" list — it has been corrected here. +- **`EventStreamAdapter.fromRawSseStream` now subscribes to the upstream + lazily** (inside `controller.onListen`) rather than eagerly at call time. + A caller that obtained the returned stream but never subscribed would + previously leak the upstream SSE connection until the server closed it. + The cancellation, pause, and resume propagation added in the prior + review pass is preserved; subscription lifecycle callbacks now use + null-safe `?.` calls since the subscription is no longer `late final`. +- **`Message.fromJson` now preserves the wire JSON payload in + `AGUIValidationError`** when `MessageRole.fromString` fails. Previously + the error was thrown without `json:` set, making it impossible to + identify which message in a `MESSAGES_SNAPSHOT` had the unrecognized + role. The re-thrown error carries the originating `json` map so the + decoder pipeline can surface it as a `DecodingError` with full context. + +### Changed +- `TimeoutError` renamed to `AGUITimeoutError` to avoid shadowing the + built-in `dart:async.TimeoutError` (raised by `Future.timeout(...)` / + `Stream.timeout(...)`). The bare name is preserved as a deprecated + typedef alias for backward compat and will be removed in 1.0.0. + Internal call sites in `AgUiClient` throw the new name directly. The + README "Errors" recipe and "Migrating from 0.1.0" section call out + the rename so consumers using both `package:ag_ui/ag_ui.dart` and + `dart:async` can avoid the symbol collision. +- Empty `delta` is now accepted on `TEXT_MESSAGE_CONTENT`, + `TOOL_CALL_ARGS`, and `REASONING_MESSAGE_CONTENT`, and empty + `content` is accepted on `TOOL_CALL_RESULT`, to match the canonical + TS/Python schemas (`z.string()` / `str` with no `min(1)` constraint). + Previously the Dart SDK rejected empty values at both the `fromJson` + factory and the `EventDecoder.validate` pipeline; a Python or TS + server that legitimately emitted a deliberate empty chunk (e.g. a + noop content refresh) would fail decode in Dart but pass in the + canonical SDKs. Empty cipher payloads on `REASONING_ENCRYPTED_VALUE` + (`entityId`, `encryptedValue`) continue to be rejected — the "no + graceful default for cipher payloads" contract stays. + +### Fixed +- `ToolCall` now carries the optional `encryptedValue` field for parity + with canonical TS (`ToolCallSchema.encryptedValue: z.string().optional()`) + and Python (`ToolCall.encrypted_value: Optional[str]`). Previously a + message arriving with `toolCalls: [{..., encryptedValue: "..."}]` + silently dropped the value at decode and could not re-emit it on a + proxy hop. Decode accepts both `encryptedValue` and `encrypted_value`; + `toJson` emits the camelCase key when present; `copyWith` uses the + sentinel pattern so callers can explicitly clear it via + `copyWith(encryptedValue: null)`. +- `RunAgentInput` now carries the optional `parentRunId` field for + parity with canonical TS (`RunAgentInputSchema.parentRunId: + z.string().optional()`) and Python (`RunAgentInput.parent_run_id`). + Previously a `RUN_STARTED` payload with `input.parentRunId: '...'` + decoded with the field silently dropped, even though + `RunStartedEvent.parentRunId` itself was preserved. Decode accepts + both `parentRunId` and `parent_run_id`; `toJson` emits camelCase when + present; `copyWith` uses the sentinel pattern. +- `EventStreamAdapter.fromRawSseStream` now handles CRLF (`\r\n`) line + terminators, not just LF. Previously a CRLF-emitting SSE server + produced `"\r"` lines that never matched the empty-line event-boundary + signal, so events buffered until stream close. The line splitter now + strips a trailing `\r` after splitting on `\n`. The same fix is + applied to `EventDecoder.decodeSSE`, which now uses `LineSplitter` + (handling `\n`, `\r`, and `\r\n` per the WHATWG SSE spec). +- `JsonDecoder.optionalListField` and `requireListField` now eagerly + type-check elements (raising `AGUIValidationError(field: '$field[$i]')` + on the first wrong-typed element) instead of returning a lazy + `cast()` view that surfaced as a raw `TypeError` at access time and + was flattened to `field: 'json'` by the decoder catch-all. +- `AssistantMessage.fromJson` now uses `JsonDecoder.optionalEitherField` + on the `toolCalls` / `tool_calls` key itself, instead of a `??` chain + on the post-`.map(...).toList()` value. The previous chain only fired + on null, so an empty `toolCalls: []` short-circuited the snake_case + fallback even when `tool_calls: [...]` was populated. +- `AssistantMessage.toJson` now emits `toolCalls` whenever the in-memory + field is non-null (including empty lists), so the round-trip + `fromJson(m.toJson()) == m` is symmetric. +- Decoder pipeline now rethrows `EncoderError` / `DecodeError` / + `EncodeError` unchanged instead of re-wrapping them as a generic + "Failed to decode event" via the catch-all. +- `EventEncoder.encodeSSE` no longer strips fields whose value is `null`. + The blanket `json.removeWhere((k, v) => v == null)` was silently + dropping fields that intentionally serialize as `null` + (`ActivitySnapshotEvent.content`, `RawEvent.event`, `CustomEvent.value`, + `StateSnapshotEvent.snapshot`), breaking the encode→decode round-trip + because the matching factories require the key to be present and reject + it with `AGUIValidationError`. Each `toJson()` already uses + `if (field != null) 'field': field` for fields that opt in to omission, + so the strip pass was redundant in addition to harmful. Pinned by a + new round-trip test in `fixtures_integration_test.dart`. +- `EventStreamAdapter.fromRawSseStream` now handles WHATWG-spec lone-`\r` + line terminators in addition to `\n` and `\r\n`. The previous chunk + scanner only split on `\n`, so a producer using bare `\r` (rare in + practice but spec-valid) buffered indefinitely. The new multi-terminator + scanner defers a trailing `\r` at chunk boundaries to disambiguate from + a chunk-spanning `\r\n` and consumes it on stream close. Steady-state + emission for CRLF-encoded streams is unchanged. +- `EventStreamAdapter.fromSseStream` and `fromRawSseStream` now preserve + any `AGUIError` subtype (`AgUiError`, `AGUIValidationError`, + `EncoderError`) raised by the decoder instead of re-wrapping the + encoder-family errors as a generic `DecodingError`. Mirrors the + unified-error-surface contract that `EventDecoder.decode/decodeJson` + already honor. +- `TestHelpers.findToolCalls` (test-only helper) now uses the typed + `AssistantMessage.toolCalls` accessor. Previously it round-tripped + through `toJson` and read the snake_case key `tool_calls`, but + `AssistantMessage.toJson` emits camelCase `toolCalls` — the helper + silently always returned an empty list. Currently unreferenced by the + test suite, so this is a latent-bug fix. + +### Added +- `JsonDecoder.optionalEitherListField` helper combining the dual-key + resolution rule from `optionalEitherField` with the index-aware + element-type validation from `requireListField` / `optionalListField`. + `AssistantMessage.fromJson` now uses it so a malformed nested + `toolCalls[i]` raises `AGUIValidationError(field: 'toolCalls[$i]')` + instead of leaking a raw `TypeError` from the per-element cast. + +### Changed +- `Message` subclass `copyWith` methods (`DeveloperMessage`, + `SystemMessage`, `UserMessage`, `AssistantMessage`, `ToolMessage`, + `ReasoningMessage`) now use the `_unsetMessage` sentinel pattern for + nullable fields, matching the event-class discipline. Callers can + explicitly clear a nullable field via `copyWith(field: null)` — + previously `?? this.field` could not distinguish "argument omitted" + from "argument explicitly null". +- `JsonDecoder.optionalIntField` (new helper) accepts `int` or `num` + and coerces via `.toInt()`. Every event factory now reads + `timestamp` via this helper, so a TS server emitting a fractional + number (e.g. `Date.now() / 1000`) no longer fails decode with + `AGUIValidationError(field: 'timestamp')`. +- Error-hierarchy unification: `AgUiError` now extends `AGUIError`, + and `AGUIValidationError` now extends `AGUIError` instead of bare + `implements Exception`. Callers can `on AGUIError catch (e)` to + cover the entire SDK error surface (including direct-factory + validation, encoder-side failures, runtime/transport, and decoder + errors). `on AgUiError` still scopes to runtime/transport/decoding + as before. Added an "Errors" section to the README documenting the + recommended catch recipe. +- `AGUIValidationError` gained an optional `cause` parameter so the + `transform`-rethrow path in `JsonDecoder` can preserve structured + error info instead of flattening to `'Failed to transform field: $e'`. +- `SseParser` documented its per-connection state semantics (sticky + `_lastEventId`); a new `reset()` method clears all parser state for + callers that explicitly want to reuse an instance across independent + streams. +- `Validators.maxTimeout` exposed as `static const Duration` so callers + can introspect the limit (10 minutes). The cap value is unchanged; + raising it is deferred to a future release. +- `RunAgentInput.fromJson` and `Run.fromJson` migrated to + `JsonDecoder.requireEitherField` for consistency with every other + factory in the SDK. Behavior preserved; the + "Missing required field 'X' (or 'Y')" wording shifts slightly to match + the helper's standard error message. +- Long `@Deprecated` messages on the `THINKING_*` enum values and event + classes hoisted into top-level `const` strings (`event_type.dart`, + `events.dart`). Surfaces the planned-removal version in one place per + context and reduces drift risk if it ever changes. No behavior change. + +### Documentation +- `UserMessage` documented as a known parity gap with the canonical + multimodal schema (TS `Union[string, InputContent[]]`, Python + `Union[str, List[InputContent]]`); the Dart SDK currently only + supports the string variant. +- `Message.id` documented as nullable-by-type but required-by-convention + (every concrete subtype constructor declares it `required`); a future + major version may tighten the type to non-nullable for parity with + canonical `BaseMessageSchema.id: z.string()`. +- `EventDecoder.validate`'s `Thinking*` deprecated cases gained + comments explaining why they don't validate `messageId` (the + deprecated wire shape has no such field; the migration target + `REASONING_*` does). +- `EventDecoder.validate`'s `ActivityDeltaEvent` case gained a comment + noting that an empty `patch` is intentional per the canonical + TS/Python schemas (`z.array(...).min(0)` / list with no length floor). +- `BaseEvent.rawEvent` field gained a dartdoc note clarifying that the + field is unvalidated (typed `dynamic` because the protocol does not + constrain the shape). +- `ToolCallResultEvent.role`, `StateSnapshotEvent.snapshot`, and + `RunErrorEvent.code` field declarations gained a dartdoc note that + `copyWith(field: null)` does NOT clear the field (these three are the + remaining cases listed in "Known parity gaps"). Construct a new + instance directly to drop. +- `MessageRole.activity` and `MessageRole.reasoning` enum values gained + wire-spelling-pinning dartdoc, mirroring the + `ReasoningEncryptedValueSubtype.toolCall` style. +- `EventDecoder.validate`'s `ThinkingTextMessageContentEvent` case gained + a clarified rationale comment: the deprecated path keeps the pre-0.2.0 + stricter "non-empty `delta`" contract intentionally — sibling content + events (`TextMessageContentEvent`, `ToolCallArgsEvent`, + `ToolCallResultEvent`, `ReasoningMessageContentEvent`) were RELAXED + to accept empty strings in 0.2.0 for canonical TS/Python parity, but + loosening a deprecated contract retroactively serves no one. +- `ReasoningEncryptedValueEvent.fromJson` empty-string rejection comment + updated to reflect the post-0.2.0 sibling state — it is intentionally + stricter than the relaxed sibling content events because cipher + payloads have no defensible "empty" semantic. +- `BaseEvent.fromJson` and `Message.fromJson` switches gained an explicit + trailing comment stating the analyzer-enforced exhaustiveness so future + contributors don't add a `default` clause "to be safe." +- `EventStreamAdapter` adopted an internal `_appendDataLine` / + `flushDataBlock` decomposition to share the per-line and `onDone` + flush paths in `fromRawSseStream`. No behavior change. +- README "Migrating from 0.1.0" `TimeoutError` → `AGUITimeoutError` + section gained a paragraph clarifying the symmetric case: consumers + who previously meant `dart:async.TimeoutError` and were accidentally + catching SDK instances will see different runtime behavior after they + fix the import. + +### Known parity gaps +- **`requireNonEmpty` on `messageId`, `threadId`, and `runId` fields is + stricter than the canonical `z.string()` / `str` schemas** (which allow + empty strings). `EventDecoder.validate()` rejects empty ID strings; + a TS or Python server that legitimately emits an empty `messageId` would + fail decode in Dart. The strict behavior is intentional (empty IDs have + no valid semantic in the current protocol) and is tracked for review at + 1.0.0 alignment. +- **`BaseEvent.toJson` always emits `rawEvent` in camelCase** even when the + original wire payload used `raw_event` (snake_case). Proxies that must + forward the exact wire spelling should read the value before calling + `fromJson` and re-attach it to the outbound payload manually, or + preserve the original byte stream instead of round-tripping through the + Dart event model. +- **`ActivityDeltaEvent.patch` decodes as `List>`** + and rejects non-object patch elements at the decoder boundary. Canonical TS + (`z.array(z.any())`) and Python (`List[Any]`) accept any element type and + defer validation to downstream RFC-6902 consumers. Producers emitting + non-object patch elements (legal per canonical schemas, illegal per RFC 6902) + will be rejected by the Dart decoder. +- **`AssistantMessage.toJson` emits `toolCalls: []` when the in-memory list + is non-null but empty.** The canonical TS/Python SDKs omit the key when the + list is empty. This ensures round-trip symmetry + (`fromJson(m.toJson()) == m`) but diverges from canonical wire output for + messages whose `toolCalls` field was decoded from an absent key. Consumers + producing wire output for external TS/Python clients should treat an empty + list and an absent key as equivalent. + +### Breaking Changes (activity/reasoning events — 2026-04-30) +- `ToolCallResultEvent.role` is now typed `ToolCallResultRole?` instead of + `String?`. Callers constructing the event directly must use the enum + (e.g. `ToolCallResultRole.tool`) instead of a raw string. Wire decoding + is unaffected: an unknown role string on the wire is absorbed via + `ToolCallResultRole.fromString` and falls back to `ToolCallResultRole.tool` + (forward-compatible with future canonical roles). The new `role` enum + exists for parity with the Python `Literal["tool"]` / TypeScript + `z.literal("tool")` canonical role surface. + +### Added +- Activity events for event-type parity with the Python and TypeScript SDKs + ([#1018](https://github.com/ag-ui-protocol/ag-ui/issues/1018)): + - `ActivitySnapshotEvent` (`ACTIVITY_SNAPSHOT`) + - `ActivityDeltaEvent` (`ACTIVITY_DELTA`) +- Reasoning events for event-type parity: + - `ReasoningStartEvent` (`REASONING_START`) + - `ReasoningMessageStartEvent` (`REASONING_MESSAGE_START`) + - `ReasoningMessageContentEvent` (`REASONING_MESSAGE_CONTENT`) + - `ReasoningMessageEndEvent` (`REASONING_MESSAGE_END`) + - `ReasoningMessageChunkEvent` (`REASONING_MESSAGE_CHUNK`) + - `ReasoningEndEvent` (`REASONING_END`) + - `ReasoningEncryptedValueEvent` (`REASONING_ENCRYPTED_VALUE`) +- Supporting enums: `ReasoningMessageRole`, `ReasoningEncryptedValueSubtype`. +- `ActivityMessage` and `ReasoningMessage` `Message` subtypes (with + `MessageRole.activity` / `MessageRole.reasoning`) so `MESSAGES_SNAPSHOT` + payloads carrying those roles decode in Dart with the same schema as the + canonical TypeScript and Python SDKs. The `activityType` / + `activity_type` and `encryptedValue` / `encrypted_value` keys both + decode for camelCase/snake_case parity with the wider protocol. +- Field-level parity for canonical events that previously dropped wire data + on decode: `TextMessageStartEvent.name`, `TextMessageChunkEvent.name`, + `RunStartedEvent.parentRunId`, and `RunStartedEvent.input` are now decoded + and re-emitted by `toJson` so a Dart proxy preserves upstream metadata. +- All event `fromJson` factories now accept both camelCase (TypeScript + server) and snake_case (Python server) field keys, including the + pre-existing `TextMessage*` and `ToolCall*` events that were previously + camelCase-only. +- Decoder-boundary non-empty validation extended to `ToolCallArgsEvent`, + `ToolCallEndEvent`, `ToolCallResultEvent`, `RunFinishedEvent`, + `StepStartedEvent`, `StepFinishedEvent`, `StateSnapshotEvent`, `RawEvent`, + and `CustomEvent` so wire payloads with empty required identifiers or + missing required content fail at `EventDecoder.decodeJson` instead of + reaching consumer code as a null/empty value. + +### Changed +- `REASONING_MESSAGE_START.role` is now required during decoding to match + the canonical TypeScript and Python schemas. A payload missing `role` + now raises `AGUIValidationError` (wrapped as `DecodingError` through + `EventDecoder`); an unknown role string still falls back to + `ReasoningMessageRole.reasoning` for forward-compatibility. +- `TextMessageRole.fromString` now throws `ArgumentError` on unknown + values, mirroring `ReasoningMessageRole.fromString`. Wire decoding is + unaffected: `TextMessageStartEvent.fromJson` and + `TextMessageChunkEvent.fromJson` absorb the throw and fall back to + `TextMessageRole.assistant` for forward compatibility — only direct + callers of `TextMessageRole.fromString` see the new visible failure + mode. +- `ReasoningEncryptedValueEvent.fromJson` now wraps an unknown `subtype` + as `AGUIValidationError` (matching the class-level dartdoc contract), + instead of leaking the raw `ArgumentError` from + `ReasoningEncryptedValueSubtype.fromString`. The `EventDecoder` + pipeline still surfaces it as `DecodingError`. +- `ActivitySnapshotEvent.copyWith` (`content`), `RawEvent.copyWith` + (`event`), `CustomEvent.copyWith` (`value`), and + `RunFinishedEvent.copyWith` (`result`) now use an internal sentinel + parameter so callers can intentionally clear the field to `null` + (matching each factory contract that already accepted explicit-null + payloads). Other `copyWith` methods retain the standard + `?? this.field` pattern (see Known parity gaps). +- `EventDecoder.decodeJson` now wraps `AGUIValidationError` (thrown by + `fromJson` factories) explicitly so the resulting `DecodingError` + preserves the original failing field — `role`, `messageId`, + `subtype`, etc. — instead of flattening to `field: 'json'`. Pre-fix, + the wrapper relied on the `AgUiError`-based catch path, which + `AGUIValidationError` (which only `implements Exception`) bypassed. +- `EventDecoder.validate` now rejects an empty `messageId` on + `TextMessageEndEvent`, restoring symmetry with `TextMessageStartEvent` + and `TextMessageContentEvent` (and the new reasoning-end events). + +### Deprecated +- `EventType.thinkingContent` and `ThinkingContentEvent` — not part of the + canonical AG-UI protocol. Use `EventType.reasoningMessageContent` / + `ReasoningMessageContentEvent` instead. Decoding remains supported for + backward compatibility; scheduled for removal in 1.0.0. +- `EventType.thinkingTextMessageStart` / + `EventType.thinkingTextMessageContent` / + `EventType.thinkingTextMessageEnd` (and their event classes: + `ThinkingTextMessageStartEvent`, `ThinkingTextMessageContentEvent`, + `ThinkingTextMessageEndEvent`). Mirrors the canonical TypeScript SDK's + deprecation of `THINKING_TEXT_MESSAGE_*` in favor of `REASONING_*`. Use + `ReasoningMessageStartEvent` / `ReasoningMessageContentEvent` / + `ReasoningMessageEndEvent` instead. Decoding remains supported for + backward compatibility; scheduled for removal in 1.0.0. + +### Known parity gaps (follow-up) +- `copyWith` sentinel sweep is now complete for all nullable payload fields. + The sentinel pattern (`kUnsetSentinel` / `identical` check) is in place for + `ActivitySnapshotEvent.content`, `RawEvent.event`, `RawEvent.source`, + `CustomEvent.value`, `RunFinishedEvent.result`, the optional fields of + `TextMessageStartEvent` / `TextMessageChunkEvent`, + `ToolCallStartEvent.parentMessageId`, the optional fields of + `ToolCallChunkEvent` and `ReasoningMessageChunkEvent`, + `RunStartedEvent.parentRunId` / `RunStartedEvent.input`, + `RunAgentInput.parentRunId` / `RunAgentInput.state` / + `RunAgentInput.forwardedProps`, `Run.result`, the message-class nullables + (`name`, `content`, `toolCalls`, `error`, `encryptedValue`), + `ThinkingStartEvent.title`, `ToolCallResultEvent.role`, + `StateSnapshotEvent.snapshot`, and `RunErrorEvent.code`. +- `RunFinishedEvent.result` is dropped from `toJson()` when null: an + inbound explicit-null `'result': null` does not survive a Dart→Dart + re-serialization round-trip. This matches the canonical TS/Python schemas + (`z.any().optional()` / `Optional[Any] = None`), so cross-SDK forwarding + is unaffected. Consumers relying on byte-for-byte round-trip fidelity + should read `rawEvent` instead of re-serializing. + +## [0.2.0] - 2026-05-28 + +### Added +- Multimodal `UserMessage` content. `UserMessage.content` now accepts either a + plain string or an ordered list of typed parts, matching the canonical + protocol (`string | InputContent[]`). +- New content types: `UserMessageContent` (`TextContent`, `MultimodalContent`), + `InputContent` (`TextInputContent`, `ImageInputContent`, `AudioInputContent`, + `VideoInputContent`, `DocumentInputContent`, legacy `BinaryInputContent`), and + `InputContentSource` (`DataSource`, `UrlSource`). +- `UserMessage.multimodal(...)` and `UserMessage.fromContent(...)` constructors. +- `Validators.validateUserMessageContent(...)`. + +### Changed +- **Breaking:** `UserMessage.content` getter is now `String?` (was non-null + `String`) and returns `null` for multimodal messages. Read + `UserMessage.messageContent` for the typed union. +- **Breaking:** `UserMessage.copyWith` now takes `messageContent` instead of + `content`. +- **Breaking:** the default `UserMessage({required id, required content})` + constructor is no longer `const` (it wraps the string into `TextContent` at + runtime). Use `UserMessage.fromContent(id:, messageContent: const TextContent(...))` + for a compile-time constant. + ## [0.1.0] - 2025-01-21 ### Added @@ -35,4 +608,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Advanced retry strategies planned for future release - Event caching and offline support planned for future release -[0.1.0]: https://github.com/ag-ui-protocol/ag-ui/releases/tag/dart-v0.1.0 \ No newline at end of file +[0.3.0]: https://github.com/ag-ui-protocol/ag-ui/releases/tag/dart-v0.3.0 +[0.2.0]: https://github.com/ag-ui-protocol/ag-ui/releases/tag/dart-v0.2.0 +[0.1.0]: https://github.com/ag-ui-protocol/ag-ui/releases/tag/dart-v0.1.0 diff --git a/sdks/community/dart/README.md b/sdks/community/dart/README.md index 63c0cae482..bd4e954a08 100644 --- a/sdks/community/dart/README.md +++ b/sdks/community/dart/README.md @@ -14,14 +14,14 @@ Or add to your `pubspec.yaml`: ```yaml dependencies: - ag_ui: ^0.1.0 + ag_ui: ^0.3.0 ``` ## Features - 🎯 **Dart-native** – Idiomatic Dart APIs with full type safety and null safety - 🔗 **HTTP connectivity** – `AgUiClient` for direct server connections with SSE streaming -- 📡 **Event streaming** – 16 core event types for real-time agent communication +- 📡 **Event streaming** – Event-type parity with the canonical Python and TypeScript SDKs (text messages, tool calls, state, activity, reasoning, lifecycle, and more) for real-time agent communication. - 🔄 **State management** – Automatic message/state tracking with JSON Patch support - 🛠️ **Tool interactions** – Full support for tool calls and generative UI - ⚡ **High performance** – Efficient event decoding with backpressure handling @@ -52,7 +52,7 @@ final input = SimpleRunAgentInput( // Stream response events await for (final event in client.runAgent('agentic_chat', input)) { if (event is TextMessageContentEvent) { - print('Assistant: ${event.text}'); + print('Assistant: ${event.delta}'); } } ``` @@ -99,9 +99,9 @@ final input = SimpleRunAgentInput( ); await for (final event in client.runAgent('agentic_chat', input)) { - switch (event.type) { + switch (event.eventType) { case EventType.textMessageContent: - final text = (event as TextMessageContentEvent).text; + final text = (event as TextMessageContentEvent).delta; print(text); // Stream tokens break; case EventType.runFinished: @@ -111,6 +111,90 @@ await for (final event in client.runAgent('agentic_chat', input)) { } ``` +### Activity & Reasoning Events + +```dart +import 'dart:io'; // for `stderr` in the example below + +await for (final event in client.runAgent('agentic_chat', input)) { + if (event is ActivitySnapshotEvent) { + // `content` is `Object?` — the Python reference server may emit a + // primitive or `null`. Guard before treating it as a structured record. + final content = event.content; + if (content is Map) { + // `event.replace == true` → discard prior content for this messageId. + // `event.replace == false` → merge/extend on top of existing content. + print( + 'Activity (${event.activityType}, replace=${event.replace}): $content', + ); + } else { + // Wire-protocol surprise: log and skip rather than crash. + stderr.writeln( + 'ActivitySnapshotEvent.content is ${content.runtimeType}, ' + 'expected Map', + ); + } + } else if (event is ActivityDeltaEvent) { + print('Activity patch (${event.activityType}): ${event.patch}'); + } else if (event is ReasoningMessageContentEvent) { + print('Reasoning: ${event.delta}'); + } else if (event is ReasoningEncryptedValueEvent) { + // Opaque cipher payload — pass through to the next agent rather than + // attempting to decode locally. + } +} +``` + +### Multimodal Input + +A `UserMessage` accepts either plain text or an ordered list of typed parts +(text, image, audio, video, document). Use `UserMessage.multimodal` for parts: + +```dart +// A base64-encoded payload for an inline data part. +const base64Pdf = 'JVBERi0xLjQKJ...'; + +final input = SimpleRunAgentInput( + messages: [ + UserMessage.multimodal( + id: 'msg_${DateTime.now().millisecondsSinceEpoch}', + parts: [ + TextInputContent('What is in this image?'), + ImageInputContent( + // UrlSource.mimeType is optional; DataSource requires it. + source: UrlSource( + value: 'https://example.com/photo.png', + mimeType: 'image/png', + ), + ), + DocumentInputContent( + source: DataSource(value: base64Pdf, mimeType: 'application/pdf'), + ), + ], + ), + ], +); +``` + +The `content` getter returns the text for text-only messages and `null` for +multimodal ones; read `messageContent` for the typed union. + +The default `UserMessage({content})` constructor is not `const` because it +wraps the string in `TextContent` at runtime. Use `UserMessage.fromContent` to +keep a compile-time constant — this is also the migration path if you +previously used `const UserMessage(content: '...')`: + +```dart +// Before (no longer const): +// UserMessage(id: 'u-1', content: 'Hello') + +// After — const-friendly: +const msg = UserMessage.fromContent( + id: 'u-1', + messageContent: TextContent('Hello'), +); +``` + ### Tool-Based Interactions ```dart @@ -152,7 +236,7 @@ Map state = {}; List messages = []; await for (final event in client.runSharedState(input)) { - switch (event.type) { + switch (event.eventType) { case EventType.stateSnapshot: state = (event as StateSnapshotEvent).snapshot; break; @@ -169,6 +253,8 @@ await for (final event in client.runSharedState(input)) { ### Error Handling +The Dart SDK errors form a single hierarchy under [`AGUIError`](https://pub.dev/documentation/ag_ui/latest/ag_ui/AGUIError-class.html). Catch that base if you want one handler for everything; catch the specific subclasses below for targeted recovery. Through [`EventDecoder`](https://pub.dev/documentation/ag_ui/latest/ag_ui/EventDecoder-class.html) the wire-decode side throws [`DecodingError`]; the client-side request/transport layer throws [`TransportError`] and [`ValidationError`]; cancellation surfaces as [`CancellationError`]. + ```dart final cancelToken = CancelToken(); @@ -180,15 +266,45 @@ try { break; } } -} on ConnectionException catch (e) { +} on TransportError catch (e) { print('Connection error: ${e.message}'); +} on DecodingError catch (e) { + print('Decode error: ${e.message}'); } on ValidationError catch (e) { print('Validation error: ${e.message}'); -} on CancelledException { +} on CancellationError { print('Request cancelled'); +} on AGUIError catch (e) { + // Catch-all for any AG-UI-originated error (covers + // AGUIValidationError thrown directly from a `Type.fromJson` call + // when the event isn't routed through the EventDecoder pipeline). + print('AG-UI error: $e'); } ``` +> **Cancellation note:** `CancelToken.cancel()` stops event delivery to your stream, but does **not** abort the underlying HTTP socket. The connection releases when the server closes it or the OS idle-timeout fires. If you need true connection abort, provide a custom `IOClient` per request. + +### Proxy notes: wire-spelling normalization + +The Dart SDK accepts both **camelCase** (TypeScript-canonical, e.g. `threadId`, +`runId`, `parentRunId`, `encryptedValue`, `rawEvent`) and **snake_case** +(Python-canonical, e.g. `thread_id`, `run_id`, `parent_run_id`, +`encrypted_value`, `raw_event`) on every `fromJson` factory, but always +emits **camelCase** on `toJson` — there is no opt-in to snake_case wire +output. + +If you use the Dart SDK as a proxy between a snake_case-emitting Python +server and a strictly snake_case-only consumer, you must convert keys +back at the boundary. The TypeScript and Python canonical SDKs both +tolerate the camelCase form on input, so this is rarely an issue in +practice — but a strict snake_case consumer is technically protocol-valid +and will see a normalized payload from a Dart middle-tier. + +Within a single `BaseEvent.rawEvent` round-trip the spelling is +preserved by the helper that reads both keys (`rawEvent` / +`raw_event`); the camelCase emit on the Dart side is the only +normalization point. + ## Complete Example ```dart @@ -222,10 +338,10 @@ void main() async { stdout.write('Assistant: '); await for (final event in client.runAgent('agentic_chat', input)) { if (event is TextMessageContentEvent) { - stdout.write(event.text); + stdout.write(event.delta); } else if (event is ToolCallStartEvent) { - print('\nCalling tool: ${event.toolName}'); - } else if (event.type == EventType.runFinished) { + print('\nCalling tool: ${event.toolCallName}'); + } else if (event.eventType == EventType.runFinished) { print('\nDone!'); break; } @@ -235,6 +351,110 @@ void main() async { } ``` +## Migrating from 0.1.0 + +0.2.0 introduces one source-breaking change for callers that construct +events directly: + +- **`ToolCallResultEvent.role` is now `ToolCallResultRole?` instead of + `String?`.** Update direct constructions: + + ```dart + // Before (0.1.0) + ToolCallResultEvent( + messageId: '...', + toolCallId: '...', + content: '...', + role: 'tool', + ); + + // After (0.2.0) + ToolCallResultEvent( + messageId: '...', + toolCallId: '...', + content: '...', + role: ToolCallResultRole.tool, + ); + ``` + + Wire decoding is unaffected: an unknown `role` string on the wire is + absorbed via `ToolCallResultRole.fromString` and falls back to + `ToolCallResultRole.tool` for forward compatibility. See + [`CHANGELOG.md`](CHANGELOG.md) "Breaking Changes" for the full + rationale. + +- **`TimeoutError` was renamed to `AGUITimeoutError`** to avoid + shadowing `dart:async.TimeoutError` (raised by `Future.timeout(...)` / + `Stream.timeout(...)`). The bare name is preserved as a deprecated + typedef alias and will be removed in 1.0.0: + + ```dart + // Before (0.1.0) + } on TimeoutError catch (e) { /* ... */ } + + // After (0.2.0) + } on AGUITimeoutError catch (e) { /* ... */ } + ``` + + If you import both `package:ag_ui/ag_ui.dart` and `dart:async`, prefer + the new name to avoid a symbol collision and to ensure raw + `dart:async.TimeoutError` instances (very common from any + `.timeout(...)` call) are not silently absorbed by an `on TimeoutError` + arm targeting the SDK type. + + Note for the inverse case: if you previously meant + `dart:async.TimeoutError` and were accidentally catching SDK instances + (because `package:ag_ui/ag_ui.dart`'s `TimeoutError` won the unqualified + name resolution), the rename surfaces the prior collision. After you + migrate to `AGUITimeoutError`, the bare `TimeoutError` arm now + unambiguously refers to `dart:async.TimeoutError` — runtime behavior + changes accordingly. + +The `THINKING_TEXT_MESSAGE_*` event types are also deprecated in 0.2.0 +in favor of the canonical `REASONING_*` events; decoding remains +supported until 1.0.0. See `CHANGELOG.md` "Deprecated" for the migration +mapping. + +## Errors + +The SDK exposes a small error hierarchy that is intentionally split by origin: + +- `AGUIError` — the SDK-wide root. Catching `on AGUIError` covers every + error the SDK can raise: runtime, transport, decoding, AND direct-factory + validation. Use this when you want a single catch-all. +- `AgUiError` — extends `AGUIError`. Covers runtime / transport / decoding: + `TransportError`, `AGUITimeoutError`, `CancellationError`, `DecodingError`, + and the client-side `ValidationError`. Catch this when you want to scope + to "the SDK encountered a runtime problem" but explicitly do NOT want to + catch direct-factory validation errors. (`TimeoutError` is preserved as + a deprecated alias for `AGUITimeoutError`; prefer the new name to avoid + shadowing `dart:async.TimeoutError`.) +- `AGUIValidationError` — extends `AGUIError` (NOT `AgUiError`). Thrown by + `*.fromJson` factory constructors at the wire-decoding boundary. When + events flow through `EventDecoder`, this is wrapped as `DecodingError`, + so consumers using the decoder pipeline never see this directly. Direct + factory callers (`TextMessageStartEvent.fromJson(...)`) do. +- `EncoderError` and its subtypes (`DecodeError`, `EncodeError`, + encoder-side `ValidationError`) extend `AGUIError`. The `EventDecoder` + pipeline rethrows these unchanged so callers can pattern-match by type. + +Recommended catch recipe in production code that uses `EventDecoder`: + +```dart +try { + for (final event in stream) { handle(event); } +} on DecodingError catch (e) { + // Wire-format problem — log e.field, e.expectedType, e.actualValue. +} on TransportError catch (e) { + // HTTP / SSE transport failure. +} on AgUiError catch (e) { + // Anything else from the runtime/transport family. +} on AGUIError catch (e) { + // Catch-all (would also catch direct-factory AGUIValidationError if you + // ever bypass the decoder). +} +``` + ## Examples See the [`example/`](example/) directory for: @@ -265,6 +485,35 @@ Contributions are welcome! Please: 4. Ensure all tests pass 5. Submit a pull request +## Cipher-data preservation + +Some AG-UI events (`ReasoningEncryptedValueEvent`, `ReasoningMessage`, `ToolMessage`) carry +opaque cipher payloads that must be forwarded verbatim between agents. This SDK implements +defense-in-depth around those payloads: + +**Success paths** — the `rawEvent` field on every `BaseEvent` is set to the verbatim +wire-format map read from the SSE stream. A proxy that needs to re-emit a +`ReasoningEncryptedValueEvent` should read `rawEvent` (or maintain its own copy of the raw +bytes) and forward it unchanged rather than calling `toJson()`, which emits only the +parsed fields. + +**Error paths** — when a factory (`fromJson`) fails to decode an event, the thrown +`AGUIValidationError` intentionally omits the raw JSON map (`json:` field) for any event +that may carry cipher data. This prevents raw cipher bytes from leaking through +reflection-based log shippers or error serializers that walk the exception cause chain. + +**`ReasoningEncryptedValueEvent` specifically** sets `rawEvent: null` unconditionally — +unlike every other factory, forwarding `_readRawEvent(json)` would store the full cipher +payload in-memory on `BaseEvent.rawEvent`, undoing the per-field cipher scrubbing above. +Proxy operators that need the verbatim wire form must maintain their own copy before +calling `fromJson`. + +**`copyWith` and `rawEvent`** — the `copyWith` methods across all event types treat +`rawEvent` as "sticky": passing `null` keeps the existing value (i.e. `rawEvent ?? this.rawEvent`). +To clear `rawEvent`, construct the event directly with `rawEvent: null`. This prevents an +accidental `copyWith()` call from silently preserving a cipher payload that the caller +intended to drop. + ## License This SDK is part of the AG-UI Protocol project. See the [main repository](https://github.com/ag-ui-protocol/ag-ui) for license information. diff --git a/sdks/community/dart/example/lib/main.dart b/sdks/community/dart/example/lib/main.dart new file mode 100644 index 0000000000..4c93efcd3a --- /dev/null +++ b/sdks/community/dart/example/lib/main.dart @@ -0,0 +1,439 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:ag_ui/ag_ui.dart'; +import 'package:args/args.dart'; +import 'package:http/http.dart' as http; + +/// Tool Based Generative UI CLI Example +/// +/// Demonstrates connecting to an AG-UI server, sending messages, +/// streaming events, and handling tool calls. +void main(List arguments) async { + final parser = ArgParser() + ..addOption( + 'url', + abbr: 'u', + defaultsTo: Platform.environment['AG_UI_BASE_URL'] ?? 'http://127.0.0.1:20203', + help: 'Base URL of the AG-UI server', + ) + ..addOption( + 'api-key', + abbr: 'k', + defaultsTo: Platform.environment['AG_UI_API_KEY'], + help: 'API key for authentication', + ) + ..addOption( + 'message', + abbr: 'm', + help: 'Message to send (if not provided, will read from stdin)', + ) + ..addFlag( + 'json', + abbr: 'j', + negatable: false, + help: 'Output structured JSON logs', + ) + ..addFlag( + 'dry-run', + abbr: 'd', + negatable: false, + help: 'Print planned requests without executing', + ) + ..addFlag( + 'auto-tool', + abbr: 'a', + negatable: false, + help: 'Automatically provide tool results (non-interactive)', + ) + ..addFlag( + 'help', + abbr: 'h', + negatable: false, + help: 'Show help message', + ); + + ArgResults args; + try { + args = parser.parse(arguments); + } catch (e) { + // ignore: avoid_print + print('Error: $e'); + // ignore: avoid_print + print(''); + _printUsage(parser); + exit(1); + } + + if (args['help'] as bool) { + _printUsage(parser); + exit(0); + } + + final cli = ToolBasedGenerativeUICLI( + baseUrl: args['url'] as String, + apiKey: args['api-key'] as String?, + jsonOutput: args['json'] as bool, + dryRun: args['dry-run'] as bool, + autoTool: args['auto-tool'] as bool, + ); + + // Get message from args or stdin + String? message = args['message'] as String?; + if (message == null) { + // ignore: avoid_print + print('Enter your message (press Enter when done):'); + message = stdin.readLineSync(); + if (message == null || message.isEmpty) { + // ignore: avoid_print + print('No message provided'); + exit(1); + } + } + + try { + await cli.run(message); + } catch (e) { + if (args['json'] as bool) { + // ignore: avoid_print + print(json.encode({'error': e.toString()})); + } else { + // ignore: avoid_print + print('Error: $e'); + } + exit(1); + } +} + +void _printUsage(ArgParser parser) { + // ignore: avoid_print + print('Tool Based Generative UI CLI Example'); + // ignore: avoid_print + print(''); + // ignore: avoid_print + print('Usage: dart run ag_ui_example [options]'); + // ignore: avoid_print + print(''); + // ignore: avoid_print + print('Options:'); + // ignore: avoid_print + print(parser.usage); + // ignore: avoid_print + print(''); + // ignore: avoid_print + print('Examples:'); + // ignore: avoid_print + print(' # Interactive mode with default server'); + // ignore: avoid_print + print(' dart run ag_ui_example'); + // ignore: avoid_print + print(''); + // ignore: avoid_print + print(' # Send a specific message'); + // ignore: avoid_print + print(' dart run ag_ui_example -m "Create a haiku about AI"'); + // ignore: avoid_print + print(''); + // ignore: avoid_print + print(' # Auto-respond to tool calls'); + // ignore: avoid_print + print(' dart run ag_ui_example -a -m "Create a haiku"'); + // ignore: avoid_print + print(''); + // ignore: avoid_print + print(' # JSON output for debugging'); + // ignore: avoid_print + print(' dart run ag_ui_example -j -m "Test message"'); +} + +/// Main CLI implementation +class ToolBasedGenerativeUICLI { + final String baseUrl; + final String? apiKey; + final bool jsonOutput; + final bool dryRun; + final bool autoTool; + + late final EventDecoder decoder; + final Set processedToolCallIds = {}; + + ToolBasedGenerativeUICLI({ + required this.baseUrl, + this.apiKey, + this.jsonOutput = false, + this.dryRun = false, + this.autoTool = false, + }) { + decoder = EventDecoder(); + } + + Future run(String message) async { + _log('info', 'Starting Tool Based Generative UI flow'); + _log('debug', 'Base URL: $baseUrl'); + + // Generate IDs + final threadId = 'thread_${DateTime.now().millisecondsSinceEpoch}'; + final runId = 'run_${DateTime.now().millisecondsSinceEpoch}'; + + // Create initial message + final userMessage = UserMessage( + id: 'msg_${DateTime.now().millisecondsSinceEpoch}', + content: message, + ); + + final input = RunAgentInput( + threadId: threadId, + runId: runId, + state: {}, + messages: [userMessage], + tools: [], + context: [], + forwardedProps: {}, + ); + + if (dryRun) { + _log('info', 'DRY RUN - Would send request:'); + _log('info', 'POST $baseUrl/tool-based-generative-ui'); + _log('info', 'Body: ${json.encode(input.toJson())}'); + return; + } + + // Start the run + _log('info', 'Starting run with thread_id: $threadId, run_id: $runId'); + _log('info', 'User message: $message'); + + try { + // Send initial request and stream events + await _streamRun(input); + } catch (e) { + _log('error', 'Failed to complete run: $e'); + rethrow; + } + } + + Future _streamRun(RunAgentInput input) async { + final url = Uri.parse('$baseUrl/tool_based_generative_ui'); + + // Prepare request + final request = http.Request('POST', url) + ..headers['Content-Type'] = 'application/json' + ..headers['Accept'] = 'text/event-stream' + ..body = json.encode(input.toJson()); + + if (apiKey != null) { + request.headers['Authorization'] = 'Bearer $apiKey'; + } + + _log('debug', 'Sending request to ${url.toString()}'); + + // Send request and get streaming response + final httpClient = http.Client(); + try { + final streamedResponse = await httpClient.send(request); + + if (streamedResponse.statusCode != 200) { + final body = await streamedResponse.stream.bytesToString(); + throw Exception('Server returned ${streamedResponse.statusCode}: $body'); + } + + // Process SSE stream + final sseClient = SseClient(); + final sseStream = sseClient.parseStream( + streamedResponse.stream, + headers: streamedResponse.headers, + ); + + final allMessages = List.from(input.messages); + final pendingToolCalls = []; + bool runCompleted = false; + + await for (final sseMessage in sseStream) { + if (sseMessage.data == null || sseMessage.data!.isEmpty) { + continue; + } + + try { + final event = decoder.decode(sseMessage.data!); + runCompleted = await _handleEvent(event, allMessages, pendingToolCalls, input); + if (runCompleted) { + break; // Exit the stream loop when run is finished + } + } catch (e) { + _log('error', 'Failed to decode event: $e'); + _log('debug', 'Raw data: ${sseMessage.data}'); + } + } + + // After run completes, process any pending tool calls that haven't been processed yet + if (runCompleted && pendingToolCalls.isNotEmpty) { + final unprocessedToolCalls = pendingToolCalls + .where((tc) => !processedToolCallIds.contains(tc.id)) + .toList(); + + if (unprocessedToolCalls.isNotEmpty) { + _log('info', 'Processing ${unprocessedToolCalls.length} pending tool calls'); + await _processToolCalls(unprocessedToolCalls, allMessages, input); + } else { + _log('info', 'All tool calls already processed, run complete'); + } + } + } finally { + httpClient.close(); + } + } + + Future _handleEvent( + BaseEvent event, + List allMessages, + List pendingToolCalls, + RunAgentInput originalInput, + ) async { + _log('event', event.eventType.toString().split('.').last); + + switch (event.eventType) { + case EventType.runStarted: + final runStarted = event as RunStartedEvent; + _log('info', 'Run started: ${runStarted.runId}'); + break; + + case EventType.messagesSnapshot: + final snapshot = event as MessagesSnapshotEvent; + allMessages.clear(); + allMessages.addAll(snapshot.messages); + + // Collect tool calls but DON'T process them yet + for (final message in snapshot.messages) { + if (message is AssistantMessage && message.toolCalls != null && message.toolCalls!.isNotEmpty) { + for (final toolCall in message.toolCalls!) { + // Check if we've already collected this tool call + if (!pendingToolCalls.any((tc) => tc.id == toolCall.id)) { + pendingToolCalls.add(toolCall); + _log('info', 'Tool call detected: ${toolCall.function.name} (will process after run completes)'); + } + } + } + } + + // Display latest assistant message + final latestAssistant = snapshot.messages + .whereType() + .lastOrNull; + if (latestAssistant != null) { + if (latestAssistant.content != null) { + _log('assistant', latestAssistant.content!); + } + } + break; + + case EventType.runFinished: + final runFinished = event as RunFinishedEvent; + _log('info', 'Run finished: ${runFinished.runId}'); + return true; // Signal that the run is complete + + default: + _log('debug', 'Unhandled event type: ${event.eventType}'); + } + return false; // Run is not complete yet + } + + Future _processToolCalls( + List toolCalls, + List allMessages, + RunAgentInput originalInput, + ) async { + if (toolCalls.isEmpty) return; + + // Process each tool call and collect results + for (final toolCall in toolCalls) { + _log('info', 'Processing tool call: ${toolCall.function.name}'); + _log('debug', 'Arguments: ${toolCall.function.arguments}'); + + String toolResult; + if (autoTool) { + // Auto-generate tool result + toolResult = _generateAutoToolResult(toolCall); + _log('info', 'Auto-generated tool result: $toolResult'); + } else { + // Prompt user for tool result + // ignore: avoid_print + print('\nTool "${toolCall.function.name}" was called with:'); + // ignore: avoid_print + print(toolCall.function.arguments); + // ignore: avoid_print + print('Enter tool result (or press Enter for default):'); + final userInput = stdin.readLineSync(); + toolResult = userInput?.isNotEmpty == true ? userInput! : 'thanks'; + } + + // Add tool result message + final toolMessage = ToolMessage( + id: 'msg_tool_${DateTime.now().millisecondsSinceEpoch}', + content: toolResult, + toolCallId: toolCall.id, + ); + allMessages.add(toolMessage); + + // Mark this tool call as processed + processedToolCallIds.add(toolCall.id); + } + + // Send a new request with all tool results + final newRunId = 'run_${DateTime.now().millisecondsSinceEpoch}'; + final updatedInput = RunAgentInput( + threadId: originalInput.threadId, + runId: newRunId, // Use a new run ID for the tool response + state: originalInput.state, + messages: allMessages, + tools: originalInput.tools, + context: originalInput.context, + forwardedProps: originalInput.forwardedProps, + ); + + if (!dryRun) { + _log('info', 'Sending tool response(s) to server with new run...'); + await _streamRun(updatedInput); + } + } + + String _generateAutoToolResult(ToolCall toolCall) { + // Generate deterministic tool results based on function name + switch (toolCall.function.name) { + case 'generate_haiku': + return 'thanks'; + case 'get_weather': + return json.encode({'temperature': 72, 'condition': 'sunny'}); + case 'calculate': + return json.encode({'result': 42}); + default: + return 'Tool executed successfully'; + } + } + + void _log(String level, String message) { + if (jsonOutput) { + // ignore: avoid_print + print(json.encode({ + 'timestamp': DateTime.now().toIso8601String(), + 'level': level, + 'message': message, + })); + } else { + final prefix = level == 'error' + ? '❌' + : level == 'info' + ? '📍' + : level == 'event' + ? '📨' + : level == 'assistant' + ? '🤖' + : level == 'debug' + ? '🔍' + : ' '; + if (level != 'debug' || Platform.environment['DEBUG'] == 'true') { + // ignore: avoid_print + print('$prefix $message'); + } + } + } +} \ No newline at end of file diff --git a/sdks/community/dart/example/pubspec.lock b/sdks/community/dart/example/pubspec.lock new file mode 100644 index 0000000000..2e68cd4d8c --- /dev/null +++ b/sdks/community/dart/example/pubspec.lock @@ -0,0 +1,404 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + url: "https://pub.dev" + source: hosted + version: "99.0.0" + ag_ui: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.2.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + url: "https://pub.dev" + source: hosted + version: "12.1.0" + args: + dependency: "direct main" + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + url: "https://pub.dev" + source: hosted + version: "1.31.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + test_core: + dependency: transitive + description: + name: test_core + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + url: "https://pub.dev" + source: hosted + version: "0.6.17" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + url: "https://pub.dev" + source: hosted + version: "15.1.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0 <4.0.0" diff --git a/sdks/community/dart/lib/ag_ui.dart b/sdks/community/dart/lib/ag_ui.dart index 0b868d3c1f..8169d56779 100644 --- a/sdks/community/dart/lib/ag_ui.dart +++ b/sdks/community/dart/lib/ag_ui.dart @@ -62,15 +62,17 @@ export 'src/client/config.dart'; export 'src/client/errors.dart'; export 'src/client/validators.dart'; -// Client codec (hide ToolResult since it's defined in types/tool.dart) -export 'src/encoder/client_codec.dart' hide ToolResult; +// Client codec — ClientToolResult is an outbound-only model used by +// Encoder.encodeToolResult; it must remain visible so callers can construct +// values to pass to that method. +export 'src/encoder/client_codec.dart'; // Core exports will be added in subsequent tasks // export 'src/agent.dart'; // export 'src/transport.dart'; /// SDK version -const String agUiVersion = '0.1.0'; +const String agUiVersion = '0.2.0'; /// Initialize the AG-UI SDK void initAgUI() { diff --git a/sdks/community/dart/lib/src/client/client.dart b/sdks/community/dart/lib/src/client/client.dart index b1d2533087..c605ada07f 100644 --- a/sdks/community/dart/lib/src/client/client.dart +++ b/sdks/community/dart/lib/src/client/client.dart @@ -1,5 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:developer' as developer; +import 'dart:math'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; @@ -63,8 +65,15 @@ class AgUiClient { /// Returns a stream of [BaseEvent] objects representing the agent's response. /// /// Throws: - /// - [ValidationError] if the input is invalid - /// - [ConnectionException] if the connection fails + /// - [ValidationError] if the input is invalid (URL, message shape, etc.) + /// - [TransportError] if the HTTP/SSE connection fails or the server + /// returns a non-success status + /// - [DecodingError] if an SSE payload cannot be decoded into a + /// [BaseEvent] + /// - [CancellationError] if the request is cancelled via [cancelToken] + /// + /// All four extend [AGUIError] — catch that base for one-shot + /// handling. Stream runAgent( String endpoint, SimpleRunAgentInput input, { @@ -73,11 +82,17 @@ class AgUiClient { // Validate inputs Validators.validateUrl(config.baseUrl, 'baseUrl'); Validators.requireNonEmpty(endpoint, 'endpoint'); - - final fullEndpoint = endpoint.startsWith('http') - ? endpoint - : '${config.baseUrl}/$endpoint'; - + + // Tighten the scheme test: `startsWith('http')` would accept httpfoo:// + // and also skips the Validators.validateUrl defense-in-depth applied to + // config.baseUrl above. Run the same check for caller-supplied full URLs. + final isAbsolute = + endpoint.startsWith('http://') || endpoint.startsWith('https://'); + if (isAbsolute) { + Validators.validateUrl(endpoint, 'endpoint'); + } + final fullEndpoint = isAbsolute ? endpoint : '${config.baseUrl}/$endpoint'; + return _runAgentInternal(fullEndpoint, input, cancelToken: cancelToken); } @@ -118,7 +133,8 @@ class AgUiClient { SimpleRunAgentInput input, { CancelToken? cancelToken, }) { - return runAgent('tool_based_generative_ui', input, cancelToken: cancelToken); + return runAgent('tool_based_generative_ui', input, + cancelToken: cancelToken); } /// Run the shared state agent. @@ -138,7 +154,8 @@ class AgUiClient { SimpleRunAgentInput input, { CancelToken? cancelToken, }) { - return runAgent('predictive_state_updates', input, cancelToken: cancelToken); + return runAgent('predictive_state_updates', input, + cancelToken: cancelToken); } /// Internal implementation for running an agent @@ -149,16 +166,30 @@ class AgUiClient { }) async* { final runId = input.runId ?? _generateRunId(); cancelToken ??= CancelToken(); - _requestTokens[runId] = cancelToken; - try { - // Validate input - _validateRunAgentInput(input); + // Validate BEFORE registering in _requestTokens so a caller-supplied + // bad runId (empty, over-length, control chars) never enters the map. + _validateRunAgentInput(input); + + // Reject a caller-supplied runId that collides with an in-flight run. + // `putIfAbsent` collapses the check-then-insert into a single map + // operation, eliminating the cross-tick race window that would exist + // between a `containsKey` check and the subsequent `[]=` assignment. + final existing = _requestTokens.putIfAbsent(runId, () => cancelToken!); + if (!identical(existing, cancelToken)) { + throw ValidationError( + 'Duplicate runId "$runId": another run with the same id is in flight', + field: 'runId', + constraint: 'unique-in-flight', + value: runId, + ); + } + try { // Send POST request with RunAgentInput final headers = _buildHeaders(); headers['Content-Type'] = 'application/json'; - headers['Accept'] = 'text/event-stream'; + headers.putIfAbsent('Accept', () => 'text/event-stream'); final uri = Uri.parse(endpoint); final request = http.Request('POST', uri) @@ -187,6 +218,7 @@ class AgUiClient { final sseClient = SseClient( idleTimeout: config.connectionTimeout, backoffStrategy: config.backoffStrategy, + maxDataCodeUnits: _streamAdapter.maxDataCodeUnits, ); _activeStreams[runId] = sseClient; @@ -200,17 +232,16 @@ class AgUiClient { yield* _transformSseStream(sseStream, runId); } on AgUiError { rethrow; + } on TimeoutException { + throw AGUITimeoutError( + 'Agent request timed out', + timeout: config.requestTimeout, + operation: endpoint, + ); } catch (e) { if (cancelToken.isCancelled) { throw CancellationError('Request was cancelled', operation: endpoint); } - if (e is TimeoutException) { - throw TimeoutError( - 'Agent request timed out', - timeout: config.requestTimeout, - operation: endpoint, - ); - } throw TransportError( 'Failed to run agent', endpoint: endpoint, @@ -222,41 +253,73 @@ class AgUiClient { } } - /// Send request with cancellation support + /// Send request with cancellation support. + /// + /// **Known limitation**: cancellation only drops the response at the + /// Dart completer level — the underlying HTTP connection is NOT aborted. + /// The `http.Client` interface does not expose per-request abort; closing + /// the shared `_httpClient` would affect all concurrent requests. In + /// practice the OS/server timeout eventually cleans up the socket. A + /// future refactor to per-request `IOClient` instances could add true + /// abort support. + /// + /// Late-arriving responses or errors from the HTTP future after + /// cancellation are silently swallowed by the `onError` handler below + /// to prevent unhandled-future-error warnings. Future _sendWithCancellation( http.Request request, CancelToken cancelToken, Duration timeout, ) async { - // Create completer for cancellation final completer = Completer(); - - // Start the request + final future = _httpClient.send(request).timeout(timeout); - - // Listen for cancellation - cancelToken.onCancel.then((_) { + + unawaited(cancelToken.onCancel.then((_) { if (!completer.isCompleted) { completer.completeError( - CancellationError('Request cancelled', operation: request.url.toString()), + CancellationError('Request cancelled', + operation: request.url.toString()), ); } - }); - - // Complete with result or error - future.then( + })); + + unawaited(future.then( (response) { if (!completer.isCompleted) { completer.complete(response); + } else { + // Late response after cancellation — caller already received + // CancellationError. Log so silent swallows are observable in + // dev tools / dart:developer listeners without surfacing to the + // stream consumer. + developer.log( + 'Late HTTP response after cancellation; discarding ' + '(status ${response.statusCode})', + name: 'ag_ui.client', + ); + // Immediately subscribe-and-cancel to signal the underlying platform + // to close the socket. Do NOT await drain() — for SSE responses the + // body stream never ends until the server disconnects, so drain() + // would hold the socket open indefinitely. + unawaited( + response.stream.listen((_) {}).cancel().catchError((_) {}), + ); } }, onError: (Object error) { if (!completer.isCompleted) { completer.completeError(error); + } else { + // Late error after cancellation — log for debuggability. + developer.log( + 'Late HTTP error after cancellation; discarded: $error', + name: 'ag_ui.client', + ); } }, - ); - + )); + return completer.future; } @@ -267,54 +330,66 @@ class AgUiClient { if (token != null && !token.isCancelled) { token.cancel(); } - + // Close any active stream await _closeStream(runId); } - /// Transform SSE messages to typed AG-UI events + /// Transform SSE messages to typed AG-UI events. + /// + /// Lifecycle note: `_runAgentInternal` owns the `runId`/`SseClient` pair + /// and calls `_closeStream` in its own `finally` block. This method does + /// NOT clean up — do not add a `finally` here to avoid a redundant second + /// `_closeStream` call. Stream _transformSseStream( Stream sseStream, String runId, ) async* { - try { - await for (final message in sseStream) { - if (message.data == null || message.data!.isEmpty) { - continue; - } + await for (final message in sseStream) { + if (message.data == null || message.data!.isEmpty) { + continue; + } + // Mirror the keep-alive filter in EventStreamAdapter.fromSseStream: + // some servers emit `data: :` as a keep-alive sentinel alongside + // spec-correct comment-only keep-alives. Passing it to json.decode + // raises FormatException and wraps it as a spurious DecodingError. + if (message.data!.trim() == ':') { + continue; + } - try { - // Parse the SSE data as JSON - final jsonData = json.decode(message.data!); - - // Use the stream adapter to convert to typed events - final events = _streamAdapter.adaptJsonToEvents(jsonData); - - for (final event in events) { - yield event; - } - } on AgUiError catch (e) { - // Re-throw AG-UI errors to the stream - yield* Stream.error(e); - } catch (e) { - // Wrap other errors - yield* Stream.error(DecodingError( - 'Failed to decode SSE message', - field: 'message.data', - expectedType: 'BaseEvent', - actualValue: message.data, - cause: e, - )); + try { + // Parse the SSE data as JSON + final jsonData = json.decode(message.data!); + + // Use the stream adapter to convert to typed events + final events = _streamAdapter.adaptJsonToEvents(jsonData); + + for (final event in events) { + yield event; } + } on AGUIError catch (e) { + // Re-throw any AG-UI error (AGUIValidationError, EncoderError, + // AgUiError, …) unchanged so field info is preserved. The former + // `on AgUiError` clause silently wrapped AGUIValidationError (which + // extends AGUIError but not AgUiError) as a generic DecodingError, + // discarding the structured field path. + yield* Stream.error(e); + } catch (e) { + // Wrap other errors + yield* Stream.error(DecodingError( + 'Failed to decode SSE message', + field: 'message.data', + expectedType: 'BaseEvent', + // Avoid forwarding the raw payload — may contain encryptedValue. + actualValue: '<${message.data?.length ?? 0} chars>', + cause: e, + )); } - } finally { - // Clean up when stream ends - await _closeStream(runId); } } /// Send an HTTP request with retries - /// + /// /// Exposed for testing HTTP retry logic @visibleForTesting Future sendRequest( @@ -338,17 +413,15 @@ class AgUiClient { } final uri = Uri.parse(endpoint); - final request = http.Request(method, uri) - ..headers.addAll(headers); + final request = http.Request(method, uri)..headers.addAll(headers); if (body != null) { request.body = json.encode(body); } - final streamedResponse = await _httpClient - .send(request) - .timeout(config.requestTimeout); - + final streamedResponse = + await _httpClient.send(request).timeout(config.requestTimeout); + final response = await http.Response.fromStream(streamedResponse); // Success or client error (don't retry) @@ -371,7 +444,7 @@ class AgUiClient { } on TimeoutException { attempts++; if (attempts > config.maxRetries) { - throw TimeoutError( + throw AGUITimeoutError( 'Request timed out after ${config.maxRetries} attempts', timeout: config.requestTimeout, operation: '$method $endpoint', @@ -380,7 +453,7 @@ class AgUiClient { nextDelay = config.backoffStrategy.nextDelay(attempts); } catch (e) { if (e is AgUiError) rethrow; - + attempts++; if (attempts > config.maxRetries) { throw TransportError( @@ -407,7 +480,7 @@ class AgUiClient { ) { // Validate status code Validators.validateStatusCode(response.statusCode, endpoint, response.body); - + try { final data = Validators.validateJson( json.decode(response.body), @@ -429,32 +502,109 @@ class AgUiClient { /// Validate RunAgentInput void _validateRunAgentInput(SimpleRunAgentInput input) { - // Validate thread ID if present + // Validate thread ID if present — use validateThreadId (100-char cap) for + // consistency with validateRunId; both flow into the same map-key spaces. if (input.threadId != null) { - Validators.requireNonEmpty(input.threadId!, 'threadId'); + Validators.validateThreadId(input.threadId!); + } + + // Validate caller-supplied runId if present — it flows into _activeStreams + // and _requestTokens as a map key, so an empty or oversized value must be + // rejected at the boundary rather than silently stored. + if (input.runId != null) { + Validators.validateRunId(input.runId!); } - - // Validate messages if present + + if (input.parentRunId != null) { + Validators.requireNonEmpty(input.parentRunId!, 'parentRunId'); + } + + // Validate messages using an exhaustive sealed switch so every concrete + // subtype is explicitly covered. A partial `is UserMessage` check implied + // validation coverage that didn't exist — this makes the boundary clear. if (input.messages != null) { + final seenMessageIds = {}; for (final message in input.messages!) { - if (message is UserMessage) { - Validators.validateMessageContent(message.content); + // `Message.id` is declared nullable (to accommodate inbound + // MESSAGES_SNAPSHOT payloads where the server may omit the field), + // but outbound messages MUST carry a non-empty id: the server uses + // it as the stable identity key for conversation history. + // `requireNonEmpty` rejects both null and empty-string. + Validators.requireNonEmpty(message.id, 'message.id'); + if (!seenMessageIds.add(message.id!)) { + throw ValidationError( + 'Duplicate message.id "${message.id}"', + field: 'message.id', + constraint: 'unique-id', + value: message.id, + ); + } + switch (message) { + case UserMessage(): + Validators.validateUserMessageContent(message.messageContent); + case AssistantMessage(:final content, :final toolCalls): + // content is String? on AssistantMessage (all other subtypes have + // non-nullable content) — guard avoids passing null to + // validateMessageContent on valid assistant messages that omit it. + if (content != null) Validators.validateMessageContent(content); + if (toolCalls != null) { + final seenToolCallIds = {}; + for (final tc in toolCalls) { + if (!seenToolCallIds.add(tc.id)) { + throw ValidationError( + 'Duplicate toolCall.id "${tc.id}" within AssistantMessage', + field: 'toolCall.id', + constraint: 'unique-within-message', + value: tc.id, + ); + } + } + } + case DeveloperMessage(:final content): + Validators.validateMessageContent(content); + case SystemMessage(:final content): + Validators.validateMessageContent(content); + case ToolMessage(:final content): + Validators.validateMessageContent(content); + case ReasoningMessage(:final content): + // content is String? on ReasoningMessage (optional reasoning text) + if (content != null) Validators.validateMessageContent(content); + case ActivityMessage(): + // ActivityMessage carries structured activityContent (Map), not + // a string content field — nothing to validate here. + break; } } } } - /// Generate a unique run ID + /// Lazily initialized secure RNG, shared across all `_generateRunId` + /// calls on this instance. `Random.secure()` seeds from the OS CSPRNG + /// on first access; creating one per call wastes that OS round-trip. + static final _secureRandom = Random.secure(); + + /// Generate a unique run ID using a timestamp + 8 cryptographically + /// random bytes. The random suffix prevents collisions for concurrent + /// calls within the same millisecond, which is important because run IDs + /// are used as map keys in `_activeStreams` / `_requestTokens` — a + /// collision would silently overwrite an in-flight stream entry. String _generateRunId() { final timestamp = DateTime.now().millisecondsSinceEpoch; - final random = DateTime.now().microsecond; - return 'run_${timestamp}_$random'; + final hex = List.generate( + 8, + (_) => _secureRandom.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + return 'run_${timestamp}_$hex'; } /// Truncate response body for error messages String _truncateBody(String body, {int maxLength = 500}) { + if (maxLength <= 0) return '...'; if (body.length <= maxLength) return body; - return '${body.substring(0, maxLength)}...'; + var end = maxLength; + final cu = body.codeUnitAt(end - 1); + if (cu >= 0xD800 && cu <= 0xDBFF) end--; // avoid splitting surrogate pair + return '${body.substring(0, end)}...'; } /// Build headers for requests @@ -478,18 +628,30 @@ class AgUiClient { token.cancel(); } _requestTokens.clear(); - + // Close all active streams final closeOps = _activeStreams.values.map((c) => c.close()); await Future.wait(closeOps); _activeStreams.clear(); - + // Close HTTP client _httpClient.close(); } } -/// Cancel token for request cancellation +/// Cancel token for request cancellation. +/// +/// **One-shot contract**: a [CancelToken] must be used with exactly ONE +/// request. Once [cancel] is called the token is permanently cancelled — +/// passing the same token to a second [AgUiClient.runAgent] call will +/// cause that call to see [isCancelled] as `true` immediately and +/// complete with a [CancellationError] before the HTTP request is sent. +/// +/// **Listener accumulation**: [_sendWithCancellation] attaches a single +/// `.then` handler to [onCancel] per request via [unawaited]. Because +/// [CancelToken] is one-shot (one request, one cancel), the handler is +/// never re-attached across multiple calls, so no listener accumulation +/// occurs as long as the one-shot contract is honored. class CancelToken { final _completer = Completer(); bool _isCancelled = false; @@ -511,6 +673,7 @@ class CancelToken { class SimpleRunAgentInput { final String? threadId; final String? runId; + final String? parentRunId; final List? messages; final List? tools; final List? context; @@ -522,6 +685,7 @@ class SimpleRunAgentInput { const SimpleRunAgentInput({ this.threadId, this.runId, + this.parentRunId, this.messages, this.tools, this.context, @@ -532,16 +696,34 @@ class SimpleRunAgentInput { }); Map toJson() { + // `state`, `messages`, `tools`, `context`, and `forwardedProps` are + // declared required (non-optional) by the canonical TS RunAgentInputSchema + // and the Python pydantic model. Always emit them — falling back to empty + // containers when null — so strict servers (pydantic BaseModel with + // required fields) do not reject the payload with 422. Optional fields + // (`threadId`, `runId`, `parentRunId`, `config`, `metadata`) are only + // emitted when set; the server treats their absence as "not provided". + assert( + state == null || state is Map, + 'SimpleRunAgentInput.state must be Map or null; ' + 'got ${state.runtimeType}', + ); + assert( + forwardedProps == null || forwardedProps is Map, + 'SimpleRunAgentInput.forwardedProps must be Map or null; ' + 'got ${forwardedProps.runtimeType}', + ); return { - if (threadId != null) 'thread_id': threadId, - if (runId != null) 'run_id': runId, - 'state': state ?? {}, - 'messages': messages?.map((m) => m.toJson()).toList() ?? [], - 'tools': tools?.map((t) => t.toJson()).toList() ?? [], - 'context': context?.map((c) => c.toJson()).toList() ?? [], - 'forwardedProps': forwardedProps ?? {}, + if (threadId != null) 'threadId': threadId, + if (runId != null) 'runId': runId, + if (parentRunId != null) 'parentRunId': parentRunId, + 'state': state ?? const {}, + 'messages': messages?.map((m) => m.toJson()).toList() ?? const >[], + 'tools': tools?.map((t) => t.toJson()).toList() ?? const >[], + 'context': context?.map((c) => c.toJson()).toList() ?? const >[], + 'forwardedProps': forwardedProps ?? const {}, if (config != null) 'config': config, if (metadata != null) 'metadata': metadata, }; } -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/client/config.dart b/sdks/community/dart/lib/src/client/config.dart index dd2b862642..63e0e5ec1b 100644 --- a/sdks/community/dart/lib/src/client/config.dart +++ b/sdks/community/dart/lib/src/client/config.dart @@ -16,22 +16,22 @@ import '../sse/backoff_strategy.dart'; class AgUiClientConfig { /// Base URL for the AG-UI server. final String baseUrl; - + /// Default headers to include with all requests final Map defaultHeaders; - + /// Request timeout duration final Duration requestTimeout; - + /// Connection timeout for SSE final Duration connectionTimeout; - + /// Backoff strategy for retries final BackoffStrategy backoffStrategy; - + /// Maximum number of retry attempts final int maxRetries; - + /// Whether to include credentials in requests final bool withCredentials; @@ -65,4 +65,4 @@ class AgUiClientConfig { withCredentials: withCredentials ?? this.withCredentials, ); } -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/client/errors.dart b/sdks/community/dart/lib/src/client/errors.dart index b3dc41d3cb..a27b0f9059 100644 --- a/sdks/community/dart/lib/src/client/errors.dart +++ b/sdks/community/dart/lib/src/client/errors.dart @@ -1,8 +1,16 @@ -/// Base class for all AG-UI errors -abstract class AgUiError implements Exception { - /// Human-readable error message - final String message; - +import '../internal/text.dart'; +import '../types/base.dart'; + +/// Base class for runtime / transport / decoding AG-UI errors. +/// +/// Extends the SDK-wide [AGUIError] root in `lib/src/types/base.dart`, +/// so a consumer that catches `on AGUIError` will also catch every +/// `AgUiError` subtype (transport, timeout, decoding, ...) along with +/// `AGUIValidationError` from the factory boundary. Catching +/// `on AgUiError` continues to scope strictly to runtime / transport / +/// decoding — direct factory-side `AGUIValidationError` is NOT caught +/// by `on AgUiError`. See README → "Errors" for the recipe. +abstract class AgUiError extends AGUIError { /// Optional error details for debugging final Map? details; @@ -10,7 +18,7 @@ abstract class AgUiError implements Exception { final Object? cause; const AgUiError( - this.message, { + super.message, { this.details, this.cause, }); @@ -72,15 +80,23 @@ class TransportError extends AgUiError { } } -/// Error when operation times out -class TimeoutError extends AgUiError { +/// Error when operation times out. +/// +/// Renamed from `TimeoutError` to avoid shadowing the built-in +/// `dart:async.TimeoutError` (raised by `Future.timeout(...)` / +/// `Stream.timeout(...)`). A consumer that imports both +/// `package:ag_ui/ag_ui.dart` and `dart:async` would otherwise hit a +/// symbol collision; the README "Errors" recipe used to inadvertently +/// mask the built-in. The old `TimeoutError` name is preserved as a +/// deprecated typedef bridge below — prefer this class. +class AGUITimeoutError extends AgUiError { /// Duration that was exceeded final Duration? timeout; /// Operation that timed out final String? operation; - const TimeoutError( + const AGUITimeoutError( super.message, { this.timeout, this.operation, @@ -91,7 +107,7 @@ class TimeoutError extends AgUiError { @override String toString() { final buffer = StringBuffer(); - buffer.write('TimeoutError: $message'); + buffer.write('AGUITimeoutError: $message'); if (operation != null) { buffer.write(' (operation: $operation)'); } @@ -102,11 +118,22 @@ class TimeoutError extends AgUiError { } } +/// Deprecated alias for [AGUITimeoutError]. +/// +/// The bare name `TimeoutError` shadows `dart:async.TimeoutError` when +/// callers import both libraries. Migrate to [AGUITimeoutError]; this +/// alias will be removed in 1.0.0. +@Deprecated( + 'Use AGUITimeoutError. The bare TimeoutError name shadows ' + 'dart:async.TimeoutError and will be removed in 1.0.0.', +) +typedef TimeoutError = AGUITimeoutError; + /// Error when operation is cancelled class CancellationError extends AgUiError { /// Operation that was cancelled final String? operation; - + /// Reason for cancellation final String? reason; @@ -165,11 +192,19 @@ class DecodingError extends AgUiError { if (actualValue != null) { buffer.write(' (actual: ${actualValue.runtimeType})'); } + if (cause != null) buffer.write('\nCaused by: $cause'); return buffer.toString(); } } -/// Error validating input or output data +/// Error validating input or output data. +/// +/// Thrown by `Validators` (e.g. `Validators.requireNonEmpty`) — not by +/// `fromJson` factories. The factory-side counterpart is +/// `AGUIValidationError` in `lib/src/types/base.dart`, which has a +/// different parent (does NOT extend `AgUiError`). When events flow +/// through the public [EventDecoder] pipeline, both are caught and +/// re-wrapped as `DecodingError`. class ValidationError extends AgUiError { /// Field that failed validation final String? field; @@ -202,10 +237,11 @@ class ValidationError extends AgUiError { if (value != null) { final valueStr = value.toString(); final excerpt = valueStr.length > 100 - ? '${valueStr.substring(0, 100)}...' + ? '${safeTruncate(valueStr, 100)}...' : valueStr; buffer.write(' (value: $excerpt)'); } + if (cause != null) buffer.write('\nCaused by: $cause'); return buffer.toString(); } } @@ -284,6 +320,10 @@ class ServerError extends AgUiError { } } +// TODO(1.0.0): Remove the following deprecated typedefs alongside the +// THINKING_TEXT_MESSAGE_* deprecation sweep. Six aliases to delete: +// AgUiHttpException, AgUiConnectionException, AgUiTimeoutException, +// AgUiValidationException, AgUiClientException, TimeoutError. // Maintain backward compatibility with existing exception types @Deprecated('Use TransportError instead') typedef AgUiHttpException = TransportError; @@ -291,11 +331,11 @@ typedef AgUiHttpException = TransportError; @Deprecated('Use TransportError instead') typedef AgUiConnectionException = TransportError; -@Deprecated('Use TimeoutError instead') -typedef AgUiTimeoutException = TimeoutError; +@Deprecated('Use AGUITimeoutError instead') +typedef AgUiTimeoutException = AGUITimeoutError; @Deprecated('Use ValidationError instead') typedef AgUiValidationException = ValidationError; @Deprecated('Use AgUiError instead') -typedef AgUiClientException = AgUiError; \ No newline at end of file +typedef AgUiClientException = AgUiError; diff --git a/sdks/community/dart/lib/src/client/validators.dart b/sdks/community/dart/lib/src/client/validators.dart index cc51ad7115..2c54dd43b4 100644 --- a/sdks/community/dart/lib/src/client/validators.dart +++ b/sdks/community/dart/lib/src/client/validators.dart @@ -1,7 +1,20 @@ +import 'dart:developer' as developer; + +import '../types/message.dart'; import 'errors.dart'; /// Validation utilities for AG-UI SDK class Validators { + // Hoisted to avoid recompiling on every validateUrl call (hot path). + // The explicit \u escapes make the matched code points visible in source: + // \x00-\x1f C0 control codes (including \t, \n, \r) + // \x7f DEL + // \u0085 NEL (U+0085, C1 Next-Line \u2014 accepted verbatim by Uri.parse) + // \u2028 Line Separator (Unicode LS) + // \u2029 Paragraph Separator (Unicode PS) + static final RegExp _kUrlControlChars = + RegExp('[\x00-\x1f\x7f\u0085\u2028\u2029]'); + /// Validates that a string is not empty static void requireNonEmpty(String? value, String fieldName) { if (value == null || value.isEmpty) { @@ -27,15 +40,51 @@ class Validators { return value; } - /// Validates a URL format + /// Validates a URL format. + /// + /// Rejects null/empty URLs, URLs with embedded control characters or DEL + /// (C0 + Unicode line-terminators), non-http/https schemes, and + /// credential-bearing URLs (`http://user:pass@host/`). + /// + /// **Defense-in-depth note.** The credentials block + /// (`uri.userInfo.isNotEmpty`) ALSO defends against percent-encoded + /// control-char injection (e.g. `http://%0a:@host/` → newline in + /// `userInfo` after `Uri.parse` decodes it). If the no-credentials rule + /// is ever relaxed, ALSO run `_kUrlControlChars` against + /// `uri.userInfo`, `uri.path`, `uri.query`, and `uri.fragment` — those + /// fields are percent-decoded at access time, so the top-of-function + /// string check on the raw URL string is not sufficient on its own. static void validateUrl(String? url, String fieldName) { requireNonEmpty(url, fieldName); - + + // Reject embedded control characters and DEL before delegating to + // `Uri.parse`. `Uri.parse('http://example.com/\nfoo')` returns a + // valid Uri with `\n` in the path, which then flows into HTTP + // request lines as a header-injection vector. The check covers: + // • C0 controls (`\x00`–`\x1f`) and DEL (`\x7f`) — including `\t`, + // `\n`, `\r`. + // • U+0085 (NEL), U+2028 (LS), U+2029 (PS) — Unicode logical-line + // terminators that Dart's `Uri.parse` accepts verbatim and a naive + // custom transport re-emitting the URL into an HTTP header line + // would interpret as a line break. + if (_kUrlControlChars.hasMatch(url!)) { + throw ValidationError( + 'URL contains control characters for "$fieldName"', + field: fieldName, + constraint: 'no-control-chars', + value: url, + ); + } + try { - final uri = Uri.parse(url!); - if (!uri.hasScheme || !uri.hasAuthority) { + final uri = Uri.parse(url); + // `uri.hasAuthority` is true for `http://` (authority = empty string, + // host = ""). Add the explicit `uri.host.isEmpty` guard so bare-scheme + // URLs like `http://` are rejected as invalid rather than passing + // through to the scheme / credentials checks. + if (!uri.hasScheme || !uri.hasAuthority || uri.host.isEmpty) { throw ValidationError( - 'Invalid URL format for "$fieldName"', + 'Invalid URL format or empty host for "$fieldName"', field: fieldName, constraint: 'valid-url', value: url, @@ -49,6 +98,36 @@ class Validators { value: url, ); } + // Reject credential-bearing URLs (`http://user:pass@host/`) to + // prevent credentials from leaking into logs, error messages, or + // HTTP Referer headers on redirects. + if (uri.userInfo.isNotEmpty) { + throw ValidationError( + 'URL must not contain user credentials for "$fieldName"', + field: fieldName, + constraint: 'no-user-credentials', + value: url, + ); + } + // Defense-in-depth: also check percent-DECODED host / path / query / + // fragment. `Uri.parse` decodes percent-escapes at access time, so a + // raw URL like `http://host/%0a/foo` passes the top-of-function string + // check but `uri.path` returns a newline — a header-injection vector + // for any consumer that reflects these fields into HTTP request lines. + // `uri.host` is included because Dart allows percent-encoded IDNA host + // labels, and the decoded host can carry control characters that a + // custom transport places into `Host:` headers. + for (final part in [uri.host, uri.path, uri.query, uri.fragment]) { + if (_kUrlControlChars.hasMatch(part)) { + throw ValidationError( + 'URL contains percent-encoded control characters in ' + 'path/query/fragment for "$fieldName"', + field: fieldName, + constraint: 'no-control-chars', + value: url, + ); + } + } } catch (e) { if (e is ValidationError) rethrow; throw ValidationError( @@ -64,7 +143,7 @@ class Validators { /// Validates an agent ID format static void validateAgentId(String? agentId) { requireNonEmpty(agentId, 'agentId'); - + // Agent IDs should be alphanumeric with optional hyphens and underscores final pattern = RegExp(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$'); if (!pattern.hasMatch(agentId!)) { @@ -75,7 +154,7 @@ class Validators { value: agentId, ); } - + if (agentId.length > 100) { throw ValidationError( 'Agent ID too long (max 100 characters)', @@ -86,37 +165,62 @@ class Validators { } } - /// Validates a run ID format + /// Validates a run ID format. + /// + /// The 100-unit cap is measured in UTF-16 code units (Dart's [String.length]), + /// not Unicode code points or user-perceived grapheme clusters. Identifiers + /// containing characters outside the Basic Multilingual Plane (e.g. emoji) + /// consume two code units per character and reach the cap sooner than + /// ASCII-only identifiers of the same visible length. static void validateRunId(String? runId) { - requireNonEmpty(runId, 'runId'); - - // Run IDs are typically UUIDs or similar identifiers - if (runId!.length > 100) { - throw ValidationError( - 'Run ID too long (max 100 characters)', - field: 'runId', - constraint: 'max-length-100', - value: runId, - ); - } + _validateBoundedId(runId, 'runId'); } - /// Validates a thread ID format + /// Validates a thread ID format. + /// + /// The 100-unit cap is measured in UTF-16 code units (Dart's [String.length]). + /// See [validateRunId] for the full rationale. static void validateThreadId(String? threadId) { - requireNonEmpty(threadId, 'threadId'); - - if (threadId!.length > 100) { + _validateBoundedId(threadId, 'threadId'); + } + + /// Maximum length (in UTF-16 code units) for [runId], [threadId], and + /// [agentId] values. See [validateRunId] for the UTF-16 rationale. + /// Consumers that derive identifiers from these values and want to enforce + /// the same cap can reference this constant rather than copying the literal. + static const int maxIdCodeUnits = 100; + + static void _validateBoundedId(String? id, String fieldName) { + requireNonEmpty(id, fieldName); + if (id!.length > maxIdCodeUnits) { throw ValidationError( - 'Thread ID too long (max 100 characters)', - field: 'threadId', + '${fieldName[0].toUpperCase()}${fieldName.substring(1)} too long ' + '(max $maxIdCodeUnits UTF-16 code units)', + field: fieldName, constraint: 'max-length-100', - value: threadId, + value: id, ); } } - /// Validates message content - static void validateMessageContent(dynamic content) { + /// Validates message content shape. + /// + /// Canonical contract: TS `BaseMessageSchema.content: z.string().optional()` + /// and Python `BaseMessage.content: Optional[str]`. The multimodal + /// `UserMessage.content: Union[str, List[InputContent]]` variant is not + /// yet supported in this Dart SDK (see CHANGELOG → "Known parity + /// gaps"). Until it is, this validator only accepts `String` — the + /// pre-0.2.0 permissive Map/List branches were dead code (no caller in + /// the SDK passes those types) and would have silently accepted a + /// malformed payload if anyone ever adopted them. + /// + /// **Defense-in-depth note.** The null rejection here is a last line of + /// defense for raw-input callers. Every protocol-correct call site in the + /// SDK already guards null before reaching this method (the canonical + /// `content` field is `Optional[str]` and is only forwarded to callers + /// that need a non-null value). If null is somehow passed, this surfaces + /// the bug early rather than producing a silent empty-string or NPE. + static void validateMessageContent(String? content) { if (content == null) { throw ValidationError( 'Message content cannot be null', @@ -125,22 +229,71 @@ class Validators { value: content, ); } - - // Content should be either a string or a structured object - if (content is! String && content is! Map && content is! List) { + } + + /// Validates user message content (text or multimodal parts). + /// + /// Multimodal content must have a non-empty list of parts, and each part must + /// satisfy its protocol invariants (re-checked here because the constructor + /// `assert`s are stripped in release builds). + static void validateUserMessageContent(UserMessageContent content) { + switch (content) { + case TextContent(): + return; + case MultimodalContent(:final parts): + if (parts.isEmpty) { + throw ValidationError( + 'User message content must have at least one part', + field: 'content', + constraint: 'non-empty', + value: parts, + ); + } + for (var i = 0; i < parts.length; i++) { + _validateInputContentPart(parts[i], i); + } + } + } + + // Release-mode defense-in-depth: BinaryInputContent.fromJson and the + // constructor asserts already enforce these rules on every normal path, but + // asserts are stripped in release builds where a caller could construct an + // invalid part directly. + static void _validateInputContentPart(InputContent part, int index) { + if (part is! BinaryInputContent) { + return; + } + if (part.mimeType.isEmpty) { throw ValidationError( - 'Message content must be a string, map, or list', - field: 'content', - constraint: 'valid-type', - value: content, + 'Binary content part at index $index requires a non-empty mimeType', + field: 'content[$index].mimeType', + constraint: 'non-empty', + value: part.mimeType, + ); + } + if (part.id == null && part.url == null && part.data == null) { + throw ValidationError( + 'Binary content part at index $index requires at least one of ' + 'id, url, or data', + field: 'content[$index]', + constraint: 'requires-payload', + value: part, ); } } + /// Maximum allowed value for any [Duration] passed through + /// [validateTimeout]. Conservative for an agent SDK where long-running + /// tool sequences and human-in-the-loop steps can sometimes legitimately + /// approach this cap; bumping is a behavior change deferred to a future + /// release. Exposed so callers can inspect the limit (e.g. to warn the + /// user before submitting a request that will be rejected). + static const Duration maxTimeout = Duration(minutes: 10); + /// Validates timeout duration static void validateTimeout(Duration? timeout) { if (timeout == null) return; - + if (timeout.isNegative) { throw ValidationError( 'Timeout cannot be negative', @@ -149,21 +302,20 @@ class Validators { value: timeout.toString(), ); } - - // Max timeout of 10 minutes - const maxTimeout = Duration(minutes: 10); + if (timeout > maxTimeout) { throw ValidationError( - 'Timeout exceeds maximum of 10 minutes', + 'Timeout exceeds maximum of ${maxTimeout.inMinutes} minutes', field: 'timeout', - constraint: 'max-10-minutes', + constraint: 'max-${maxTimeout.inMinutes}-minutes', value: timeout.toString(), ); } } /// Validates a map contains required fields - static void requireFields(Map map, List requiredFields) { + static void requireFields( + Map map, List requiredFields) { for (final field in requiredFields) { if (!map.containsKey(field)) { throw ValidationError( @@ -186,7 +338,7 @@ class Validators { actualValue: json, ); } - + if (json is! Map) { throw DecodingError( 'Expected JSON object in $context', @@ -195,16 +347,21 @@ class Validators { actualValue: json, ); } - + return json; } - /// Validates event type + /// Validates that an event type string matches UPPER_SNAKE_CASE format. + /// + /// This is a format-only check. Format conformance does not imply that the + /// SDK can dispatch the type — [EventType.fromString] and the exhaustive + /// switch in [BaseEvent.fromJson] / [EventDecoder.validate] are the actual + /// authority for recognized types. Adding a new event type requires a + /// coordinated enum addition regardless of whether this regex accepts it. static void validateEventType(String? eventType) { requireNonEmpty(eventType, 'eventType'); - - // Event types should follow the naming convention - final pattern = RegExp(r'^[A-Z][A-Z_]*$'); + + final pattern = RegExp(r'^[A-Z][A-Z0-9_]*$'); if (!pattern.hasMatch(eventType!)) { throw ValidationError( 'Invalid event type format (should be UPPER_SNAKE_CASE)', @@ -216,9 +373,10 @@ class Validators { } /// Validates HTTP status code - static void validateStatusCode(int? statusCode, String endpoint, [String? responseBody]) { + static void validateStatusCode(int? statusCode, String endpoint, + [String? responseBody]) { if (statusCode == null) return; - + if (statusCode < 200 || statusCode >= 300) { String message; if (statusCode >= 400 && statusCode < 500) { @@ -228,7 +386,7 @@ class Validators { } else { message = 'Unexpected status'; } - + throw TransportError( '$message at $endpoint', statusCode: statusCode, @@ -248,7 +406,7 @@ class Validators { actualValue: event, ); } - + if (!event.containsKey('data')) { throw DecodingError( 'SSE event missing required "data" field', @@ -259,8 +417,39 @@ class Validators { } } - /// Validates protocol compliance for event sequences - static void validateEventSequence(String currentEvent, String? previousEvent, String? state) { + /// Validates protocol compliance for event sequences. + /// + /// **Note:** This method was never wired up in the SDK client path and is + /// not called from any production code in `lib/`. The SDK does not enforce + /// sequence rules client-side. This method is retained for consumers who + /// want to validate sequences in their own code, but may be removed in + /// a future major version. + /// + /// **Coverage gap.** This method only knows the `RUN_*` and `TOOL_CALL_*` + /// event families. The newer `REASONING_*`, `ACTIVITY_*`, `RAW`, and + /// `CUSTOM` event types are silently passed through without validation. + /// Do not rely on this method for sequence validation of any modern event + /// stream that includes these types. + // TODO(1.0.0): Remove alongside the THINKING_TEXT_MESSAGE_* deprecation sweep. + @Deprecated( + 'DO NOT USE — covers only RUN_* and TOOL_CALL_* events; ' + 'REASONING_*, ACTIVITY_*, RAW, and CUSTOM are silently passed through ' + 'without validation. Will be removed in 1.0.0.', + ) + static bool _validateEventSequenceWarnedOnce = false; + + static void validateEventSequence( + String currentEvent, String? previousEvent, String? state) { + if (!_validateEventSequenceWarnedOnce) { + _validateEventSequenceWarnedOnce = true; + developer.log( + 'validateEventSequence is deprecated and covers only RUN_* and ' + 'TOOL_CALL_* event families. REASONING_*, ACTIVITY_*, RAW, and CUSTOM ' + 'are silently passed through. This method will be removed in 1.0.0.', + name: 'ag_ui.validators', + level: 900, // WARNING + ); + } // RUN_STARTED must be first or after RUN_FINISHED if (currentEvent == 'RUN_STARTED') { if (previousEvent != null && previousEvent != 'RUN_FINISHED') { @@ -272,7 +461,7 @@ class Validators { ); } } - + // RUN_FINISHED must have a preceding RUN_STARTED if (currentEvent == 'RUN_FINISHED' && state != 'running') { throw ProtocolViolationError( @@ -282,7 +471,7 @@ class Validators { expected: 'RUN_STARTED before RUN_FINISHED', ); } - + // Tool call events must be within a run if (currentEvent.startsWith('TOOL_CALL_') && state != 'running') { throw ProtocolViolationError( @@ -301,7 +490,7 @@ class Validators { T Function(Map) fromJson, ) { final json = validateJson(data, modelName); - + try { return fromJson(json); } catch (e) { @@ -329,7 +518,7 @@ class Validators { actualValue: data, ); } - + if (data is! List) { throw DecodingError( 'Expected list for $modelName', @@ -338,7 +527,7 @@ class Validators { actualValue: data, ); } - + final results = []; for (var i = 0; i < data.length; i++) { try { @@ -354,7 +543,7 @@ class Validators { ); } } - + return results; } -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/encoder/client_codec.dart b/sdks/community/dart/lib/src/encoder/client_codec.dart index 10f86b88a3..53e7b6649a 100644 --- a/sdks/community/dart/lib/src/encoder/client_codec.dart +++ b/sdks/community/dart/lib/src/encoder/client_codec.dart @@ -19,8 +19,8 @@ class Encoder { return message.toJson(); } - /// Encode ToolResult to JSON - Map encodeToolResult(ToolResult result) { + /// Encode ClientToolResult to JSON + Map encodeToolResult(ClientToolResult result) { return { 'toolCallId': result.toolCallId, 'result': result.result, @@ -35,17 +35,21 @@ class Decoder { const Decoder(); } -/// ToolResult model for submitting tool execution results -class ToolResult { +/// ToolResult model for submitting tool execution results to the server. +/// +/// Named [ClientToolResult] to distinguish it from [types/tool.dart:ToolResult], +/// which models results received FROM the server (`content: String`). This +/// class is for the outbound direction (`result: dynamic`, `metadata`). +class ClientToolResult { final String toolCallId; final dynamic result; final String? error; final Map? metadata; - const ToolResult({ + const ClientToolResult({ required this.toolCallId, required this.result, this.error, this.metadata, }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/encoder/decoder.dart b/sdks/community/dart/lib/src/encoder/decoder.dart index 19b8fd387a..615c75210a 100644 --- a/sdks/community/dart/lib/src/encoder/decoder.dart +++ b/sdks/community/dart/lib/src/encoder/decoder.dart @@ -10,6 +10,12 @@ import '../client/errors.dart'; import '../client/validators.dart'; import '../events/events.dart'; import '../types/base.dart'; +// `encoder/errors.dart` defines its own `ValidationError`, distinct from +// the `client/errors.dart` one. Hide it on import so the `on ValidationError` +// clauses below unambiguously resolve to the client-side class that +// `Validators.requireNonEmpty` actually throws — see lib/ag_ui.dart:52 +// for the parallel public-export disambiguation. +import 'errors.dart' hide ValidationError; /// Decoder for AG-UI events. /// @@ -22,45 +28,128 @@ class EventDecoder { /// Decodes an event from a string (assumed to be JSON). /// /// This method expects a JSON string without the SSE "data: " prefix. + /// + /// **Catch-chain ordering** (do not reorder — each clause depends on prior + /// clauses not having matched): + /// 1. `on FormatException` — raw JSON parse failure before any typed + /// object exists; must come before the typed catch clauses. + /// 2. `on ValidationError` — `client/errors.dart`'s `AgUiError`-extending + /// subtype; must come before `on AgUiError` to avoid the rethrow below + /// bypassing the `_wrapValidation` call. + /// 3. `on AGUIValidationError` — factory-side validation (only + /// `implements Exception`, not `AgUiError`); does not match `on AgUiError`. + /// 4. `on AgUiError` — all other SDK errors; rethrown unchanged. + /// 5. `on EncoderError` — encoder-side family extends `AGUIError` but NOT + /// `AgUiError`; without this clause it falls to the catch-all. + /// 6. catch-all — foreign exceptions wrapped as `DecodingError`. BaseEvent decode(String data) { try { - final json = jsonDecode(data) as Map; - return decodeJson(json); + final decoded = jsonDecode(data); + // Validate the top-level shape explicitly so a list/primitive + // payload (`[1,2,3]`, `"hello"`, `42`) produces a structured + // [DecodingError] instead of a `TypeError` swallowed by the + // catch-all below — which was being wrapped as a generic "Failed + // to decode event" with no hint about the actual mismatch. + if (decoded is! Map) { + throw DecodingError( + 'Expected JSON object at top level', + field: 'data', + expectedType: 'Map', + // Surface the runtime type (e.g. `List`, `String`, + // `int`) rather than the raw value so debug logs read + // "actual: List" instead of dumping the whole + // payload — much more useful when the payload is large. + actualValue: decoded.runtimeType.toString(), + ); + } + return decodeJson(decoded); } on FormatException catch (e) { throw DecodingError( 'Invalid JSON format', field: 'data', expectedType: 'JSON', - actualValue: data, + // Avoid forwarding the raw payload — may contain encryptedValue. + actualValue: '<${data.length} chars>', cause: e, ); + } on ValidationError catch (e, stack) { + // Mirror `decodeJson`'s clauses so a factory-side validation error + // raised before `decodeJson` ever runs (e.g. via a future inline + // pre-check) still surfaces as a structured `DecodingError` with + // the originating field preserved, instead of falling to the + // catch-all and getting flattened to `field: 'event'`. + // `Error.throwWithStackTrace` preserves the original stack so the + // debug trace points at the failing field, not the wrapper. + return _wrapValidation(e, e.field, {'data': data}, stack); + } on AGUIValidationError catch (e, stack) { + return _wrapValidation(e, e.field, {'data': data}, stack); } on AgUiError { rethrow; + } on EncoderError { + // Encoder-side family (`EncoderError`, `DecodeError`, `EncodeError`, + // and `encoder/errors.dart`'s `ValidationError`) extends `AGUIError` + // but NOT `AgUiError`, so without this clause it would fall through + // to the catch-all and get re-wrapped as a generic decode failure. + // Rethrow so callers can pattern-match on the original encoder type. + rethrow; } catch (e) { throw DecodingError( 'Failed to decode event', field: 'event', expectedType: 'BaseEvent', - actualValue: data, + // Avoid forwarding the raw payload — may contain encryptedValue. + actualValue: '<${data.length} chars>', cause: e, ); } } /// Decodes an event from a JSON map. + /// + /// **Catch-chain ordering**: same required sequence as [decode] — + /// `on ValidationError` → `on AGUIValidationError` → `on AgUiError` → + /// `on EncoderError` → catch-all. Do not reorder. BaseEvent decodeJson(Map json) { try { - // Validate required fields - Validators.requireNonEmpty(json['type'] as String?, 'type'); - + // `BaseEvent.fromJson` already enforces presence and string-type + // for the `type` discriminator via `JsonDecoder.requireField`, + // and `validate()` below enforces non-empty on identifier strings. + // No standalone pre-check needed — keeping one collapsed the + // `type: 123` (wrong-typed) path into a single `AGUIValidationError` + // wrapped uniformly into [DecodingError] by the handlers below. final event = BaseEvent.fromJson(json); - + // Validate the created event validate(event); - + return event; + } on ValidationError catch (e, stack) { + // Wire-boundary contract documented on `AGUIValidationError` + // (lib/src/types/base.dart): both `AGUIValidationError` (from + // `fromJson` factories) and `ValidationError` (from `validate()` + // via `Validators.requireNonEmpty`) surface to consumers as + // `DecodingError` so callers only need to catch one error type at + // the decode boundary. This `on` clause covers the + // `AgUiError`-extending sibling so it does not bypass the wrapping + // via the `on AgUiError` rethrow. + // `Error.throwWithStackTrace` preserves the original stack so the + // debug trace points at the failing field, not the wrapper. + return _wrapValidation(e, e.field, json, stack); + } on AGUIValidationError catch (e, stack) { + // Companion clause for the factory-side error. Without this branch, + // `AGUIValidationError` (which only `implements Exception`, not + // `AgUiError`) falls through to the catch-all below and the + // original failing field — `role`, `messageId`, `subtype`, etc. — + // is flattened to `field: 'json'`, breaking the public decoder + // error surface. + return _wrapValidation(e, e.field, json, stack); } on AgUiError { rethrow; + } on EncoderError { + // See the matching clause in `decode()` above — encoder-side + // errors extend `AGUIError` (not `AgUiError`), so we rethrow them + // unchanged rather than re-wrapping as a generic decode failure. + rethrow; } catch (e) { throw DecodingError( 'Failed to create event from JSON', @@ -72,14 +161,43 @@ class EventDecoder { } } - /// Decodes an SSE message. + /// Decodes a complete SSE message string. /// - /// Expects a complete SSE message with "data: " prefix and double newlines. + /// Expects a complete SSE frame (one logical message, from the first line + /// through the terminating blank line) with a `data:` prefix. Uses + /// [LineSplitter] so `\n`, `\r`, and `\r\n` terminators are all handled per + /// the WHATWG SSE spec — a trailing `\r` from a CRLF-encoded payload no + /// longer leaks into the joined `data` value. + /// + /// **Semantic divergence from `EventStreamAdapter.fromRawSseStream`:** + /// - This method receives a COMPLETE frame and throws [DecodingError] for + /// keep-alive frames (comment-only lines or `data: :`) and for frames + /// with no `data:` lines at all (see "No data found"). + /// - `fromRawSseStream` buffers streaming chunks, accumulates `data:` lines + /// across chunk boundaries, and silently discards keep-alives (it never + /// calls `decodeSSE` — it invokes `decode` directly after accumulation). + /// Use this method when you have a pre-assembled SSE frame; use + /// `fromRawSseStream` for raw streaming bytes. BaseEvent decodeSSE(String sseMessage) { - // Extract data from SSE format - final lines = sseMessage.split('\n'); + // Reject keep-alive / comment-only frames before any `data:` collection. + // A frame that is entirely `:`-prefixed comment lines (with optional + // blank lines) carries no payload and must surface as a structured + // keep-alive error rather than the misleading "No data found" path + // that the previous `dataLines.isEmpty`-first ordering produced. + final lines = const LineSplitter().convert(sseMessage); + final hasOnlyComments = lines.every( + (line) => line.isEmpty || line.startsWith(':'), + ); + if (hasOnlyComments && lines.any((line) => line.startsWith(':'))) { + throw DecodingError( + 'SSE keep-alive comment, not an event', + field: 'data', + expectedType: 'JSON event data', + actualValue: sseMessage, + ); + } + final dataLines = []; - for (final line in lines) { if (line.startsWith('data: ')) { dataLines.add(line.substring(6)); // Remove "data: " prefix @@ -87,7 +205,14 @@ class EventDecoder { dataLines.add(line.substring(5)); // Remove "data:" prefix } } - + + // A frame whose lines are ALL empty (no comment, no data prefix) falls + // here. This can happen with a bare double-newline `\n\n` that acts as an + // SSE message boundary with no payload — the WHATWG spec says to dispatch + // the event but if there's nothing to decode, "No data found" is the + // correct outcome. Treat as a non-event rather than a keep-alive because + // there is no `:` comment marker to distinguish it; callers that care + // about empty-frame detection should observe the DecodingError. if (dataLines.isEmpty) { throw DecodingError( 'No data found in SSE message', @@ -96,11 +221,30 @@ class EventDecoder { actualValue: sseMessage, ); } - - // Join all data lines (for multi-line data) + + // Join all data lines (for multi-line data) with `\n`, per spec. final data = dataLines.join('\n'); - - // Handle special SSE comment for keep-alive + + // A `data: ` line (field present but value is the empty string) contributes + // an empty string to dataLines, so `data` can be empty after the join. + // Passing "" to `decode` raises "Unexpected end of input" which surfaces as + // the misleading "Invalid JSON format" DecodingError. Surface a clearer + // error instead. + if (data.isEmpty) { + throw DecodingError( + 'SSE data field is empty', + field: 'data', + expectedType: 'non-empty JSON event data', + actualValue: sseMessage, + ); + } + + // Legacy compatibility: a single `data: :` line (with the field value + // being the bare colon character) is treated as a keep-alive + // sentinel by some servers. Surface it as a structured keep-alive + // error rather than letting `jsonDecode(':')` raise a generic + // FormatException. Spec-compliant keep-alives are top-level `:`-only + // lines, which are caught earlier in [hasOnlyComments]. if (data.trim() == ':') { throw DecodingError( 'SSE keep-alive comment, not an event', @@ -109,31 +253,52 @@ class EventDecoder { actualValue: data, ); } - + return decode(data); } /// Decodes an event from binary data. /// - /// Currently assumes the binary data is UTF-8 encoded SSE. + /// Currently assumes the binary data is UTF-8 encoded SSE/JSON. + /// Protobuf is NOT yet supported — a server emitting actual protobuf bytes + /// will raise [DecodingError] with message "Invalid UTF-8 data" rather than + /// a descriptive "protobuf not implemented" error. Negotiate + /// `acceptsProtobuf=false` (i.e. use SSE transport) until protobuf support + /// lands end-to-end in both encoder and decoder. + /// /// TODO: Add protobuf support when proto definitions are available. BaseEvent decodeBinary(Uint8List data) { try { final string = utf8.decode(data); - - // Check if it looks like SSE format - if (string.startsWith('data:')) { + + // Detect SSE format by any recognised field prefix, including keep-alive + // comment lines (`:`). Without the `:` check, a keep-alive frame decoded + // from binary bytes would fall through to `decode(string)`, which tries + // jsonDecode(':') and raises a misleading "Invalid JSON format" error + // instead of the structured `DecodingError('SSE keep-alive comment…')`. + final looksLikeSse = string.startsWith('data:') || + string.startsWith(':') || + string.startsWith('event:') || + string.startsWith('id:') || + string.startsWith('retry:'); + if (looksLikeSse) { return decodeSSE(string); } else { // Assume it's raw JSON return decode(string); } } on FormatException catch (e) { + // A FormatException here almost always means the bytes are not valid + // UTF-8, which in turn usually means the server sent actual protobuf. + // Protobuf decoding is not yet implemented end-to-end; negotiate + // text/event-stream (acceptsProtobuf: false) until it lands. throw DecodingError( - 'Invalid UTF-8 data', + 'Binary data is not valid UTF-8. If the server negotiated ' + 'application/vnd.ag-ui.event+proto, note that protobuf decoding ' + 'is not yet implemented — use SSE transport instead.', field: 'binary', - expectedType: 'UTF-8 encoded data', - actualValue: data, + expectedType: 'UTF-8 SSE/JSON', + actualValue: 'Uint8List(${data.length})', cause: e, ); } @@ -141,31 +306,221 @@ class EventDecoder { /// Validates that an event has all required fields. /// + /// Defensive re-check on top of `fromJson`: catches empty-string values + /// (which `JsonDecoder.requireField` permits), and any event + /// constructed outside `fromJson` (e.g. via a `copyWith` that violates + /// the non-empty contract). The asymmetry is intentional — `fromJson` + /// only enforces presence and type; `validate()` is the single source of + /// truth for non-empty constraints on string identifiers. + /// + /// **Error class note.** `validate()` raises [ValidationError] + /// (`lib/src/client/errors.dart`, extends `AgUiError`). The eager + /// `fromJson`-side rejections (e.g. unknown role, unknown subtype) + /// raise [AGUIValidationError] (`lib/src/types/base.dart`, extends + /// `AGUIError` directly). Through the public [decode] / [decodeJson] + /// boundary both surface uniformly as [DecodingError], so the + /// asymmetry is only visible to direct callers of [validate] vs. + /// direct callers of `fromJson`. A consumer that wants to catch both + /// without distinguishing class can `on AGUIError catch (e)` — + /// `ValidationError` and `AGUIValidationError` both extend it. + /// /// Returns true if valid, throws [ValidationError] if not. bool validate(BaseEvent event) { // Basic validation - ensure type is set Validators.validateEventType(event.type); - - // Type-specific validation + + // Type-specific validation. Listing every sealed subtype explicitly + // (no `default`) makes the analyzer flag any new event type that is + // added without a corresponding decision here. The `exhaustive_cases` + // lint in `analysis_options.yaml` enforces this at analysis time — + // without it a new sealed subtype would silently pass `validate`. + // When you add a case here, also update `BaseEvent.fromJson` in + // `lib/src/events/events.dart` so the discriminator-dispatch switch + // and this validator remain in sync. switch (event) { case TextMessageStartEvent(): Validators.requireNonEmpty(event.messageId, 'messageId'); case TextMessageContentEvent(): Validators.requireNonEmpty(event.messageId, 'messageId'); - Validators.requireNonEmpty(event.delta, 'delta'); - case ThinkingContentEvent(): - Validators.requireNonEmpty(event.delta, 'delta'); + // `delta` may be empty per canonical TS/Python schemas + // (`TextMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str`). Do not enforce non-empty here. + case TextMessageEndEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + case TextMessageChunkEvent(): + break; // All fields optional — nothing to validate + // TODO(1.0.0): Remove the following deprecated cases + their event classes: + // ThinkingTextMessageStartEvent, ThinkingTextMessageContentEvent, + // ThinkingTextMessageEndEvent, ThinkingContentEvent. + // Also remove EventType.thinkingTextMessage* / thinkingContent enum + // values, the _kThinkingTextMessage*Deprecation / _kThinkingContent* + // Deprecation constants, and the deprecated TimeoutError typedef in + // client/errors.dart. + // ignore: deprecated_member_use_from_same_package + case ThinkingTextMessageStartEvent(): + // Deprecated; no `messageId` on the wire by design — matches the + // canonical TS `THINKING_TEXT_MESSAGE_START` shape this event + // mirrors. The migration target [ReasoningMessageStartEvent] + // adds `messageId` per canonical `REASONING_MESSAGE_START`. Do + // NOT add validation here at 1.0.0 removal — that would tighten + // the deprecated contract retroactively and break consumers + // still on the old wire shape. + break; + // ignore: deprecated_member_use_from_same_package + case ThinkingTextMessageContentEvent(): + // Empty `delta` is accepted — relaxed to match the canonical + // `z.string()` / `delta: str` contract (parity with + // `TextMessageContentEvent`, `ReasoningMessageContentEvent`, etc.). + break; + // ignore: deprecated_member_use_from_same_package + case ThinkingTextMessageEndEvent(): + // Same rationale as `ThinkingTextMessageStartEvent` above: no + // `messageId` on the wire by design; the migration target + // [ReasoningMessageEndEvent] adds it. + break; case ToolCallStartEvent(): Validators.requireNonEmpty(event.toolCallId, 'toolCallId'); Validators.requireNonEmpty(event.toolCallName, 'toolCallName'); + case ToolCallArgsEvent(): + Validators.requireNonEmpty(event.toolCallId, 'toolCallId'); + // `delta` may be empty per canonical TS/Python schemas + // (`ToolCallArgsEventSchema.delta: z.string()` / pydantic + // `delta: str`). Do not enforce non-empty here. + case ToolCallEndEvent(): + Validators.requireNonEmpty(event.toolCallId, 'toolCallId'); + case ToolCallChunkEvent(): + break; // All fields optional — nothing to validate + case ToolCallResultEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + Validators.requireNonEmpty(event.toolCallId, 'toolCallId'); + // `content` may be empty per canonical TS/Python schemas + // (`ToolCallResultEventSchema.content: z.string()` / pydantic + // `content: str`). Do not enforce non-empty here. + case ThinkingStartEvent(): + break; + // ignore: deprecated_member_use_from_same_package + case ThinkingContentEvent(): + // Empty `delta` is accepted — relaxed to match canonical contract. + break; + case ThinkingEndEvent(): + break; + case StateSnapshotEvent(): + // `snapshot` is an opaque JSON value — presence is enforced in + // `StateSnapshotEvent.fromJson`; there is no non-empty constraint + // we can express on `dynamic` content here. + break; + case StateDeltaEvent(): + // `delta` is allowed to be empty per canonical TS/Python — mirrors + // `ActivityDeltaEvent` which has the same schema floor of 0. Do not + // add a non-empty check here without a corresponding schema change. + break; + case MessagesSnapshotEvent(): + break; + case ActivitySnapshotEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + Validators.requireNonEmpty(event.activityType, 'activityType'); + case ActivityDeltaEvent(): + // `patch` is allowed to be empty per canonical TS/Python + // (`z.array(JsonPatchOperationSchema).min(0)` / list with no + // length floor). This matches `StateDeltaEvent` which similarly + // does not enforce non-empty on its patch list. Do not add + // `requireNonEmpty(...patch...)` here without a corresponding + // schema change in the canonical SDKs. + Validators.requireNonEmpty(event.messageId, 'messageId'); + Validators.requireNonEmpty(event.activityType, 'activityType'); + case RawEvent(): + // `event` payload presence is enforced in `RawEvent.fromJson`. + break; + case CustomEvent(): + Validators.requireNonEmpty(event.name, 'name'); case RunStartedEvent(): Validators.validateThreadId(event.threadId); Validators.validateRunId(event.runId); - default: - // No specific validation for other event types + case RunFinishedEvent(): + Validators.validateThreadId(event.threadId); + Validators.validateRunId(event.runId); + case RunErrorEvent(): + Validators.requireNonEmpty(event.message, 'message'); + case StepStartedEvent(): + Validators.requireNonEmpty(event.stepName, 'stepName'); + case StepFinishedEvent(): + Validators.requireNonEmpty(event.stepName, 'stepName'); + case ReasoningStartEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + case ReasoningMessageStartEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + case ReasoningMessageContentEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + // `delta` may be empty per canonical TS/Python schemas + // (`ReasoningMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str`). Do not enforce non-empty here. + case ReasoningMessageEndEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + case ReasoningMessageChunkEvent(): + break; // All fields optional — nothing to validate + case ReasoningEndEvent(): + Validators.requireNonEmpty(event.messageId, 'messageId'); + case ReasoningEncryptedValueEvent(): + // `subtype` is enum-typed and constructor-required, so it cannot + // be null/invalid here. If the enum ever gains an `unknown` + // member (currently `fromString` throws — see the dartdoc on + // `ReasoningEncryptedValueSubtype.fromString`), this case is the + // place to reject it. + // TODO: revisit if `ReasoningEncryptedValueSubtype` gains an + // `unknown` member — at that point the comment above goes + // stale and this case must explicitly reject the unknown + // subtype to preserve the "no graceful default for cipher + // payloads" contract. + // + // `entityId` and `encryptedValue` are accepted as plain strings + // (including empty) to match canonical TS `z.string()` and + // Python `str` schemas — neither imposes a minimum length. + // + // **Operational risk of empty `entityId`.** An empty `entityId` + // will pass validation here but the referenced entity cannot be + // located by consumers. This matches the canonical SDK behavior + // (no min-length constraint). If your deployment routes these + // events to a decryption service that fails on empty entityId, + // add a length check at the consumer or via a proxy validator. break; } - + return true; } -} \ No newline at end of file + + /// Wraps a factory-side or validate-side validation failure into the + /// public [DecodingError] envelope, preserving the original failing + /// field name so consumers can react to specific field violations + /// instead of getting a flattened `field: 'json'` everywhere. + /// + /// Returns [Never] so the analyzer verifies that all call sites are + /// unconditionally throwing — callers pass `stack` instead of wrapping + /// in `Error.throwWithStackTrace(...)` themselves, which keeps the + /// original stack trace intact. + Never _wrapValidation( + Object cause, + String? field, + Map json, + StackTrace stack, + ) { + // Do not forward the raw json map when the inner factory already scrubbed + // it (indicated by cause.json == null on an AGUIValidationError). Doing so + // would re-expose a cipher payload that the factory deliberately omitted. + final innerScrubbed = cause is AGUIValidationError && cause.json == null; + Error.throwWithStackTrace( + DecodingError( + 'Failed to create event from JSON', + field: field ?? 'json', + // When the inner factory scrubbed its json map (cipher-bearing event), + // mark expectedType so operators can tell that the absent actualValue + // is intentional rather than a logging bug. + expectedType: innerScrubbed + ? 'BaseEvent (cipher-bearing — actualValue suppressed)' + : 'BaseEvent', + actualValue: innerScrubbed ? null : json, + cause: cause, + ), + stack, + ); + } +} diff --git a/sdks/community/dart/lib/src/encoder/encoder.dart b/sdks/community/dart/lib/src/encoder/encoder.dart index cc2b5b054b..544dbb828c 100644 --- a/sdks/community/dart/lib/src/encoder/encoder.dart +++ b/sdks/community/dart/lib/src/encoder/encoder.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'dart:typed_data'; import '../events/events.dart'; +import 'errors.dart'; /// The AG-UI protobuf media type constant. const String aguiMediaType = 'application/vnd.ag-ui.event+proto'; @@ -17,6 +18,13 @@ const String aguiMediaType = 'application/vnd.ag-ui.event+proto'; /// and binary format (protobuf or SSE as bytes). class EventEncoder { /// Whether this encoder accepts protobuf format. + /// + /// **Important:** Setting this to `true` (via an `Accept: + /// application/vnd.ag-ui.event+proto` header) makes [encodeBinary] fall + /// back to SSE-as-bytes, not real protobuf. [EventDecoder.decodeBinary] + /// similarly has NO protobuf support — a server emitting real protobuf bytes + /// will fail with a misleading "Invalid UTF-8 data" error. Do not negotiate + /// `acceptsProtobuf=true` until protobuf support is implemented end-to-end. final bool acceptsProtobuf; /// Creates an encoder with optional format preferences. @@ -48,9 +56,28 @@ class EventEncoder { /// ``` String encodeSSE(BaseEvent event) { final json = event.toJson(); - // Remove null values for cleaner output - json.removeWhere((key, value) => value == null); - final jsonString = jsonEncode(json); + // Do NOT strip null values: each `toJson()` already uses + // `if (field != null) 'field': field` for fields that should be omitted + // when null. Stripping here would silently drop fields that intentionally + // serialize as `null` (e.g. `ActivitySnapshotEvent.content`, + // `RawEvent.event`, `CustomEvent.value`, `StateSnapshotEvent.snapshot`) + // — their factories require the key to be present and reject + // missing-key with `AGUIValidationError`, so a null-strip pass would + // break the encode→decode round-trip. See + // `fixtures_integration_test.dart` "round-trip preserves explicit-null + // payload" for the regression guard. + final String jsonString; + try { + jsonString = jsonEncode(json); + } on JsonUnsupportedObjectError catch (e) { + throw EncodeError( + message: 'Event payload is not JSON-encodable: ' + '${event.runtimeType} contains a non-serializable value ' + '(${e.unsupportedObject.runtimeType})', + source: event, + cause: e, + ); + } return 'data: $jsonString\n\n'; } @@ -75,9 +102,32 @@ class EventEncoder { } /// Checks if protobuf format is accepted based on Accept header. + /// + /// Evaluates each comma-separated token independently to avoid false + /// positives from substring matches and to honor `q=0` (explicit deny). + /// Examples: + /// `"application/vnd.ag-ui.event+proto"` → true + /// `"application/vnd.ag-ui.event+proto; q=0.8"` → true + /// `"application/vnd.ag-ui.event+proto; q=0"` → false + /// `"*/*; q=0.5, application/vnd.ag-ui.event+proto; q=0"` → false static bool _isProtobufAccepted(String acceptHeader) { - // Simple check for protobuf media type - // In production, this should use proper media type negotiation - return acceptHeader.contains(aguiMediaType); + for (final token in acceptHeader.split(',')) { + final parts = token.trim().split(';'); + final mediaType = parts.first.trim().toLowerCase(); + if (mediaType != aguiMediaType.toLowerCase()) continue; + // Found the media type — accept unless a q=0 parameter denies it. + var denied = false; + for (var i = 1; i < parts.length; i++) { + final kv = parts[i].trim().split('='); + if (kv.length == 2 && + kv[0].trim().toLowerCase() == 'q' && + double.tryParse(kv[1].trim()) == 0.0) { + denied = true; + break; + } + } + if (!denied) return true; + } + return false; } -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/encoder/errors.dart b/sdks/community/dart/lib/src/encoder/errors.dart index ecbd5abb88..52b0a78098 100644 --- a/sdks/community/dart/lib/src/encoder/errors.dart +++ b/sdks/community/dart/lib/src/encoder/errors.dart @@ -7,7 +7,7 @@ import '../types/base.dart'; class EncoderError extends AGUIError { /// The source data that caused the error. final dynamic source; - + /// The underlying cause of the error, if any. final Object? cause; @@ -81,7 +81,7 @@ class EncodeError extends EncoderError { class ValidationError extends EncoderError { /// The field that failed validation. final String? field; - + /// The value that failed validation. final dynamic value; @@ -106,4 +106,4 @@ class ValidationError extends EncoderError { } return buffer.toString(); } -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/encoder/stream_adapter.dart b/sdks/community/dart/lib/src/encoder/stream_adapter.dart index f1621cb2cf..eafa6aa53f 100644 --- a/sdks/community/dart/lib/src/encoder/stream_adapter.dart +++ b/sdks/community/dart/lib/src/encoder/stream_adapter.dart @@ -4,9 +4,10 @@ library; import 'dart:async'; import '../client/errors.dart'; -import '../client/validators.dart'; import '../events/events.dart'; +import '../internal/sse_constants.dart'; import '../sse/sse_message.dart'; +import '../types/base.dart'; import 'decoder.dart'; /// Adapter for converting streams of SSE messages to typed AG-UI events. @@ -18,20 +19,37 @@ import 'decoder.dart'; /// - Handle errors gracefully class EventStreamAdapter { final EventDecoder _decoder; - - /// Buffer for accumulating partial SSE data. - final StringBuffer _buffer = StringBuffer(); - - /// Buffer for accumulating data field values (without "data: " prefix). - final StringBuffer _dataBuffer = StringBuffer(); - - /// Whether we're currently in a multi-line data block. - bool _inDataBlock = false; + + /// Maximum number of UTF-16 code units accepted per SSE data block and + /// per raw-input buffer in [fromRawSseStream]. Matches [SseParser]'s + /// default of 8 MiB (8 × 1 048 576 code units) so both SSE paths enforce + /// the same bound. A misbehaving server that streams `data:` without a + /// blank-line terminator can otherwise grow [fromRawSseStream]'s internal + /// buffers without bound. + /// + /// **UTF-16 vs. bytes.** Dart's [String.length] counts UTF-16 code units, + /// not bytes. Each code unit is 2 bytes on most platforms, so the default + /// 8 MiB value permits up to ~16 MiB of actual memory. When sizing this + /// cap against a byte-counted upstream limit (e.g. an nginx + /// `proxy_buffer_size`), divide that limit by 2–4 depending on the + /// expected character density of the SSE payload. + final int maxDataCodeUnits; /// Creates a new stream adapter with an optional custom decoder. - EventStreamAdapter({EventDecoder? decoder}) - : _decoder = decoder ?? const EventDecoder(); - + /// + /// [maxDataCodeUnits] caps the in-memory SSE data buffer in + /// [fromRawSseStream]. Defaults to 8 MiB (code units), matching [SseParser]. + /// + /// SSE line-buffering state for [fromRawSseStream] lives in locals scoped + /// to each invocation, not on the adapter instance. This means the same + /// adapter can safely process multiple streams sequentially or + /// concurrently — abnormal termination of one stream cannot leak partial + /// `data:` payloads or a stale `inDataBlock` flag into the next. + EventStreamAdapter({ + EventDecoder? decoder, + this.maxDataCodeUnits = kSseDefaultMaxDataCodeUnits, + }) : _decoder = decoder ?? const EventDecoder(); + /// Adapts JSON data to AG-UI events. /// /// Returns a list of events parsed from the JSON data. @@ -46,18 +64,42 @@ class EventStreamAdapter { // Array of events final events = []; for (var i = 0; i < jsonData.length; i++) { - if (jsonData[i] is Map) { - try { - events.add(_decoder.decodeJson(jsonData[i] as Map)); - } catch (e) { - throw DecodingError( - 'Failed to decode event at index $i', - field: 'jsonData[$i]', - expectedType: 'BaseEvent', - actualValue: jsonData[i], - cause: e, - ); + final element = jsonData[i]; + if (element is! Map) { + // Reject non-object elements explicitly so a list with a + // primitive or non-record entry produces a structured error + // naming the bad index, rather than silently skipping or + // throwing a `TypeError` swallowed by the catch-all below. + throw DecodingError( + 'Expected JSON object at index $i', + field: 'events[$i]', + expectedType: 'Map', + actualValue: element, + ); + } + try { + events.add(_decoder.decodeJson(element)); + } catch (e) { + // Compose the inner field path so consumers driving on `.field` + // see 'jsonData[i].role' instead of the coarser 'jsonData[i]'. + final String? innerField; + if (e is DecodingError) { + innerField = e.field; + } else if (e is AGUIValidationError) { + innerField = e.field; + } else { + innerField = null; } + final composedField = innerField != null + ? 'events[$i].$innerField' + : 'events[$i]'; + throw DecodingError( + 'Failed to decode event at index $i', + field: composedField, + expectedType: 'BaseEvent', + actualValue: element, + cause: e, + ); } } return events; @@ -89,11 +131,25 @@ class EventStreamAdapter { /// - Parsing JSON to typed event objects /// - Filtering out non-data messages (comments, etc.) /// - Error handling with optional recovery + /// + /// When [skipInvalidEvents] is `true`, decode failures (malformed JSON, + /// unknown event types, validation errors) are routed to [onError] and + /// the stream continues. This includes silent loss of any + /// `REASONING_ENCRYPTED_VALUE` event whose `subtype` is unknown to this + /// SDK version: there is no sensible default for an encrypted-payload + /// subtype, so the event becomes a `DecodingError` and is dropped under + /// the flag. Most other enums (`ReasoningMessageRole`, `TextMessageRole`) + /// absorb unknown values at the event-decoding boundary instead. + /// Consumers that need to react to such drops should observe [onError]. Stream fromSseStream( Stream sseStream, { bool skipInvalidEvents = false, void Function(Object error, StackTrace stackTrace)? onError, }) { + // `StreamTransformer.fromHandlers` propagates lifecycle (pause, resume, + // cancel) to the upstream stream automatically per the Dart SDK contract — + // unlike `fromRawSseStream` which uses a manual controller.onListen / + // onCancel / onPause / onResume pattern to achieve the same guarantee. return sseStream.transform( StreamTransformer.fromHandlers( handleData: (message, sink) { @@ -101,28 +157,35 @@ class EventStreamAdapter { // Only process data messages final data = message.data; if (data != null && data.isNotEmpty) { - // Skip keep-alive messages + // Keep-alive sentinels (data field whose trimmed value is `:`). + // Silently discard regardless of `skipInvalidEvents` — a + // keep-alive is not a protocol error; routing it through + // `onError` would cause consumers that log on `onError` to + // receive spurious noise on every server keep-alive ping. if (data.trim() == ':') { return; } - - final event = _decoder.decode(data); - - // Validate event before adding to stream - if (_decoder.validate(event)) { - sink.add(event); - } + + // `decode` already runs `validate` via `decodeJson`; no + // second pass needed here. + sink.add(_decoder.decode(data)); } // Ignore non-data messages (id, event, retry, comments) } catch (e, stack) { - final error = e is AgUiError ? e : DecodingError( - 'Failed to process SSE message', - field: 'message', - expectedType: 'BaseEvent', - actualValue: message.data, - cause: e, - ); - + // Preserve any `AGUIError` subtype (covers `AgUiError`, + // `AGUIValidationError`, and `EncoderError` siblings) so the + // unified error-surface contract documented on `EventDecoder` + // is not undone by re-wrapping at the stream-adapter layer. + final error = e is AGUIError + ? e + : DecodingError( + 'Failed to process SSE message', + field: 'message', + expectedType: 'BaseEvent', + actualValue: message.data, + cause: e, + ); + if (skipInvalidEvents) { // Log error but continue processing onError?.call(error, stack); @@ -149,171 +212,483 @@ class EventStreamAdapter { /// /// This handles partial messages that may be split across multiple /// stream events, buffering as needed. + /// + /// Line terminators: per the WHATWG SSE spec, `\r\n`, lone `\n`, and + /// lone `\r` are all valid. This implementation supports all three. + /// A trailing `\r` at the end of a chunk is deferred to the next chunk + /// to disambiguate from a chunk-spanning `\r\n`; on stream close the + /// deferred `\r` is consumed as a complete lone-CR terminator. + /// + /// **Semantic divergence from [EventDecoder.decodeSSE]:** + /// - `decodeSSE` receives a complete SSE message string and throws a + /// structured [DecodingError] for keep-alive frames (comment-only or + /// `data: :` payloads) and for frames with no `data:` lines. + /// - `fromRawSseStream` receives raw streaming chunks; keep-alives + /// (`data.trim() == ':'`) are silently discarded in [flushDataBlock] + /// and partial frames accumulate across chunks. The two methods share + /// the same final `decode` call but differ on keep-alive routing and + /// partial-frame handling. + /// + /// See [fromSseStream] for the [skipInvalidEvents] / [onError] + /// semantics, including the silent-drop note for + /// `REASONING_ENCRYPTED_VALUE` events with unknown subtypes. + /// + /// Edge case on abnormal termination: when the stream ends mid-line + /// (no trailing terminator) AND the partial line in the buffer is NOT + /// `data:`-prefixed (e.g. it is `event:`, `id:`, `retry:`, a `:`-comment, + /// or an in-progress continuation of a multi-line `data:` block), that + /// partial line is silently dropped. Steady-state SSE parsing already + /// ignores those lines per the spec; the drop only affects truly + /// abnormal close-without-newline cases. A trailing `data:`-prefixed + /// partial line, by contrast, is flushed and decoded. Stream fromRawSseStream( Stream rawStream, { bool skipInvalidEvents = false, void Function(Object error, StackTrace stackTrace)? onError, }) { + // `sync: true` means `controller.add(...)` calls downstream listeners + // synchronously on the same call stack. Re-entrancy contract: + // consumers MUST NOT call `subscription.cancel()` synchronously from + // inside a `listen` data handler — doing so cancels the underlying + // subscription while it is still being iterated and can cause a + // `ConcurrentModificationError` or double-close. If you need to + // cancel on a received event, schedule it via `Future.microtask`. + // + // Re-entrancy guards: two separate flags protect two separate surfaces. + // `inDispatch` guards the controller.add dispatch site inside + // flushDataBlock — it fires if a downstream data handler cancels the + // subscription synchronously during dispatch, which would corrupt + // controller state. `inProcessChunk` (declared below) guards the + // outer processChunk entry — it fires if a downstream data handler + // synchronously pushes new SSE input while the current chunk is still + // being parsed, which would corrupt the per-invocation parser state + // (buffer, dataBuffer, inDataBlock, lastWasLoneCr). Callers must + // schedule any re-entrant stream operations via Future.microtask. + // IMPORTANT: single-subscription semantics assumed. The closure state + // below (buffer, dataBuffer, inDataBlock, lastWasLoneCr, errorRoutedInChunk, + // skipUntilBoundary) is created once per invocation for exactly one + // subscriber. Converting to a broadcast controller would require moving + // these locals into per-listener closures — the current design is + // incompatible with multiple concurrent subscribers. final controller = StreamController(sync: true); - - rawStream.listen( - (chunk) { + var inDispatch = false; + + // Per-invocation state. Keeping these local (not instance fields) + // ensures abnormal termination of one stream cannot leak partial + // `data:` payloads or a stale `inDataBlock` flag into a subsequent + // invocation on the same adapter. + final buffer = StringBuffer(); + final dataBuffer = StringBuffer(); + var inDataBlock = false; + // Tracks whether the last terminator seen across ALL prior chunks was a + // lone CR. Persisting this across processChunk calls lets _scanLines + // skip the trailing-\r deferral for producers that use lone-CR style + // and deliver each terminator in its own chunk — without persistence the + // flag resets to false on every call, adding a full chunk-RTT of latency + // per event. + var lastWasLoneCr = false; + // When a data-block size-cap error fires mid-message, skip all subsequent + // `data:` lines for that message until the next blank-line boundary. This + // prevents the tail of an oversized message (possibly in a later chunk) + // from silently leaking into the next message's buffer. + var skipUntilBoundary = false; + + // Append the value portion of a `data:` or `data: ` line to the + // active data block. Lines that aren't `data:`-prefixed are silently + // ignored per the WHATWG SSE spec (event:, id:, retry:, comments). + // Closes over `dataBuffer` and `inDataBlock` so the per-line loop + // and the `onDone` final flush share the same logic. + void appendDataLine(String line) { + if (skipUntilBoundary) return; // skip tail of capped message + String value; + if (line.startsWith('data: ')) { + value = line.substring(6); + } else if (line.startsWith('data:')) { + value = line.substring(5); + } else { + return; // Not a data line — ignore per spec. + } + // Size cap: mirrors SseParser._processField. The +1 is for the newline + // separator added between multi-line data blocks. + final addedLen = inDataBlock ? (1 + value.length) : value.length; + if (dataBuffer.length + addedLen > maxDataCodeUnits) { + // Clear state before throwing so partial data doesn't pollute the + // next frame. Set skipUntilBoundary so later chunks' continuation + // lines for this same message don't leak into the next message. + // The thrown DecodingError is caught by processChunk's outer + // try/catch and routed via controller.addError. + dataBuffer.clear(); + inDataBlock = false; + lastWasLoneCr = false; + skipUntilBoundary = true; + throw DecodingError( + 'SSE data block exceeds $maxDataCodeUnits code units', + field: 'data', + expectedType: 'String', + ); + } + if (inDataBlock) { + // Multi-line data: add newline between lines per spec. + dataBuffer.write('\n'); + dataBuffer.write(value); + } else { + dataBuffer.clear(); + dataBuffer.write(value); + inDataBlock = true; + } + } + + // Flush the accumulated data block as a single decoded event. + // Used by the empty-line dispatch and the `onDone` final flush. + // Returns `true` if an error was routed to the controller so callers + // can suppress a redundant second `addError` from their own catch. + bool flushDataBlock() { + if (!inDataBlock) return false; + final data = dataBuffer.toString(); + dataBuffer.clear(); + inDataBlock = false; + + if (data.isEmpty || data.trim() == ':') return false; + + // Programmer-error guard sits outside the wire-error catch so a + // re-entrancy bug surfaces as DecodingError("Internal error processing + // SSE chunk") — distinct from the normal "Failed to decode SSE data". + if (inDispatch) { + throw StateError( + 'sync re-entrancy: cancel() must not be called synchronously ' + 'from inside a data handler; use Future.microtask. See ' + 'fromRawSseStream dartdoc for details.', + ); + } + + try { + // `decode` already runs `validate` via `decodeJson`; no + // second pass needed here. + inDispatch = true; try { - _processChunk(chunk, controller, skipInvalidEvents, onError); - } catch (e, stack) { - if (!skipInvalidEvents) { - controller.addError(e, stack); - } else { - onError?.call(e, stack); - } + controller.add(_decoder.decode(data)); + } finally { + inDispatch = false; } - }, - onError: (Object error, StackTrace stack) { + return false; + } catch (e, stack) { + // StateError is the re-entrancy programmer-error guard — do not + // flatten to DecodingError. Let it propagate unwrapped so test + // runners surface it as a hard failure, not a recoverable wire error. + if (e is StateError) rethrow; + // Preserve any `AGUIError` subtype (`AgUiError`, + // `AGUIValidationError`, `EncoderError`) so the unified + // error-surface contract from `EventDecoder` is not undone by + // re-wrapping here. Only foreign exceptions become a generic + // `DecodingError`. + final error = e is AGUIError + ? e + : DecodingError( + 'Failed to decode SSE data', + field: 'data', + expectedType: 'BaseEvent', + actualValue: data, + cause: e, + ); + + // NOTE: `addError` is intentionally not wrapped by `inDispatch`. + // The guard protects `controller.add` (data dispatch). Error handlers + // registered via `listen(onError:)` should not call stream operations + // synchronously — see the re-entrancy note on [fromRawSseStream]. if (!skipInvalidEvents) { controller.addError(error, stack); } else { onError?.call(error, stack); } - }, - onDone: () { - // Process any remaining incomplete line in buffer - final remaining = _buffer.toString(); - if (remaining.isNotEmpty) { - // Treat remaining content as a complete line - if (remaining.startsWith('data: ')) { - final value = remaining.substring(6); - if (_inDataBlock) { - _dataBuffer.write('\n'); - _dataBuffer.write(value); - } else { - _dataBuffer.clear(); - _dataBuffer.write(value); - _inDataBlock = true; - } - } else if (remaining.startsWith('data:')) { - final value = remaining.substring(5); - if (_inDataBlock) { - _dataBuffer.write('\n'); - _dataBuffer.write(value); - } else { - _dataBuffer.clear(); - _dataBuffer.write(value); - _inDataBlock = true; - } - } + return true; // error was already routed + } + } + + // Whether the current chunk's `flushDataBlock` call already routed an + // error so the outer `onListen` catch can skip a second `addError`. + var errorRoutedInChunk = false; + + // Local helpers that own the "reset errorRoutedInChunk before call" + // invariant so it is enforced at the definition site rather than at + // every callsite in the per-line loop. + void flushThenAck() { + errorRoutedInChunk = false; + if (flushDataBlock()) errorRoutedInChunk = true; + } + + void appendThenAck(String line) { + errorRoutedInChunk = false; + appendDataLine(line); + } + + // Re-entrancy guard for processChunk. Distinct from `inDispatch` (which + // guards flushDataBlock's controller.add call): this flag detects the + // rarer case where a downstream data handler synchronously triggers new + // SSE input (possible with sync: true controllers), which would corrupt + // the per-invocation parser state (buffer, dataBuffer, inDataBlock, …). + var inProcessChunk = false; + + void processChunk(String chunk) { + if (inProcessChunk) { + throw StateError( + 'processChunk re-entered synchronously — a downstream data handler ' + 'must not synchronously add new SSE input. Use Future.microtask. ' + 'See fromRawSseStream dartdoc for details.', + ); + } + inProcessChunk = true; + try { + // Size cap on the raw line buffer. A server that sends a line without + // any newline would otherwise grow `buffer` without bound. + // Note: this cap is applied to `buffer` (the line assembly buffer) and + // `appendDataLine` applies an independent cap to `dataBuffer`. In the + // worst case, both caps fire at their limits, so the true in-flight + // worst-case memory for a single stream invocation is approximately + // 2 × maxDataCodeUnits code units. + if (buffer.length + chunk.length > maxDataCodeUnits) { + buffer.clear(); + // Mirror the appendDataLine size-cap reset: clear any in-progress + // data block so its partial content doesn't contaminate the next + // message's buffer after the error is routed and processing continues. + dataBuffer.clear(); + inDataBlock = false; + lastWasLoneCr = false; + skipUntilBoundary = true; + throw DecodingError( + 'SSE chunk combined with pending line buffer exceeds ' + '$maxDataCodeUnits code units', + field: 'chunk', + expectedType: 'String', + ); + } + // Add chunk to buffer to handle partial lines. + buffer.write(chunk); + + // Multi-terminator scan: see [_scanLines] for the spec rationale. + // `endOfStream: false` defers a trailing `\r` so a chunk-spanning + // `\r\n` doesn't double-fire as two empty lines. + // Pass `lastWasLoneCrAtStart` so the flag survives chunk boundaries + // and capture the updated value for the next call. + final scan = _scanLines( + buffer.toString(), + endOfStream: false, + lastWasLoneCrAtStart: lastWasLoneCr, + ); + lastWasLoneCr = scan.lastWasLoneCr; + buffer.clear(); + buffer.write(scan.unconsumed); + + for (final line in scan.lines) { + if (line.isEmpty) { + skipUntilBoundary = false; + flushThenAck(); + } else { + appendThenAck(line); } - - // Process any accumulated data - if (_inDataBlock && _dataBuffer.isNotEmpty) { - final data = _dataBuffer.toString(); + } + } finally { + inProcessChunk = false; + } + } + + // Defer the upstream subscription to `onListen` so a caller that + // obtains the returned stream but never subscribes does not leak the + // upstream connection. Without deferral, `rawStream.listen(...)` fires + // immediately on the `fromRawSseStream` call — a caller that stores the + // stream for later or abandons it would keep the upstream alive until the + // server closes the SSE connection. Mirroring the standard Dart lazy- + // subscription idiom also makes the backpressure propagation below + // consistent: `onCancel` only fires after `onListen`, so `subscription` + // is always initialized by the time any lifecycle callback runs. + StreamSubscription? subscription; + + controller.onListen = () { + subscription = rawStream.listen( + (chunk) { + errorRoutedInChunk = false; try { - final event = _decoder.decode(data); - controller.add(event); + processChunk(chunk); } catch (e, stack) { + // If `flushDataBlock` already routed an error to the controller + // (via `controller.addError`), skip a second `addError` here to + // avoid double-firing the same error at the stream consumer. + if (errorRoutedInChunk) return; + final error = e is AGUIError + ? e + : DecodingError( + 'Internal error processing SSE chunk', + field: 'chunk', + expectedType: 'String', + actualValue: chunk, + cause: e, + ); if (!skipInvalidEvents) { - controller.addError(e, stack); + controller.addError(error, stack); } else { - onError?.call(e, stack); + onError?.call(error, stack); } } - } - // Clear buffers - _buffer.clear(); - _dataBuffer.clear(); - _inDataBlock = false; - controller.close(); - }, - cancelOnError: false, - ); - + }, + onError: (Object error, StackTrace stack) { + if (!skipInvalidEvents) { + controller.addError(error, stack); + } else { + onError?.call(error, stack); + } + }, + onDone: () { + // Defensive reset: the chunk handler is the only place where + // errorRoutedInChunk can be set to true, and onDone fires after + // the last chunk. Resetting here prevents a stale true from a + // malformed final chunk from silently suppressing errors in a + // reused context (not possible in the current design, but safe). + errorRoutedInChunk = false; + // End-of-stream: any deferred trailing `\r` is now a complete + // terminator. Run the scanner with `endOfStream: true` to + // consume it (and any other complete lines still in the buffer). + // Forward lastWasLoneCr for symmetry with processChunk — safe + // today (unconsumed cannot begin with \n at onDone time) but + // preserves the correct state for any future unconsumed handling. + final scan = _scanLines( + buffer.toString(), + endOfStream: true, + lastWasLoneCrAtStart: lastWasLoneCr, + ); + buffer.clear(); + + for (final line in scan.lines) { + if (line.isEmpty) { + flushDataBlock(); + } else { + appendDataLine(line); + } + } + + // Any unconsumed suffix is a final partial line with no + // terminator. The pre-CRLF-fix code only handled `data:`-prefixed + // partials here; `appendDataLine` preserves that behavior because + // it ignores non-`data:` lines per spec. + if (scan.unconsumed.isNotEmpty) { + appendDataLine(scan.unconsumed); + } + + // Final flush — emits any leftover data block accumulated from + // either the deferred-line scan or the partial-line append above. + flushDataBlock(); + if (!controller.isClosed) controller.close(); + }, + cancelOnError: false, + ); + }; + controller.onCancel = () async { + await subscription?.cancel(); + subscription = null; + }; + controller.onPause = () => subscription?.pause(); + controller.onResume = () => subscription?.resume(); + return controller.stream; } - /// Process a chunk of SSE data. - void _processChunk( - String chunk, - StreamController controller, - bool skipInvalidEvents, - void Function(Object error, StackTrace stackTrace)? onError, - ) { - // Add chunk to buffer to handle partial lines - _buffer.write(chunk); - - // Process complete lines only - String bufferStr = _buffer.toString(); + /// Scans [input] for complete lines, returning the complete lines and + /// the unconsumed suffix. Per the WHATWG SSE spec, line terminators + /// can be `\r\n`, lone `\n`, or lone `\r`. + /// + /// When [endOfStream] is `false`, a trailing `\r` at the end of the + /// buffer is left in the unconsumed suffix to disambiguate a + /// chunk-spanning `\r\n` (the next chunk could start with `\n`). + /// EXCEPTION: when the immediately preceding terminator in this scan + /// was also a lone `\r`, the producer is committed to lone-CR style and + /// the trailing `\r` is consumed immediately — without this exception + /// a single-chunk `data: foo\r\r` would defer the event-boundary `\r` + /// and stall steady-state lone-CR streams. CRLF producers cannot + /// trigger this exception because every `\r` is paired with `\n` + /// (so `lastWasLoneCr` never becomes `true` in the same scan). + /// + /// When [endOfStream] is `true`, the deferral is disabled entirely — + /// any trailing `\r` is consumed as a lone-CR terminator since no + /// further chunks are coming. + static ({List lines, String unconsumed, bool lastWasLoneCr}) + _scanLines( + String input, { + required bool endOfStream, + bool lastWasLoneCrAtStart = false, + }) { final lines = []; - - // Extract complete lines (those ending with \n) - while (bufferStr.contains('\n')) { - final lineEnd = bufferStr.indexOf('\n'); - final line = bufferStr.substring(0, lineEnd); - lines.add(line); - bufferStr = bufferStr.substring(lineEnd + 1); + + // Edge case: when `lastWasLoneCrAtStart` is true, the previous scan + // consumed a lone-CR at its boundary immediately (because the exception + // that skips deferral for known-lone-CR producers applied). If the new + // chunk starts with `\n`, that `\n` is the second half of a + // chunk-spanning CRLF pair — skip it so the pair does not dispatch an + // extra empty-line boundary. + String s; + bool lastWasLoneCr; + if (lastWasLoneCrAtStart && + input.isNotEmpty && + input.codeUnitAt(0) == 0x0A /* \n */) { + s = input.substring(1); + lastWasLoneCr = false; // was actually CRLF, not lone-CR + } else { + s = input; + lastWasLoneCr = lastWasLoneCrAtStart; } - - // Keep any incomplete line in the buffer - _buffer.clear(); - _buffer.write(bufferStr); - - // Process each complete line - for (final line in lines) { - if (line.isEmpty) { - // Empty line signals end of SSE message - if (_inDataBlock) { - final data = _dataBuffer.toString(); - _dataBuffer.clear(); - _inDataBlock = false; - - if (data.isNotEmpty && data.trim() != ':') { - try { - final event = _decoder.decode(data); - if (_decoder.validate(event)) { - controller.add(event); - } - } catch (e, stack) { - final error = e is AgUiError ? e : DecodingError( - 'Failed to decode SSE data', - field: 'data', - expectedType: 'BaseEvent', - actualValue: data, - cause: e, - ); - - if (!skipInvalidEvents) { - controller.addError(error, stack); - } else { - onError?.call(error, stack); - } - } - } - } - } else if (line.startsWith('data: ')) { - // Extract data value (after "data: ") - final value = line.substring(6); - if (_inDataBlock) { - // Multi-line data: add newline between lines - _dataBuffer.write('\n'); - _dataBuffer.write(value); - } else { - // Start new data block - _dataBuffer.clear(); - _dataBuffer.write(value); - _inDataBlock = true; - } - } else if (line.startsWith('data:')) { - // Handle no space after colon - final value = line.substring(5); - if (_inDataBlock) { - _dataBuffer.write('\n'); - _dataBuffer.write(value); - } else { - _dataBuffer.clear(); - _dataBuffer.write(value); - _inDataBlock = true; + // Single-pass O(n) scan: advance index `i` forward rather than + // repeatedly calling indexOf + substring (which was O(n²) on inputs + // with many lines, since each iteration re-scanned the remaining string). + var i = 0; + while (i < s.length) { + // Scan forward for the next \r or \n terminator. + int brk = -1; + for (var j = i; j < s.length; j++) { + final c = s.codeUnitAt(j); + if (c == 0x0A /* \n */ || c == 0x0D /* \r */) { + brk = j; + break; } } - // Ignore other lines (comments, event:, id:, retry:, etc.) + if (brk == -1) break; // no more terminators in remaining input + + // Defer a trailing `\r` so a chunk-spanning `\r\n` doesn't appear + // as two terminators (lone `\r` then lone `\n`). Skip the deferral + // when the previous terminator was lone-CR — the producer is + // clearly using lone-CR style, so the trailing `\r` IS its own + // terminator. See class-level scan rationale above. + // + // NOTE on the "chunk ends exactly at \r" case (e.g. chunk = "foo\r"): + // This deferral fires and leaves `\r` in the unconsumed suffix. + // `lastWasLoneCrAtStart` is NOT involved here — that flag is only set + // when a PREVIOUS scan already consumed a lone-CR at its boundary + // (the producer was confirmed lone-CR style). In this path the `\r` + // is tentative: the next chunk may start with `\n` (making it CRLF) + // or not (making it lone-CR). The next scan will resolve it via the + // `lastWasLoneCrAtStart` edge-case check at the top of `_scanLines`. + if (!endOfStream && + !lastWasLoneCr && + s.codeUnitAt(brk) == 0x0D /* \r */ && + brk == s.length - 1) { + break; + } + + final isCrLf = s.codeUnitAt(brk) == 0x0D && + brk + 1 < s.length && + s.codeUnitAt(brk + 1) == 0x0A /* \n */; + // Recompute lastWasLoneCr for the terminator we just consumed. This + // interacts with the deferral exception above (line 622): once the + // producer is confirmed lone-CR style (`lastWasLoneCr == true`), the + // deferral at line 621 is skipped, so a trailing `\r` IS consumed + // immediately — and this recompute keeps lastWasLoneCr true for the + // next chunk. The flag therefore toggles only when the terminator + // style changes mid-stream. + lastWasLoneCr = s.codeUnitAt(brk) == 0x0D /* \r */ && !isCrLf; + lines.add(s.substring(i, brk)); + i = brk + (isCrLf ? 2 : 1); } + return ( + lines: lines, + unconsumed: s.substring(i), + lastWasLoneCr: lastWasLoneCr + ); } /// Filters a stream of events to only include specific event types. @@ -327,90 +702,441 @@ class EventStreamAdapter { /// /// For example, groups TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, /// and TEXT_MESSAGE_END events for the same messageId. + /// + /// **Unbounded-state warning.** Open groups (where `*Start` was + /// received but `*End` has not yet arrived) are held in memory until + /// the matching `*End` event arrives or the upstream stream + /// completes. A producer that opens IDs without closing them — for + /// instance, an interrupted upstream connection or a buggy server — + /// will grow the internal map indefinitely. Use [maxOpenGroups] to cap + /// the number of concurrently open groups; when the cap is reached the + /// oldest open group is evicted (emitted as-is) before the new one is + /// added. "Oldest" means earliest `*Start` arrival (insertion order into + /// the internal LinkedHashMap), not least-recently-used. Set to 0 (the + /// default) for no cap. The same caveat and option apply to + /// [accumulateTextMessages]. **Note:** [maxOpenGroups] is a single cap + /// shared across ALL event families (text, reasoning, tool); a mixed + /// stream with 5 open tool-call groups and 5 open text-message groups + /// counts as 10 against the cap. + /// + /// **Duplicate-start policy.** If a second `*Start` event arrives with + /// the same id while the prior group is still open, the prior group's + /// accumulated events are discarded silently and a new group begins + /// ("last-Start-wins"). **Data loss warning:** all `*Content` events + /// accumulated under the prior group are dropped with no signal to the + /// downstream consumer. This is intentional and matches the behavior of the + /// TS/Python reference SDKs. Consumers that need strict sequencing (or need + /// to detect duplicate-Start conditions) should validate the upstream event + /// stream before passing it here. + /// + /// **On stream close:** any open groups (where a `*Start` was received + /// but `*End` has not yet arrived) are emitted in `*Start` arrival order. + /// Consumers should treat such groups as potentially incomplete — they + /// will be missing the terminal `*End` event and any final content that + /// never arrived. + /// + /// **Reasoning event asymmetry.** Only message-level + /// `REASONING_MESSAGE_START` / `REASONING_MESSAGE_CONTENT` / + /// `REASONING_MESSAGE_END` events are grouped (under the key + /// `reasoning:`). The phase-level `REASONING_START` / + /// `REASONING_END` events are emitted as standalone singletons — they + /// fall through to the `default` case. Consumers that need to associate + /// phase-level markers with the messages they wrap should track the phase + /// boundary in their own state, or subscribe to the typed event stream + /// directly. + /// + /// **`TOOL_CALL_RESULT` events.** `ToolCallResultEvent` is emitted as a + /// standalone singleton (falls through to `default`). It is NOT grouped + /// with its sibling `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` + /// events — results arrive asynchronously via a separate protocol flow and + /// share no id-based linkage. Consumers that need to associate results with + /// their preceding call group should track by `toolCallId` in their own + /// state. + /// + /// **Orphan `*_End` events.** An `*_End` event that arrives with no + /// preceding `*_Start` (e.g. after a reconnect that missed the opening + /// event) is emitted as a standalone single-element group rather than + /// silently dropped, consistent with how orphan `*_Chunk` events are + /// handled. + /// + /// **Chunk fold-in and duplicate-Start interaction.** `*_Chunk` events are + /// folded into the currently active group for the same id if one is open; + /// otherwise they are emitted as standalone single-element groups. When a + /// duplicate `*_Start` evicts the prior open group, any chunks that arrived + /// before the eviction travel with the evicted group. Chunks that arrive + /// after the eviction fold into the new group. static Stream> groupRelatedEvents( - Stream eventStream, - ) { + Stream eventStream, { + int maxOpenGroups = 0, + }) { + // `sync: true` — see re-entrancy note on [fromRawSseStream]. final controller = StreamController>(sync: true); + // LinkedHashMap insertion order is relied upon by the onDone flush AND by + // the maxOpenGroups eviction (evicts oldest — first insertion-order entry). + // Do NOT replace with HashMap (unordered) or SplayTreeMap (sorted). final Map> activeGroups = {}; - - eventStream.listen( - (event) { - switch (event) { - case TextMessageStartEvent(:final messageId): - activeGroups[messageId] = [event]; - case TextMessageContentEvent(:final messageId): - activeGroups[messageId]?.add(event); - case TextMessageEndEvent(:final messageId): - final group = activeGroups.remove(messageId); - if (group != null) { - group.add(event); - controller.add(group); + StreamSubscription? subscription; + var inDispatch = false; + + // Defer subscription to `onListen` so that: + // • A caller that stores the stream but never subscribes does not + // leak the upstream listener. + // • Backpressure (pause/resume/cancel) propagates correctly to + // the upstream, matching the pattern used by `fromRawSseStream`. + controller.onListen = () { + subscription = eventStream.listen( + (event) { + // Route the re-entrancy StateError through controller.addError so + // the downstream consumer receives a structured error rather than + // an unhandled async exception. Mirrors fromRawSseStream's outer + // try/catch around processChunk. + try { + if (inDispatch) { + throw StateError( + 'sync re-entrancy: cancel() must not be called synchronously ' + 'from inside a groupRelatedEvents data handler; use ' + 'Future.microtask.', + ); } - case ToolCallStartEvent(:final toolCallId): - activeGroups[toolCallId] = [event]; - case ToolCallArgsEvent(:final toolCallId): - activeGroups[toolCallId]?.add(event); - case ToolCallEndEvent(:final toolCallId): - final group = activeGroups.remove(toolCallId); - if (group != null) { - group.add(event); + inDispatch = true; + try { + // Open a new group, evicting the oldest open group first if the + // maxOpenGroups cap is exceeded. Eviction emits the oldest group + // as-is (without a terminal *End event) — consumers should treat + // evicted groups the same as groups emitted on stream close. + void openGroup(String key, BaseEvent startEvent) { + if (maxOpenGroups > 0 && + activeGroups.length >= maxOpenGroups && + !activeGroups.containsKey(key)) { + final oldestKey = activeGroups.keys.first; + final evicted = activeGroups.remove(oldestKey)!; + // controller.add is intentionally NOT wrapped by the + // inDispatch guard here. inDispatch exists to detect + // re-entrant UPSTREAM events delivered synchronously via + // controller.add → listener callback → next event. Eviction + // is a downstream flush triggered by upstream overflow — it + // does not re-enter the upstream dispatch path. Wrapping it + // in inDispatch would cause the duplicate-Start silent-drop + // test to break by treating the eviction flush as re-entrant. + if (evicted.isNotEmpty) controller.add(evicted); + } + activeGroups[key] = [startEvent]; + } + + switch (event) { + // Keys are namespaced by event family ('text:', 'reasoning:', + // 'tool:') so that a producer reusing the same id across families + // (e.g. a text message and a reasoning step sharing a messageId) + // does not overwrite one group with another. + case TextMessageStartEvent(:final messageId): + openGroup('text:$messageId', event); + case TextMessageContentEvent(:final messageId): + activeGroups['text:$messageId']?.add(event); + case TextMessageEndEvent(:final messageId): + final group = activeGroups.remove('text:$messageId'); + if (group != null) { + group.add(event); + controller.add(group); + } else { + controller.add([event]); // orphan End — emit standalone + } + case ToolCallStartEvent(:final toolCallId): + openGroup('tool:$toolCallId', event); + case ToolCallArgsEvent(:final toolCallId): + activeGroups['tool:$toolCallId']?.add(event); + case ToolCallEndEvent(:final toolCallId): + final group = activeGroups.remove('tool:$toolCallId'); + if (group != null) { + group.add(event); + controller.add(group); + } else { + controller.add([event]); // orphan End — emit standalone + } + case ReasoningMessageStartEvent(:final messageId): + openGroup('reasoning:$messageId', event); + case ReasoningMessageContentEvent(:final messageId): + activeGroups['reasoning:$messageId']?.add(event); + case ReasoningMessageEndEvent(:final messageId): + final group = activeGroups.remove('reasoning:$messageId'); + if (group != null) { + group.add(event); + controller.add(group); + } else { + controller.add([event]); // orphan End — emit standalone + } + case TextMessageChunkEvent(:final messageId): + // Fold into the open text group when one exists; otherwise emit + // standalone — chunks may arrive without a preceding *Start. + if (messageId != null && + activeGroups.containsKey('text:$messageId')) { + activeGroups['text:$messageId']!.add(event); + } else { + controller.add([event]); + } + case ToolCallChunkEvent(:final toolCallId): + // Fold into the open tool group when one exists; otherwise emit + // standalone — chunks may arrive without a preceding *Start. + if (toolCallId != null && + activeGroups.containsKey('tool:$toolCallId')) { + activeGroups['tool:$toolCallId']!.add(event); + } else { + controller.add([event]); + } + case ReasoningMessageChunkEvent(:final messageId): + // Fold into the open reasoning group when one exists; otherwise + // emit standalone — chunks may arrive without a preceding *Start. + if (messageId != null && + activeGroups.containsKey('reasoning:$messageId')) { + activeGroups['reasoning:$messageId']!.add(event); + } else { + controller.add([event]); + } + default: + // Single events not part of a group + controller.add([event]); + } + } finally { + inDispatch = false; + } + } catch (e, stack) { + // StateError is the re-entrancy programmer-error guard — do not + // flatten to addError. Let it propagate unwrapped so test runners + // surface it as a hard failure, not a recoverable error event. + if (e is StateError) rethrow; + // NOTE: `addError` is intentionally not wrapped by `inDispatch`. + // The guard protects `controller.add` (data dispatch). Error + // handlers registered via `listen(onError:)` must not call stream + // operations synchronously — see the re-entrancy note on + // [fromRawSseStream]. + controller.addError(e, stack); + } + }, + onError: controller.addError, + onDone: () { + // Snapshot before iterating: a synchronous downstream cancel inside + // controller.add could re-enter onDone via controller.close and + // mutate activeGroups mid-iteration. + final snapshot = activeGroups.values.toList(); + activeGroups.clear(); + for (final group in snapshot) { + if (group.isNotEmpty) { controller.add(group); } - default: - // Single events not part of a group - controller.add([event]); - } - }, - onError: controller.addError, - onDone: () { - // Emit any incomplete groups - for (final group in activeGroups.values) { - if (group.isNotEmpty) { - controller.add(group); } - } - controller.close(); - }, - cancelOnError: false, - ); - + if (!controller.isClosed) controller.close(); + }, + cancelOnError: false, + ); + }; + controller.onCancel = () async { + await subscription?.cancel(); + subscription = null; + }; + controller.onPause = () => subscription?.pause(); + controller.onResume = () => subscription?.resume(); + return controller.stream; } - /// Accumulates text message content into complete messages. + /// Accumulates user-visible text message content into complete messages. + /// + /// **Scope: user-visible text only.** Only `TEXT_MESSAGE_*` and + /// `TEXT_MESSAGE_CHUNK` events are handled. `REASONING_MESSAGE_*` events + /// (model-internal reasoning chains, not shown to the end user) are + /// intentionally excluded — consumers that need to accumulate reasoning + /// content should use [groupRelatedEvents] and filter by type, or write + /// a dedicated sibling accumulator. + /// + /// Emits one [String] per logical message when its `TextMessageEnd` event + /// arrives. Empty Start→End cycles (no content events between them) emit + /// nothing. **On stream close:** any accumulated-but-not-ended message + /// buffers are flushed in `*Start` arrival order as a final [String], + /// matching [groupRelatedEvents]' "emit incomplete groups on close" + /// behavior. Empty buffers are not emitted. Consumers cannot distinguish + /// between a normally-completed message and a flushed-on-close partial + /// without observing the absence of `TextMessageEnd` upstream. + /// + /// **Duplicate-start policy.** If a second `TextMessageStartEvent` arrives + /// with the same `messageId` while a prior buffer is still open, the prior + /// accumulated content is discarded silently and a new buffer begins + /// ("last-Start-wins"). This matches the behavior of [groupRelatedEvents]. + /// Consumers that need strict sequencing should validate the upstream event + /// stream before passing it here. + /// + /// **Chunk-before-Start buffering.** A `TextMessageChunkEvent` that arrives + /// before its `TextMessageStartEvent` is buffered (keyed by `messageId`) in + /// an internal `pendingPreStartChunks` map and drained into the active buffer + /// when the matching `Start` arrives. The eventual `End` produces a single + /// emission containing both the buffered chunks and any intervening `Content` + /// deltas — no double-emission. Example: + /// ``` + /// upstream: Chunk("hello") → Start → Content(" world") → End + /// emitted: "hello world" + /// ^single accumulated flush on End + /// ``` + /// If the `Start` never arrives, the buffered chunk is flushed as a + /// standalone fragment when the stream closes. A null `messageId` on `Chunk` + /// has no identity to track and is emitted immediately as a standalone + /// fragment. + /// + /// Both `activeMessages` and `pendingPreStartChunks` count against the + /// [maxOpenGroups] cap: an eviction is triggered when the combined size of + /// the two maps reaches [maxOpenGroups] and a new unseen `messageId` arrives + /// on a `Chunk`. The oldest `pendingPreStartChunks` entry is evicted first + /// (before it has accumulated a `Start`), matching the `groupRelatedEvents` + /// eviction semantics. static Stream accumulateTextMessages( - Stream eventStream, - ) { - final controller = StreamController(); + Stream eventStream, { + int maxOpenGroups = 0, + }) { + // `sync: true` — see re-entrancy note on [fromRawSseStream]. + final controller = StreamController(sync: true); + // LinkedHashMap insertion order is relied upon by the onDone flush AND by + // the maxOpenGroups eviction (evicts oldest open message first). + // Do NOT replace with HashMap (unordered) or SplayTreeMap (sorted). final Map activeMessages = {}; - - eventStream.listen( - (event) { - switch (event) { - case TextMessageStartEvent(:final messageId): - activeMessages[messageId] = StringBuffer(); - case TextMessageContentEvent(:final messageId, :final delta): - activeMessages[messageId]?.write(delta); - case TextMessageEndEvent(:final messageId): - final buffer = activeMessages.remove(messageId); - if (buffer != null) { - controller.add(buffer.toString()); + // Buffers Chunk deltas that arrive before the matching Start. Keyed by + // messageId. Drained into activeMessages when the Start arrives; flushed + // as standalone fragments in onDone if no Start ever arrives. + final Map pendingPreStartChunks = {}; + StreamSubscription? subscription; + var inDispatch = false; + + // Defer subscription to `onListen` — mirrors `groupRelatedEvents` + // and `fromRawSseStream` so upstream leaks and backpressure issues + // are avoided. Uses `sync: true` to match the synchronous-emit + // contract of the other stream helpers in this class. + controller.onListen = () { + subscription = eventStream.listen( + (event) { + // Route the re-entrancy StateError through controller.addError. + // Mirrors the groupRelatedEvents and fromRawSseStream patterns. + try { + if (inDispatch) { + throw StateError( + 'sync re-entrancy: cancel() must not be called synchronously ' + 'from inside an accumulateTextMessages data handler; use ' + 'Future.microtask.', + ); } - case TextMessageChunkEvent(:final messageId, :final delta): - // Handle chunk events (single event with complete content) - if (messageId != null && delta != null) { - controller.add(delta); + inDispatch = true; + try { + switch (event) { + case TextMessageStartEvent(:final messageId): + // Evict the oldest open message when the cap is reached. + if (maxOpenGroups > 0 && + activeMessages.length >= maxOpenGroups && + !activeMessages.containsKey(messageId)) { + final oldestKey = activeMessages.keys.first; + final evicted = activeMessages.remove(oldestKey)!; + final content = evicted.toString(); + if (content.isNotEmpty) controller.add(content); + } + final buf = StringBuffer(); + // Drain any Chunk deltas that arrived before this Start. + final preStart = pendingPreStartChunks.remove(messageId); + if (preStart != null) buf.write(preStart); + activeMessages[messageId] = buf; + case TextMessageContentEvent(:final messageId, :final delta): + activeMessages[messageId]?.write(delta); + case TextMessageEndEvent(:final messageId): + final buffer = activeMessages.remove(messageId); + // Skip empty buffers (Start→End with no content) — consistent + // with the onDone flush which also drops empty buffers. + if (buffer != null && buffer.isNotEmpty) { + controller.add(buffer.toString()); + } + case TextMessageChunkEvent(:final messageId, :final delta): + // A chunk is a standalone text fragment. + // Priority 1: if a Start/End cycle is already open for this + // messageId, route into the active buffer (avoids emitting + // before the End-triggered flush). + // Priority 2: if no Start has arrived yet for this messageId, + // buffer the delta in pendingPreStartChunks — it will be + // drained into the active buffer when Start arrives, preventing + // the duplicate-emit that the old immediate-emit path produced. + // Priority 3: null messageId has no identity to track; emit. + if (delta == null) break; // genuinely nothing to emit + if (messageId != null) { + final activeBuffer = activeMessages[messageId]; + if (activeBuffer != null) { + activeBuffer.write(delta); + break; + } + // No active buffer yet — buffer for the eventual Start. + // Apply maxOpenGroups to the combined size of activeMessages + // and pendingPreStartChunks: the dartdoc promises a single + // unified bound, so both maps count against it. + if (maxOpenGroups > 0 && + (activeMessages.length + + pendingPreStartChunks.length) >= + maxOpenGroups && + !pendingPreStartChunks.containsKey(messageId)) { + final oldestKey = pendingPreStartChunks.keys.first; + final evicted = + pendingPreStartChunks.remove(oldestKey)!; + final content = evicted.toString(); + if (content.isNotEmpty) controller.add(content); + } + (pendingPreStartChunks[messageId] ??= StringBuffer()) + .write(delta); + break; + } + controller.add( + delta); // null messageId — emit immediately + default: + // Ignore other event types + break; + } + } finally { + inDispatch = false; } - default: - // Ignore other event types - break; - } - }, - onError: controller.addError, - onDone: controller.close, - cancelOnError: false, - ); - + } catch (e, stack) { + // StateError is the re-entrancy programmer-error guard — do not + // flatten to addError. Let it propagate unwrapped so test runners + // surface it as a hard failure, not a recoverable error event. + if (e is StateError) rethrow; + // NOTE: `addError` is intentionally not wrapped by `inDispatch`. + // The guard protects `controller.add` (data dispatch). Error + // handlers registered via `listen(onError:)` must not call stream + // operations synchronously — see the re-entrancy note on + // [fromRawSseStream]. + controller.addError(e, stack); + } + }, + onError: controller.addError, + onDone: () { + // Emit accumulated content for messages that never received + // TextMessageEnd (e.g. abnormal stream close). Mirrors + // groupRelatedEvents which emits incomplete groups on close. + // Snapshot before iterating: a synchronous downstream cancel inside + // controller.add could mutate activeMessages mid-iteration. + final snapshot = activeMessages.entries.toList(); + activeMessages.clear(); + for (final entry in snapshot) { + final content = entry.value.toString(); + if (content.isNotEmpty) controller.add(content); + } + // Flush pre-Start chunks whose Start never arrived (orphan Chunks). + // Emit them as standalone fragments in insertion order. + final pendingSnapshot = pendingPreStartChunks.entries.toList(); + pendingPreStartChunks.clear(); + for (final entry in pendingSnapshot) { + final content = entry.value.toString(); + if (content.isNotEmpty) controller.add(content); + } + if (!controller.isClosed) controller.close(); + }, + cancelOnError: false, + ); + }; + controller.onCancel = () async { + await subscription?.cancel(); + subscription = null; + }; + controller.onPause = () => subscription?.pause(); + controller.onResume = () => subscription?.resume(); + return controller.stream; } -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/events/event_type.dart b/sdks/community/dart/lib/src/events/event_type.dart index 6d5519adf7..10683e8fd6 100644 --- a/sdks/community/dart/lib/src/events/event_type.dart +++ b/sdks/community/dart/lib/src/events/event_type.dart @@ -1,14 +1,42 @@ /// Event type enumeration for AG-UI protocol. library; +// Hoisted `@Deprecated` messages: each is referenced exactly once below, +// but the long form is repeated again in `events.dart` per event class. +// Centralizing lets the planned-removal version (1.0.0) get edited in one +// place per surface (enum value vs. event class) instead of drifting. +const String _kThinkingTextMessageStartEnumDeprecation = + 'Use reasoningMessageStart (ReasoningMessageStartEvent) instead. ' + 'Mirrors the canonical TypeScript SDK deprecation of ' + 'THINKING_TEXT_MESSAGE_* in favor of REASONING_*. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingTextMessageContentEnumDeprecation = + 'Use reasoningMessageContent (ReasoningMessageContentEvent) instead. ' + 'Mirrors the canonical TypeScript SDK deprecation of ' + 'THINKING_TEXT_MESSAGE_* in favor of REASONING_*. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingTextMessageEndEnumDeprecation = + 'Use reasoningMessageEnd (ReasoningMessageEndEvent) instead. ' + 'Mirrors the canonical TypeScript SDK deprecation of ' + 'THINKING_TEXT_MESSAGE_* in favor of REASONING_*. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingContentEnumDeprecation = + 'Dart-only legacy: never part of the canonical AG-UI protocol ' + '(TypeScript/Python). ' + 'Use reasoningMessageContent (ReasoningMessageContentEvent) instead. ' + 'Scheduled for removal in 1.0.0.'; + /// Enumeration of all AG-UI event types enum EventType { textMessageStart('TEXT_MESSAGE_START'), textMessageContent('TEXT_MESSAGE_CONTENT'), textMessageEnd('TEXT_MESSAGE_END'), textMessageChunk('TEXT_MESSAGE_CHUNK'), + @Deprecated(_kThinkingTextMessageStartEnumDeprecation) thinkingTextMessageStart('THINKING_TEXT_MESSAGE_START'), + @Deprecated(_kThinkingTextMessageContentEnumDeprecation) thinkingTextMessageContent('THINKING_TEXT_MESSAGE_CONTENT'), + @Deprecated(_kThinkingTextMessageEndEnumDeprecation) thinkingTextMessageEnd('THINKING_TEXT_MESSAGE_END'), toolCallStart('TOOL_CALL_START'), toolCallArgs('TOOL_CALL_ARGS'), @@ -16,15 +44,9 @@ enum EventType { toolCallChunk('TOOL_CALL_CHUNK'), toolCallResult('TOOL_CALL_RESULT'), thinkingStart('THINKING_START'), + @Deprecated(_kThinkingContentEnumDeprecation) thinkingContent('THINKING_CONTENT'), thinkingEnd('THINKING_END'), - reasoningStart('REASONING_START'), - reasoningEnd('REASONING_END'), - reasoningMessageStart('REASONING_MESSAGE_START'), - reasoningMessageContent('REASONING_MESSAGE_CONTENT'), - reasoningMessageEnd('REASONING_MESSAGE_END'), - reasoningMessageChunk('REASONING_MESSAGE_CHUNK'), - reasoningEncryptedValue('REASONING_ENCRYPTED_VALUE'), stateSnapshot('STATE_SNAPSHOT'), stateDelta('STATE_DELTA'), messagesSnapshot('MESSAGES_SNAPSHOT'), @@ -36,15 +58,38 @@ enum EventType { runFinished('RUN_FINISHED'), runError('RUN_ERROR'), stepStarted('STEP_STARTED'), - stepFinished('STEP_FINISHED'); + stepFinished('STEP_FINISHED'), + reasoningStart('REASONING_START'), + reasoningMessageStart('REASONING_MESSAGE_START'), + reasoningMessageContent('REASONING_MESSAGE_CONTENT'), + reasoningMessageEnd('REASONING_MESSAGE_END'), + reasoningMessageChunk('REASONING_MESSAGE_CHUNK'), + reasoningEnd('REASONING_END'), + reasoningEncryptedValue('REASONING_ENCRYPTED_VALUE'); final String value; const EventType(this.value); + // Intentionally lazy-init (static final, not const) so it is built once + // on first use rather than at program start, keeping start-up cost O(1). + static final Map _byValue = Map.unmodifiable({ + for (final t in EventType.values) t.value: t, + }); + + /// Parses [value] into an [EventType]. + /// + /// **Contract:** throws [ArgumentError] for unknown values. Do NOT change + /// this to throw any other exception type — `BaseEvent.fromJson` uses a + /// narrow `on ArgumentError` catch to distinguish unknown event types + /// (recoverable: wrap as `AGUIValidationError`) from genuine bugs in the + /// factory body (rethrow). Breaking this contract will silently swallow + /// factory errors or surface them as unknown-type errors. Wire decoding via + /// `BaseEvent.fromJson` ultimately surfaces `AGUIValidationError` as + /// `DecodingError`. Direct callers must catch [ArgumentError] if they want + /// to handle unknown event types gracefully — see + /// `dart-enum-parsing-safety.md` for the throw-vs-fallback rationale. static EventType fromString(String value) { - return EventType.values.firstWhere( - (type) => type.value == value, - orElse: () => throw ArgumentError('Invalid event type: $value'), - ); + return _byValue[value] ?? + (throw ArgumentError('Invalid event type: $value')); } -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/events/events.dart b/sdks/community/dart/lib/src/events/events.dart index 59927fb61b..bb13049f88 100644 --- a/sdks/community/dart/lib/src/events/events.dart +++ b/sdks/community/dart/lib/src/events/events.dart @@ -7,6 +7,8 @@ /// can only be extended within the same library. library; +import 'dart:developer' as developer; + import '../types/base.dart'; import '../types/message.dart'; import '../types/context.dart'; @@ -14,6 +16,68 @@ import 'event_type.dart'; export 'event_type.dart'; +// `kUnsetSentinel` (from `base.dart`) is the shared sentinel for all +// `copyWith` methods in this file. With the default `?? this.field` pattern, +// a caller cannot distinguish "argument omitted" from "argument explicitly set +// to `null`". Comparing against `kUnsetSentinel` with `identical(...)` makes +// that distinction explicit. +// +// **`rawEvent` is intentionally sticky** — all `copyWith` methods use +// `rawEvent ?? this.rawEvent` rather than the sentinel pattern. Passing +// `null` for `rawEvent` keeps the existing value; to clear it, construct +// the event directly with `rawEvent: null`. This is a deliberate design: +// `ReasoningEncryptedValueEvent.fromJson` explicitly sets `rawEvent: null` +// to scrub cipher data, and the sentinel approach would inadvertently +// re-expose a prior non-null value when the caller omits the argument. +// See `BaseEvent.rawEvent` dartdoc for the full consumer note. +// +// Applied to every nullable payload field on the events whose `copyWith` +// callers may legitimately want to clear: +// `ActivitySnapshotEvent.content`, `RawEvent.event`, `CustomEvent.value`, +// `RunFinishedEvent.result`, `RunStartedEvent.parentRunId` / +// `RunStartedEvent.input`, the `name` field of `TextMessageStartEvent`, +// the optional fields of `TextMessageChunkEvent`, +// `ToolCallStartEvent.parentMessageId`, the optional fields of +// `ToolCallChunkEvent`, the optional fields of `ReasoningMessageChunkEvent`, +// `ThinkingStartEvent.title`, `ToolCallResultEvent.role`, +// `StateSnapshotEvent.snapshot`, and `RunErrorEvent.code`. + +/// Reads the `rawEvent` field from a wire payload, accepting both +/// `rawEvent` (TypeScript-canonical) and `raw_event` (Python-canonical). +/// `containsKey` precedence — a present `rawEvent` key wins even when its +/// value is explicitly `null`, matching the documented `requireEitherField` +/// rule for camelCase-vs-snake_case dual reads. Used by every event +/// factory in this library so a Python-emitted `raw_event` survives the +/// proxy round-trip. +dynamic _readRawEvent(Map json) => + json.containsKey('rawEvent') ? json['rawEvent'] : json['raw_event']; + +// Hoisted `@Deprecated` messages: each is repeated on the class +// declaration AND the constructor of the corresponding event type, so a +// constant lets the planned-removal version (1.0.0) and migration target +// get edited in one place per event class. Sibling enum-side messages +// live in `event_type.dart`; the surfaces are intentionally different +// (enum names vs. event class names). +// IMPORTANT: Do NOT add `// ignore_for_file: deprecated_member_use_from_same_package` +// to this file. The per-line `// ignore:` comments below are load-bearing: +// they enumerate every deprecated event type use so the 1.0.0 removal sweep +// knows exactly which lines to delete. A file-level suppression would silence +// the deprecation alarm and make the sweep invisible to the analyzer. +const String _kThinkingTextMessageStartEventDeprecation = + 'Use ReasoningMessageStartEvent instead. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingTextMessageContentEventDeprecation = + 'Use ReasoningMessageContentEvent instead. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingTextMessageEndEventDeprecation = + 'Use ReasoningMessageEndEvent instead. ' + 'Scheduled for removal in 1.0.0.'; +const String _kThinkingContentEventDeprecation = + 'Dart-only legacy: never part of the canonical AG-UI protocol ' + '(TypeScript/Python). ' + 'Use ReasoningMessageContentEvent instead. ' + 'Scheduled for removal in 1.0.0.'; + /// Base event for all AG-UI protocol events. /// /// All protocol events extend this class and are identified by their @@ -22,6 +86,30 @@ export 'event_type.dart'; sealed class BaseEvent extends AGUIModel with TypeDiscriminator { final EventType eventType; final int? timestamp; + + /// The original wire-format payload, preserved verbatim for proxy + /// scenarios. Typed `dynamic` because the protocol does not constrain + /// the shape (TS: `z.unknown()`, Python: `Any`). No validation is + /// performed; the raw value flows through unchanged via every factory + /// (which reads both `rawEvent` and `raw_event` via the private + /// `_readRawEvent` helper, with camelCase precedence) and is + /// re-emitted as-is from `toJson` when non-null. + /// + /// **Consumer note: round-trip emission.** Anything assigned to this + /// field WILL be serialized on the next `encode`. If you don't want + /// the upstream payload echoed downstream, set `rawEvent: null` on + /// the in-flight event before re-encoding by constructing a new event + /// directly with `rawEvent: null`. Wire output uses the camelCase key + /// `rawEvent` regardless of which spelling came in. + /// + /// **Cipher-safety hazard.** Event types that nest [Message] objects with + /// `encryptedValue` payloads (currently [MessagesSnapshotEvent] and + /// [RunStartedEvent]) force `rawEvent` to `null` in their `fromJson` and + /// `copyWith` implementations, because the verbatim wire map would expose + /// the cipher payload that the inner message factories intentionally + /// omitted. Callers building these events in memory (without going through + /// `fromJson`) are responsible for setting `rawEvent: null` when the + /// structured payload contains `encryptedValue` data. final dynamic rawEvent; const BaseEvent({ @@ -33,10 +121,42 @@ sealed class BaseEvent extends AGUIModel with TypeDiscriminator { @override String get type => eventType.value; - /// Factory constructor to create specific event types from JSON + /// Factory constructor to create specific event types from JSON. + /// + /// When you add a case here, also update `EventDecoder.validate` in + /// `lib/src/encoder/decoder.dart` so the analyzer-enforced exhaustive + /// switch on the sealed `BaseEvent` hierarchy continues to compile. + /// + /// **Error surface.** Throws [AGUIValidationError] for: + /// - Missing or wrong-typed `type` field. + /// - Unknown event type string (wrapped from `ArgumentError`). + /// - Any per-event-factory field validation failure (missing required field, + /// wrong type, enum parse error, etc.) — these are thrown directly by the + /// delegate factory and propagate unchanged. + /// + /// Through the [EventDecoder] pipeline all of the above surface as + /// [DecodingError]. Direct callers that bypass [EventDecoder] should catch + /// [AGUIValidationError]. Direct callers should also run + /// `EventDecoder.validate(event)` after this factory if they want + /// non-empty-field enforcement (e.g. non-null `messageId`) — this factory + /// only enforces field presence and type, not semantic constraints. + /// + /// Note on equality: event subtypes are `final class` and do NOT + /// override `==`/`hashCode`. Use field-by-field assertions in tests + /// rather than `expect(a, equals(b))` on whole events. factory BaseEvent.fromJson(Map json) { final typeStr = JsonDecoder.requireField(json, 'type'); - final eventType = EventType.fromString(typeStr); + final EventType eventType; + try { + eventType = EventType.fromString(typeStr); + } on ArgumentError { + throw AGUIValidationError( + message: 'Unknown event type: $typeStr', + field: 'type', + value: typeStr, + json: json, + ); + } switch (eventType) { case EventType.textMessageStart: @@ -47,11 +167,24 @@ sealed class BaseEvent extends AGUIModel with TypeDiscriminator { return TextMessageEndEvent.fromJson(json); case EventType.textMessageChunk: return TextMessageChunkEvent.fromJson(json); + // TODO(1.0.0): Remove the following deprecated cases + their event classes: + // ThinkingTextMessageStartEvent, ThinkingTextMessageContentEvent, + // ThinkingTextMessageEndEvent, ThinkingContentEvent. + // Also remove EventType.thinkingTextMessage* / thinkingContent enum + // values, the _kThinkingTextMessage*Deprecation / _kThinkingContent* + // Deprecation constants, and the deprecated TimeoutError typedef in + // client/errors.dart. + // ignore: deprecated_member_use_from_same_package case EventType.thinkingTextMessageStart: + // ignore: deprecated_member_use_from_same_package return ThinkingTextMessageStartEvent.fromJson(json); + // ignore: deprecated_member_use_from_same_package case EventType.thinkingTextMessageContent: + // ignore: deprecated_member_use_from_same_package return ThinkingTextMessageContentEvent.fromJson(json); + // ignore: deprecated_member_use_from_same_package case EventType.thinkingTextMessageEnd: + // ignore: deprecated_member_use_from_same_package return ThinkingTextMessageEndEvent.fromJson(json); case EventType.toolCallStart: return ToolCallStartEvent.fromJson(json); @@ -65,24 +198,12 @@ sealed class BaseEvent extends AGUIModel with TypeDiscriminator { return ToolCallResultEvent.fromJson(json); case EventType.thinkingStart: return ThinkingStartEvent.fromJson(json); + // ignore: deprecated_member_use_from_same_package case EventType.thinkingContent: + // ignore: deprecated_member_use_from_same_package return ThinkingContentEvent.fromJson(json); case EventType.thinkingEnd: return ThinkingEndEvent.fromJson(json); - case EventType.reasoningStart: - return ReasoningStartEvent.fromJson(json); - case EventType.reasoningEnd: - return ReasoningEndEvent.fromJson(json); - case EventType.reasoningMessageStart: - return ReasoningMessageStartEvent.fromJson(json); - case EventType.reasoningMessageContent: - return ReasoningMessageContentEvent.fromJson(json); - case EventType.reasoningMessageEnd: - return ReasoningMessageEndEvent.fromJson(json); - case EventType.reasoningMessageChunk: - return ReasoningMessageChunkEvent.fromJson(json); - case EventType.reasoningEncryptedValue: - return ReasoningEncryptedValueEvent.fromJson(json); case EventType.stateSnapshot: return StateSnapshotEvent.fromJson(json); case EventType.stateDelta: @@ -107,20 +228,47 @@ sealed class BaseEvent extends AGUIModel with TypeDiscriminator { return StepStartedEvent.fromJson(json); case EventType.stepFinished: return StepFinishedEvent.fromJson(json); + case EventType.reasoningStart: + return ReasoningStartEvent.fromJson(json); + case EventType.reasoningMessageStart: + return ReasoningMessageStartEvent.fromJson(json); + case EventType.reasoningMessageContent: + return ReasoningMessageContentEvent.fromJson(json); + case EventType.reasoningMessageEnd: + return ReasoningMessageEndEvent.fromJson(json); + case EventType.reasoningMessageChunk: + return ReasoningMessageChunkEvent.fromJson(json); + case EventType.reasoningEnd: + return ReasoningEndEvent.fromJson(json); + case EventType.reasoningEncryptedValue: + return ReasoningEncryptedValueEvent.fromJson(json); + // No `default` clause — exhaustive switch on the [EventType] enum + // (analyzer-enforced). A new EventType value will produce a compile + // error here AND in `EventDecoder.validate`, which is the desired + // outcome rather than a runtime fall-through. } } @override Map toJson() => { - 'type': eventType.value, - if (timestamp != null) 'timestamp': timestamp, - if (rawEvent != null) 'rawEvent': rawEvent, - }; + 'type': eventType.value, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + }; } /// Text message roles that can be used in text message events. /// -/// Defines the possible roles for text messages in the protocol. +/// **Role-fallback convention.** Wire-decoding factories that reference this +/// enum follow a consistent pattern: the enum's `fromString` throws +/// [ArgumentError] for unknown values, and the factory that calls it catches +/// the error and falls back to the canonical role for that event type (e.g. +/// `assistant` for [TextMessageStartEvent], `tool` for +/// [ToolCallResultEvent], `reasoning` for [ReasoningMessageStartEvent]). +/// The one exception is [TextMessageChunkEvent], where `role` is nullable — +/// it falls back to `null` because "present but unrecognized" is distinct +/// from "absent". If you add a new role value here or a new event type that +/// references this enum, update the corresponding factory fall-back as well. enum TextMessageRole { developer('developer'), system('system'), @@ -130,11 +278,22 @@ enum TextMessageRole { final String value; const TextMessageRole(this.value); + /// Parses [value] into a [TextMessageRole]. + /// + /// Throws [ArgumentError] for unknown values. Callers decoding from the + /// wire should use `TextMessageStartEvent.fromJson`, which absorbs the + /// throw and falls back to [TextMessageRole.assistant] so a future + /// server-side role does not tear down the SSE stream. This is the + /// same "throw at the enum, absorb at the factory" pattern used by + /// [ReasoningMessageRole] — see `dart-enum-parsing-safety.md` for the + /// consistency rationale. + static final Map _byValue = Map.unmodifiable({ + for (final r in TextMessageRole.values) r.value: r, + }); + static TextMessageRole fromString(String value) { - return TextMessageRole.values.firstWhere( - (role) => role.value == value, - orElse: () => TextMessageRole.assistant, - ); + return _byValue[value] ?? + (throw ArgumentError('Invalid text message role: $value')); } } @@ -146,42 +305,72 @@ enum TextMessageRole { final class TextMessageStartEvent extends BaseEvent { final String messageId; final TextMessageRole role; + final String? name; const TextMessageStartEvent({ required this.messageId, this.role = TextMessageRole.assistant, + this.name, super.timestamp, super.rawEvent, }) : super(eventType: EventType.textMessageStart); factory TextMessageStartEvent.fromJson(Map json) { + final messageId = JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ); + final roleStr = JsonDecoder.optionalField(json, 'role'); + var role = TextMessageRole.assistant; + if (roleStr != null) { + try { + role = TextMessageRole.fromString(roleStr); + } on ArgumentError { + // Forward-compat: an unknown wire role falls back to + // `assistant` to keep the stream alive. + // + // We intentionally do NOT broaden to `catch (e)` or + // `on Exception`: a wrong-typed `role` raises + // `AGUIValidationError` from `optionalField` above, and + // a missing `messageId` raises `AGUIValidationError` from + // `requireEitherField` — those MUST propagate to the decoder + // boundary as protocol violations. Widening the catch would + // silently absorb them. Mirrors + // `ReasoningMessageStartEvent.fromJson`. + role = TextMessageRole.assistant; + } + } return TextMessageStartEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - role: TextMessageRole.fromString( - JsonDecoder.optionalField(json, 'role') ?? 'assistant', - ), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + messageId: messageId, + role: role, + name: JsonDecoder.optionalField(json, 'name'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - 'role': role.value, - }; + ...super.toJson(), + 'messageId': messageId, + 'role': role.value, + if (name != null) 'name': name, + }; + // See `_Unset` (top of file) for the sentinel rationale. @override TextMessageStartEvent copyWith({ String? messageId, TextMessageRole? role, + Object? name = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return TextMessageStartEvent( messageId: messageId ?? this.messageId, role: role ?? this.role, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -201,30 +390,34 @@ final class TextMessageContentEvent extends BaseEvent { }) : super(eventType: EventType.textMessageContent); factory TextMessageContentEvent.fromJson(Map json) { + // Validate the cheap required identifier FIRST so a missing-id error + // surfaces before any payload-validation work — same convention as + // `ReasoningMessageStartEvent.fromJson`. + final messageId = JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ); + // Empty `delta` is accepted to match canonical TS/Python schemas + // (`TextMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str`). Servers may legitimately emit empty + // chunks (e.g. a noop content refresh). final delta = JsonDecoder.requireField(json, 'delta'); - if (delta.isEmpty) { - throw AGUIValidationError( - message: 'Delta must not be an empty string', - field: 'delta', - value: delta, - json: json, - ); - } - + return TextMessageContentEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), + messageId: messageId, delta: delta, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - 'delta': delta, - }; + ...super.toJson(), + 'messageId': messageId, + 'delta': delta, + }; @override TextMessageContentEvent copyWith({ @@ -254,17 +447,21 @@ final class TextMessageEndEvent extends BaseEvent { factory TextMessageEndEvent.fromJson(Map json) { return TextMessageEndEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - }; + ...super.toJson(), + 'messageId': messageId, + }; @override TextMessageEndEvent copyWith({ @@ -285,46 +482,73 @@ final class TextMessageChunkEvent extends BaseEvent { final String? messageId; final TextMessageRole? role; final String? delta; + final String? name; const TextMessageChunkEvent({ this.messageId, this.role, this.delta, + this.name, super.timestamp, super.rawEvent, }) : super(eventType: EventType.textMessageChunk); factory TextMessageChunkEvent.fromJson(Map json) { final roleStr = JsonDecoder.optionalField(json, 'role'); + TextMessageRole? role; + if (roleStr != null) { + try { + role = TextMessageRole.fromString(roleStr); + } on ArgumentError { + // Forward-compat: unknown wire role falls back to null. + // Unlike TextMessageStartEvent (required role → assistant default), + // role here is nullable/optional — null is the correct sentinel for + // "value was present on the wire but unrecognized." + role = null; + } + } return TextMessageChunkEvent( - messageId: JsonDecoder.optionalField(json, 'messageId'), - role: roleStr != null ? TextMessageRole.fromString(roleStr) : null, + messageId: JsonDecoder.optionalEitherField( + json, + 'messageId', + 'message_id', + ), + role: role, delta: JsonDecoder.optionalField(json, 'delta'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + name: JsonDecoder.optionalField(json, 'name'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - if (messageId != null) 'messageId': messageId, - if (role != null) 'role': role!.value, - if (delta != null) 'delta': delta, - }; + ...super.toJson(), + if (messageId != null) 'messageId': messageId, + if (role != null) 'role': role!.value, + if (delta != null) 'delta': delta, + if (name != null) 'name': name, + }; + // See `_Unset` (top of file) for the sentinel rationale. @override TextMessageChunkEvent copyWith({ - String? messageId, - TextMessageRole? role, - String? delta, + Object? messageId = kUnsetSentinel, + Object? role = kUnsetSentinel, + Object? delta = kUnsetSentinel, + Object? name = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return TextMessageChunkEvent( - messageId: messageId ?? this.messageId, - role: role ?? this.role, - delta: delta ?? this.delta, + messageId: identical(messageId, kUnsetSentinel) + ? this.messageId + : messageId as String?, + role: identical(role, kUnsetSentinel) + ? this.role + : role as TextMessageRole?, + delta: identical(delta, kUnsetSentinel) ? this.delta : delta as String?, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); @@ -348,35 +572,41 @@ final class ThinkingStartEvent extends BaseEvent { factory ThinkingStartEvent.fromJson(Map json) { return ThinkingStartEvent( title: JsonDecoder.optionalField(json, 'title'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - if (title != null) 'title': title, - }; + ...super.toJson(), + if (title != null) 'title': title, + }; @override ThinkingStartEvent copyWith({ - String? title, + Object? title = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { return ThinkingStartEvent( - title: title ?? this.title, + title: identical(title, kUnsetSentinel) ? this.title : title as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing thinking content +/// Event containing thinking content. +/// +/// Dart-only legacy: never part of the canonical AG-UI protocol +/// (TypeScript/Python). Included only for backward compatibility with +/// pre-0.2.0 Dart consumers. Use [ThinkingTextMessageContentEvent] instead. +@Deprecated(_kThinkingContentEventDeprecation) final class ThinkingContentEvent extends BaseEvent { final String delta; + @Deprecated(_kThinkingContentEventDeprecation) const ThinkingContentEvent({ required this.delta, super.timestamp, @@ -384,28 +614,21 @@ final class ThinkingContentEvent extends BaseEvent { }) : super(eventType: EventType.thinkingContent); factory ThinkingContentEvent.fromJson(Map json) { + // Empty `delta` is accepted to match the relaxed canonical contract + // (`z.string()` / `delta: str`). Migrate to [ReasoningMessageContentEvent]. final delta = JsonDecoder.requireField(json, 'delta'); - if (delta.isEmpty) { - throw AGUIValidationError( - message: 'Delta must not be an empty string', - field: 'delta', - value: delta, - json: json, - ); - } - return ThinkingContentEvent( delta: delta, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'delta': delta, - }; + ...super.toJson(), + 'delta': delta, + }; @override ThinkingContentEvent copyWith({ @@ -430,8 +653,8 @@ final class ThinkingEndEvent extends BaseEvent { factory ThinkingEndEvent.fromJson(Map json) { return ThinkingEndEvent( - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -447,17 +670,25 @@ final class ThinkingEndEvent extends BaseEvent { } } -/// Event indicating the start of a thinking text message +/// Event indicating the start of a thinking text message. +/// +/// Deprecated in favor of [ReasoningMessageStartEvent], mirroring the +/// canonical TypeScript SDK deprecation of `THINKING_TEXT_MESSAGE_*` in +/// favor of `REASONING_*`. Decoding remains supported for backward +/// compatibility; scheduled for removal in 1.0.0. +@Deprecated(_kThinkingTextMessageStartEventDeprecation) final class ThinkingTextMessageStartEvent extends BaseEvent { + @Deprecated(_kThinkingTextMessageStartEventDeprecation) const ThinkingTextMessageStartEvent({ super.timestamp, super.rawEvent, + // ignore: deprecated_member_use_from_same_package }) : super(eventType: EventType.thinkingTextMessageStart); factory ThinkingTextMessageStartEvent.fromJson(Map json) { return ThinkingTextMessageStartEvent( - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -473,39 +704,40 @@ final class ThinkingTextMessageStartEvent extends BaseEvent { } } -/// Event containing thinking text message content +/// Event containing thinking text message content. +/// +/// Deprecated in favor of [ReasoningMessageContentEvent], mirroring the +/// canonical TypeScript SDK deprecation of `THINKING_TEXT_MESSAGE_*` in +/// favor of `REASONING_*`. Decoding remains supported for backward +/// compatibility; scheduled for removal in 1.0.0. +@Deprecated(_kThinkingTextMessageContentEventDeprecation) final class ThinkingTextMessageContentEvent extends BaseEvent { final String delta; + @Deprecated(_kThinkingTextMessageContentEventDeprecation) const ThinkingTextMessageContentEvent({ required this.delta, super.timestamp, super.rawEvent, + // ignore: deprecated_member_use_from_same_package }) : super(eventType: EventType.thinkingTextMessageContent); factory ThinkingTextMessageContentEvent.fromJson(Map json) { + // No identifier on this event. Empty `delta` is accepted to match the + // relaxed canonical contract (`z.string()` / `delta: str`). final delta = JsonDecoder.requireField(json, 'delta'); - if (delta.isEmpty) { - throw AGUIValidationError( - message: 'Delta must not be an empty string', - field: 'delta', - value: delta, - json: json, - ); - } - return ThinkingTextMessageContentEvent( delta: delta, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'delta': delta, - }; + ...super.toJson(), + 'delta': delta, + }; @override ThinkingTextMessageContentEvent copyWith({ @@ -521,17 +753,25 @@ final class ThinkingTextMessageContentEvent extends BaseEvent { } } -/// Event indicating the end of a thinking text message +/// Event indicating the end of a thinking text message. +/// +/// Deprecated in favor of [ReasoningMessageEndEvent], mirroring the +/// canonical TypeScript SDK deprecation of `THINKING_TEXT_MESSAGE_*` in +/// favor of `REASONING_*`. Decoding remains supported for backward +/// compatibility; scheduled for removal in 1.0.0. +@Deprecated(_kThinkingTextMessageEndEventDeprecation) final class ThinkingTextMessageEndEvent extends BaseEvent { + @Deprecated(_kThinkingTextMessageEndEventDeprecation) const ThinkingTextMessageEndEvent({ super.timestamp, super.rawEvent, + // ignore: deprecated_member_use_from_same_package }) : super(eventType: EventType.thinkingTextMessageEnd); factory ThinkingTextMessageEndEvent.fromJson(Map json) { return ThinkingTextMessageEndEvent( - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @@ -548,1132 +788,1856 @@ final class ThinkingTextMessageEndEvent extends BaseEvent { } // ============================================================================ -// Reasoning Events +// Tool Call Events // ============================================================================ -/// Event indicating the start of a reasoning section. -final class ReasoningStartEvent extends BaseEvent { - final String messageId; +/// Event indicating the start of a tool call +final class ToolCallStartEvent extends BaseEvent { + final String toolCallId; + final String toolCallName; + final String? parentMessageId; - const ReasoningStartEvent({ - required this.messageId, + const ToolCallStartEvent({ + required this.toolCallId, + required this.toolCallName, + this.parentMessageId, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.reasoningStart); + }) : super(eventType: EventType.toolCallStart); - factory ReasoningStartEvent.fromJson(Map json) { - return ReasoningStartEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ToolCallStartEvent.fromJson(Map json) { + return ToolCallStartEvent( + toolCallId: JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), + toolCallName: JsonDecoder.requireEitherField( + json, + 'toolCallName', + 'tool_call_name', + ), + parentMessageId: JsonDecoder.optionalEitherField( + json, + 'parentMessageId', + 'parent_message_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - }; + ...super.toJson(), + 'toolCallId': toolCallId, + 'toolCallName': toolCallName, + if (parentMessageId != null) 'parentMessageId': parentMessageId, + }; + // See `_Unset` (top of file) for the sentinel rationale. @override - ReasoningStartEvent copyWith({ - String? messageId, + ToolCallStartEvent copyWith({ + String? toolCallId, + String? toolCallName, + Object? parentMessageId = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { - return ReasoningStartEvent( - messageId: messageId ?? this.messageId, + return ToolCallStartEvent( + toolCallId: toolCallId ?? this.toolCallId, + toolCallName: toolCallName ?? this.toolCallName, + parentMessageId: identical(parentMessageId, kUnsetSentinel) + ? this.parentMessageId + : parentMessageId as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event indicating the end of a reasoning section. -final class ReasoningEndEvent extends BaseEvent { - final String messageId; +/// Event containing tool call arguments +final class ToolCallArgsEvent extends BaseEvent { + final String toolCallId; + final String delta; - const ReasoningEndEvent({ - required this.messageId, + const ToolCallArgsEvent({ + required this.toolCallId, + required this.delta, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.reasoningEnd); + }) : super(eventType: EventType.toolCallArgs); - factory ReasoningEndEvent.fromJson(Map json) { - return ReasoningEndEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ToolCallArgsEvent.fromJson(Map json) { + final toolCallId = JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ); + // Empty `delta` is accepted to match canonical TS/Python schemas + // (`ToolCallArgsEventSchema.delta: z.string()` / pydantic `delta: str`). + final delta = JsonDecoder.requireField(json, 'delta'); + return ToolCallArgsEvent( + toolCallId: toolCallId, + delta: delta, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - }; + ...super.toJson(), + 'toolCallId': toolCallId, + 'delta': delta, + }; @override - ReasoningEndEvent copyWith({ - String? messageId, + ToolCallArgsEvent copyWith({ + String? toolCallId, + String? delta, int? timestamp, dynamic rawEvent, }) { - return ReasoningEndEvent( - messageId: messageId ?? this.messageId, + return ToolCallArgsEvent( + toolCallId: toolCallId ?? this.toolCallId, + delta: delta ?? this.delta, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event indicating the start of a reasoning message. -final class ReasoningMessageStartEvent extends BaseEvent { - final String messageId; +/// Event indicating the end of a tool call +final class ToolCallEndEvent extends BaseEvent { + final String toolCallId; - const ReasoningMessageStartEvent({ - required this.messageId, + const ToolCallEndEvent({ + required this.toolCallId, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.reasoningMessageStart); + }) : super(eventType: EventType.toolCallEnd); - factory ReasoningMessageStartEvent.fromJson(Map json) { - return ReasoningMessageStartEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ToolCallEndEvent.fromJson(Map json) { + return ToolCallEndEvent( + toolCallId: JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - 'role': 'reasoning', - }; + ...super.toJson(), + 'toolCallId': toolCallId, + }; @override - ReasoningMessageStartEvent copyWith({ - String? messageId, + ToolCallEndEvent copyWith({ + String? toolCallId, int? timestamp, dynamic rawEvent, }) { - return ReasoningMessageStartEvent( - messageId: messageId ?? this.messageId, + return ToolCallEndEvent( + toolCallId: toolCallId ?? this.toolCallId, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event carrying a delta of reasoning message content. -final class ReasoningMessageContentEvent extends BaseEvent { - final String messageId; - final String delta; +/// Event containing a chunk of tool call content +final class ToolCallChunkEvent extends BaseEvent { + final String? toolCallId; + final String? toolCallName; + final String? parentMessageId; + final String? delta; - const ReasoningMessageContentEvent({ - required this.messageId, - required this.delta, + const ToolCallChunkEvent({ + this.toolCallId, + this.toolCallName, + this.parentMessageId, + this.delta, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.reasoningMessageContent); - - factory ReasoningMessageContentEvent.fromJson(Map json) { - final delta = JsonDecoder.requireField(json, 'delta'); - if (delta.isEmpty) { - throw AGUIValidationError( - message: 'Delta must not be an empty string', - field: 'delta', - value: delta, - json: json, - ); - } + }) : super(eventType: EventType.toolCallChunk); - return ReasoningMessageContentEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - delta: delta, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ToolCallChunkEvent.fromJson(Map json) { + return ToolCallChunkEvent( + toolCallId: JsonDecoder.optionalEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), + toolCallName: JsonDecoder.optionalEitherField( + json, + 'toolCallName', + 'tool_call_name', + ), + parentMessageId: JsonDecoder.optionalEitherField( + json, + 'parentMessageId', + 'parent_message_id', + ), + delta: JsonDecoder.optionalField(json, 'delta'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - 'delta': delta, - }; + ...super.toJson(), + if (toolCallId != null) 'toolCallId': toolCallId, + if (toolCallName != null) 'toolCallName': toolCallName, + if (parentMessageId != null) 'parentMessageId': parentMessageId, + if (delta != null) 'delta': delta, + }; + // See `_Unset` (top of file) for the sentinel rationale. @override - ReasoningMessageContentEvent copyWith({ - String? messageId, - String? delta, + ToolCallChunkEvent copyWith({ + Object? toolCallId = kUnsetSentinel, + Object? toolCallName = kUnsetSentinel, + Object? parentMessageId = kUnsetSentinel, + Object? delta = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { - return ReasoningMessageContentEvent( - messageId: messageId ?? this.messageId, - delta: delta ?? this.delta, + return ToolCallChunkEvent( + toolCallId: identical(toolCallId, kUnsetSentinel) + ? this.toolCallId + : toolCallId as String?, + toolCallName: identical(toolCallName, kUnsetSentinel) + ? this.toolCallName + : toolCallName as String?, + parentMessageId: identical(parentMessageId, kUnsetSentinel) + ? this.parentMessageId + : parentMessageId as String?, + delta: identical(delta, kUnsetSentinel) ? this.delta : delta as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event indicating the end of a reasoning message. -final class ReasoningMessageEndEvent extends BaseEvent { +/// Role for tool-call result messages (aligned with the AG-UI protocol). +/// +/// Currently a single-variant enum mirroring the canonical +/// `Literal["tool"]` (Python) / `z.literal("tool")` (TypeScript). Modeled +/// as an enum so a future role addition can land without churning every +/// call site, and so producers cannot accidentally emit a free-form +/// string like `'developer'` on a `TOOL_CALL_RESULT` event. +enum ToolCallResultRole { + tool('tool'); + + final String value; + const ToolCallResultRole(this.value); + + /// Parses [value] into a [ToolCallResultRole]. + /// + /// Throws [ArgumentError] for unknown values. Callers decoding from the + /// wire should use `ToolCallResultEvent.fromJson`, which absorbs the + /// throw and falls back to [ToolCallResultRole.tool] so a future + /// server-side role does not tear down the SSE stream. Mirrors + /// `ReasoningMessageRole.fromString` and `TextMessageRole.fromString`. + static final Map _byValue = Map.unmodifiable({ + for (final r in ToolCallResultRole.values) r.value: r, + }); + + static ToolCallResultRole fromString(String value) { + return _byValue[value] ?? + (throw ArgumentError('Invalid tool call result role: $value')); + } +} + +/// Event containing the result of a tool call +final class ToolCallResultEvent extends BaseEvent { final String messageId; + final String toolCallId; + final String content; - const ReasoningMessageEndEvent({ + /// Optional role discriminator for the tool-call result. + /// + /// `copyWith(role: null)` clears this field via the [kUnsetSentinel] + /// pattern — same as every other nullable field on this event. + final ToolCallResultRole? role; + + const ToolCallResultEvent({ required this.messageId, + required this.toolCallId, + required this.content, + this.role, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.reasoningMessageEnd); + }) : super(eventType: EventType.toolCallResult); - factory ReasoningMessageEndEvent.fromJson(Map json) { - return ReasoningMessageEndEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ToolCallResultEvent.fromJson(Map json) { + final roleStr = JsonDecoder.optionalField(json, 'role'); + ToolCallResultRole? role; + if (roleStr != null) { + try { + role = ToolCallResultRole.fromString(roleStr); + } on ArgumentError { + // Forward-compat: an unknown wire role falls back to `tool` so a + // future server-side role does not tear down the SSE stream. + // Mirrors `TextMessageStartEvent.fromJson` / + // `ReasoningMessageStartEvent.fromJson`. Narrow `on ArgumentError` + // (not `catch (e)`) preserves propagation of `AGUIValidationError` + // raised by `optionalField` for a wrong-typed `role`. + role = ToolCallResultRole.tool; + } + } + return ToolCallResultEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + toolCallId: JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), + content: JsonDecoder.requireField(json, 'content'), + role: role, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - }; + ...super.toJson(), + 'messageId': messageId, + 'toolCallId': toolCallId, + 'content': content, + if (role != null) 'role': role!.value, + }; @override - ReasoningMessageEndEvent copyWith({ + ToolCallResultEvent copyWith({ String? messageId, + String? toolCallId, + String? content, + Object? role = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { - return ReasoningMessageEndEvent( + return ToolCallResultEvent( messageId: messageId ?? this.messageId, + toolCallId: toolCallId ?? this.toolCallId, + content: content ?? this.content, + role: identical(role, kUnsetSentinel) + ? this.role + : role as ToolCallResultRole?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event carrying a chunk of reasoning message content (optional fields). -final class ReasoningMessageChunkEvent extends BaseEvent { - final String? messageId; - final String? delta; - - const ReasoningMessageChunkEvent({ - this.messageId, - this.delta, +// ============================================================================ +// State Events +// ============================================================================ + +/// Event containing a snapshot of the state +final class StateSnapshotEvent extends BaseEvent { + /// The state snapshot. Type [State] permits any JSON shape including + /// `null` (an empty / cleared state is a valid wire payload — see the + /// matching note on [StateSnapshotEvent.fromJson]). + final State snapshot; + + const StateSnapshotEvent({ + required this.snapshot, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.reasoningMessageChunk); + }) : super(eventType: EventType.stateSnapshot); - factory ReasoningMessageChunkEvent.fromJson(Map json) { - return ReasoningMessageChunkEvent( - messageId: JsonDecoder.optionalField(json, 'messageId'), - delta: JsonDecoder.optionalField(json, 'delta'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory StateSnapshotEvent.fromJson(Map json) { + // `snapshot` may be any JSON shape (including `null` for an empty + // state), so we cannot use `requireField` (which rejects null + // values). The field MUST be present though — its absence is a + // protocol violation, not "the snapshot is empty". Distinguishing + // missing-key from explicit-null is the whole point of this check. + if (!json.containsKey('snapshot')) { + throw AGUIValidationError( + message: 'Missing required field', + field: 'snapshot', + json: json, + ); + } + return StateSnapshotEvent( + snapshot: json['snapshot'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - if (messageId != null) 'messageId': messageId, - if (delta != null) 'delta': delta, - }; + ...super.toJson(), + 'snapshot': snapshot, + }; @override - ReasoningMessageChunkEvent copyWith({ - String? messageId, - String? delta, + StateSnapshotEvent copyWith({ + Object? snapshot = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { - return ReasoningMessageChunkEvent( - messageId: messageId ?? this.messageId, - delta: delta ?? this.delta, + return StateSnapshotEvent( + snapshot: identical(snapshot, kUnsetSentinel) ? this.snapshot : snapshot, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Subtype discriminator for [ReasoningEncryptedValueEvent]. -enum ReasoningEncryptedValueSubtype { - toolCall('tool-call'), - message('message'); +/// Event containing a delta of the state (JSON Patch RFC 6902) +final class StateDeltaEvent extends BaseEvent { + // RFC 6902 patch operations are always JSON objects ({op, path, …}). + // Using List> (via requireListField) surfaces + // non-object elements as AGUIValidationError at the decoder boundary + // instead of leaking a downstream TypeError at the first op['op'] access. + final List> delta; - final String value; - const ReasoningEncryptedValueSubtype(this.value); + const StateDeltaEvent({ + required this.delta, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.stateDelta); - static ReasoningEncryptedValueSubtype fromString(String value) { - for (final subtype in ReasoningEncryptedValueSubtype.values) { - if (subtype.value == value) { - return subtype; - } - } - throw AGUIValidationError( - message: 'Invalid ReasoningEncryptedValueSubtype: $value', - field: 'subtype', - value: value, + factory StateDeltaEvent.fromJson(Map json) { + return StateDeltaEvent( + delta: JsonDecoder.requireListField>(json, 'delta'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'delta': delta, + }; + + @override + StateDeltaEvent copyWith({ + List>? delta, + int? timestamp, + dynamic rawEvent, + }) { + return StateDeltaEvent( + delta: delta ?? this.delta, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event carrying an encrypted reasoning value for a tool-call or message. -final class ReasoningEncryptedValueEvent extends BaseEvent { - final ReasoningEncryptedValueSubtype subtype; - final String entityId; - final String encryptedValue; +/// Event containing a snapshot of messages +/// +/// **Sensitive-data warning.** [rawEvent] is automatically cleared (set to +/// `null`) when ANY inner message carries cipher data. This includes every +/// [BaseMessage] subtype ([ReasoningMessage], [AssistantMessage], +/// [ToolMessage], [SystemMessage], [DeveloperMessage], [UserMessage]) with a +/// non-null [encryptedValue], AND any `role: 'activity'` entry whose wire form +/// carries an `encryptedValue` / `encrypted_value` key (which +/// [ActivityMessage.fromJson] silently strips from the structured field). +/// This prevents the verbatim wire map — which may include cipher data — from +/// leaking through [BaseEvent.rawEvent] to log sinks or reflection-based +/// serializers. Proxy operators that need the verbatim wire form should keep +/// their own copy of the raw JSON before calling [fromJson]. +/// See [ReasoningEncryptedValueEvent.fromJson] for the same pattern on +/// individual cipher events. +final class MessagesSnapshotEvent extends BaseEvent { + final List messages; - const ReasoningEncryptedValueEvent({ - required this.subtype, - required this.entityId, - required this.encryptedValue, + MessagesSnapshotEvent({ + required this.messages, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.reasoningEncryptedValue); + }) : super(eventType: EventType.messagesSnapshot) { + // Direct-construction caveat: this guard only inspects the structured + // Message.encryptedValue field. A caller that already has a wire-form + // rawEvent map whose payload contains an encryptedValue key on an + // activity-role entry can still violate the cipher-scrub invariant — + // ActivityMessage.encryptedValue is always null by construction, so it + // cannot be detected here. Pass rawEvent: null or pre-scrub the map before + // invoking this constructor for activity-role cipher data. fromJson enforces + // both code paths; this constructor enforces only the structured-field one. + if (rawEvent != null && messages.any((m) => m.encryptedValue != null)) { + throw AGUIValidationError( + message: 'Direct construction with rawEvent + cipher-bearing messages ' + 'violates the scrub invariant. Pass rawEvent: null or pre-scrub.', + field: 'rawEvent', + ); + } + } - factory ReasoningEncryptedValueEvent.fromJson(Map json) { - return ReasoningEncryptedValueEvent( - subtype: ReasoningEncryptedValueSubtype.fromString( - JsonDecoder.requireField(json, 'subtype'), - ), - entityId: JsonDecoder.requireField(json, 'entityId'), - encryptedValue: - JsonDecoder.requireField(json, 'encryptedValue'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory MessagesSnapshotEvent.fromJson(Map json) { + final rawMessages = JsonDecoder.requireListField>( + json, + 'messages', + ); + final messages = []; + for (var i = 0; i < rawMessages.length; i++) { + try { + messages.add(Message.fromJson(rawMessages[i])); + } catch (e) { + if (e is AGUIValidationError) { + // Always drop json: — the inner Message map can carry encryptedValue + // for Tool/Reasoning subtypes. Preserve cause: only when the inner + // error already cleared its own json: field (e.json == null), which + // indicates the inner factory was cipher-aware and the cause chain + // does not expose raw wire data. Non-cipher messages (Developer, + // System, User) typically produce errors with e.json == null, so + // their cause is preserved for ergonomic debugging. + throw AGUIValidationError( + message: e.message, + field: e.field != null ? 'messages[$i].${e.field}' : 'messages[$i]', + value: e.value, + cause: e.json == null ? e : null, + ); + } + throw AGUIValidationError( + message: 'Failed to decode message at index $i: $e', + field: 'messages[$i]', + cause: e, + ); + } + } + // Auto-scrub rawEvent when any inner message carries cipher data. Storing + // the verbatim wire map in rawEvent would undo the cipher scrubbing that + // the ReasoningMessage factory already applied to the structured field. + // Proxies that need the verbatim wire form should keep their own copy of + // the raw JSON before calling fromJson. + // + // ActivityMessage.fromJson silently strips wire-level encryptedValue from + // the structured field (the constructor does not accept it), so the + // structured-field predicate alone would miss a cipher on an + // ActivityMessage. We check rawMessages directly for role == 'activity' + // entries that still carry a cipher key on the wire. + // + // SCRUB CONTRACT: this check assumes encryptedValue / encrypted_value is + // the only cipher-named key on any Message subtype. If a future subtype + // adds a different sensitive payload key, this hasCipher predicate MUST be + // extended in parallel. + final hasCipher = messages.any((m) => m.encryptedValue != null) || + rawMessages.any((m) => + m['role'] == 'activity' && + (m.containsKey('encryptedValue') || + m.containsKey('encrypted_value'))); + return MessagesSnapshotEvent( + messages: messages, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: hasCipher ? null : _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'subtype': subtype.value, - 'entityId': entityId, - 'encryptedValue': encryptedValue, - }; - + ...super.toJson(), + 'messages': messages.map((m) => m.toJson()).toList(), + }; + + /// Creates a copy of this event with the given fields replaced. + /// + /// **Cipher-safety note.** When the resolved messages list does NOT carry + /// cipher data (`encryptedValue == null` for every message), [rawEvent] is + /// applied normally — kept, cleared, or replaced per the standard sentinel + /// semantics. When ANY resolved message carries cipher data + /// (`encryptedValue != null`), [rawEvent] is silently forced to `null` + /// regardless of the supplied value. This mirrors the `fromJson` invariant + /// that prevents the raw wire map (which contains the cipher payload) from + /// leaking through `rawEvent`. Callers that have already scrubbed + /// `encryptedValue` and need to retain a sanitized `rawEvent` map should + /// construct a new [MessagesSnapshotEvent] directly with + /// `rawEvent: scrubbedMap` rather than using `copyWith`. @override - ReasoningEncryptedValueEvent copyWith({ - ReasoningEncryptedValueSubtype? subtype, - String? entityId, - String? encryptedValue, + MessagesSnapshotEvent copyWith({ + List? messages, int? timestamp, - dynamic rawEvent, + Object? rawEvent = kUnsetSentinel, }) { - return ReasoningEncryptedValueEvent( - subtype: subtype ?? this.subtype, - entityId: entityId ?? this.entityId, - encryptedValue: encryptedValue ?? this.encryptedValue, + final newMessages = messages ?? this.messages; + // Re-apply the fromJson cipher-scrub invariant: if any message in the + // (possibly updated) list carries cipher data, force rawEvent to null so + // the wire map cannot be reattached and expose encrypted content. + // ActivityMessage always returns null for encryptedValue by construction; + // see SCRUB CONTRACT comment in fromJson. + final hasCipher = newMessages.any((m) => m.encryptedValue != null); + // Log in all builds (including release) when a caller passes a non-null + // rawEvent that will be silently scrubbed. The force-to-null below is + // the authoritative safety measure; the log helps callers diagnose + // unexpected scrub in production without crashing. + if (hasCipher && + !identical(rawEvent, kUnsetSentinel) && + rawEvent != null) { + developer.log( + 'MessagesSnapshotEvent.copyWith: rawEvent is silently forced to null ' + 'when any message carries encryptedValue. Construct directly if you ' + 'need to retain a sanitized rawEvent.', + name: 'ag_ui.cipher_scrub', + level: 900, // WARNING + ); + } + final dynamic resolvedRaw; + if (hasCipher) { + resolvedRaw = null; + } else if (identical(rawEvent, kUnsetSentinel)) { + resolvedRaw = this.rawEvent; + } else { + resolvedRaw = rawEvent; + } + return MessagesSnapshotEvent( + messages: newMessages, timestamp: timestamp ?? this.timestamp, - rawEvent: rawEvent ?? this.rawEvent, + rawEvent: resolvedRaw, ); } } // ============================================================================ -// Tool Call Events +// Activity Events // ============================================================================ -/// Event indicating the start of a tool call -final class ToolCallStartEvent extends BaseEvent { - final String toolCallId; - final String toolCallName; - final String? parentMessageId; +/// Event containing a snapshot of an activity message. +/// +/// Note: [content] is typed `Object?` rather than `Map`. +/// The canonical TypeScript schema requires a non-null record +/// (`z.record(z.any())`); the Dart SDK is intentionally more permissive on +/// the *value* (allows primitives and `null`) to stay forward-compatible +/// with the Python reference server's `content: Any`. The *key itself* +/// is still required — see the matching note on `StateSnapshotEvent.fromJson` +/// for why we check key-presence rather than `requireField`. Treat any +/// non-record value you encounter as a wire-protocol surprise rather than +/// a contract. +final class ActivitySnapshotEvent extends BaseEvent { + final String messageId; + final String activityType; + final Object? content; + + /// `true` (the default) means this snapshot replaces any prior content + /// for the same [messageId]; `false` means it merges/extends. + /// + /// Optional on the wire (`replace: z.boolean().optional().default(true)` + /// in TS, `replace: bool = True` in Python). [toJson] omits the field + /// when it equals the default `true`, matching canonical TypeScript and + /// Python wire output. `fromJson` restores the default when the field is + /// absent, so round-trip semantics are preserved. + final bool replace; - const ToolCallStartEvent({ - required this.toolCallId, - required this.toolCallName, - this.parentMessageId, + const ActivitySnapshotEvent({ + required this.messageId, + required this.activityType, + required this.content, + this.replace = true, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.toolCallStart); + }) : super(eventType: EventType.activitySnapshot); - factory ToolCallStartEvent.fromJson(Map json) { - return ToolCallStartEvent( - toolCallId: JsonDecoder.requireField(json, 'toolCallId'), - toolCallName: JsonDecoder.requireField(json, 'toolCallName'), - parentMessageId: JsonDecoder.optionalField(json, 'parentMessageId'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ActivitySnapshotEvent.fromJson(Map json) { + // `content` may be any JSON shape (including `null`) but MUST be + // present — see the matching note on `StateSnapshotEvent.fromJson` + // for why we check key-presence rather than `requireField`. + if (!json.containsKey('content')) { + throw AGUIValidationError( + message: 'Missing required field', + field: 'content', + json: json, + ); + } + return ActivitySnapshotEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + activityType: JsonDecoder.requireEitherField( + json, + 'activityType', + 'activity_type', + ), + content: json['content'], + replace: JsonDecoder.optionalField(json, 'replace') ?? true, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'toolCallId': toolCallId, - 'toolCallName': toolCallName, - if (parentMessageId != null) 'parentMessageId': parentMessageId, - }; - + ...super.toJson(), + 'messageId': messageId, + 'activityType': activityType, + 'content': content, + // Omit `replace` when it equals the default `true`, matching canonical + // TS/Python wire output. `fromJson` defaults to `true` when absent, so + // round-trip semantics are preserved. + if (!replace) 'replace': replace, + }; + + // See `_Unset` (top of file) for the sentinel rationale. @override - ToolCallStartEvent copyWith({ - String? toolCallId, - String? toolCallName, - String? parentMessageId, + ActivitySnapshotEvent copyWith({ + String? messageId, + String? activityType, + Object? content = kUnsetSentinel, + bool? replace, int? timestamp, dynamic rawEvent, }) { - return ToolCallStartEvent( - toolCallId: toolCallId ?? this.toolCallId, - toolCallName: toolCallName ?? this.toolCallName, - parentMessageId: parentMessageId ?? this.parentMessageId, + return ActivitySnapshotEvent( + messageId: messageId ?? this.messageId, + activityType: activityType ?? this.activityType, + content: identical(content, kUnsetSentinel) ? this.content : content, + replace: replace ?? this.replace, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing tool call arguments -final class ToolCallArgsEvent extends BaseEvent { - final String toolCallId; - final String delta; +/// Event containing a JSON Patch (RFC 6902) delta for an activity message +final class ActivityDeltaEvent extends BaseEvent { + final String messageId; + final String activityType; + // RFC 6902 patch operations are always JSON objects ({op, path, …}). + // Using List> (via requireListField) surfaces + // non-object elements as AGUIValidationError at the decoder boundary. + final List> patch; - const ToolCallArgsEvent({ - required this.toolCallId, - required this.delta, + const ActivityDeltaEvent({ + required this.messageId, + required this.activityType, + required this.patch, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.toolCallArgs); + }) : super(eventType: EventType.activityDelta); - factory ToolCallArgsEvent.fromJson(Map json) { - return ToolCallArgsEvent( - toolCallId: JsonDecoder.requireField(json, 'toolCallId'), - delta: JsonDecoder.requireField(json, 'delta'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ActivityDeltaEvent.fromJson(Map json) { + return ActivityDeltaEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + activityType: JsonDecoder.requireEitherField( + json, + 'activityType', + 'activity_type', + ), + patch: JsonDecoder.requireListField>(json, 'patch'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'toolCallId': toolCallId, - 'delta': delta, - }; + ...super.toJson(), + 'messageId': messageId, + 'activityType': activityType, + 'patch': patch, + }; @override - ToolCallArgsEvent copyWith({ - String? toolCallId, - String? delta, + ActivityDeltaEvent copyWith({ + String? messageId, + String? activityType, + List>? patch, int? timestamp, dynamic rawEvent, }) { - return ToolCallArgsEvent( - toolCallId: toolCallId ?? this.toolCallId, - delta: delta ?? this.delta, + return ActivityDeltaEvent( + messageId: messageId ?? this.messageId, + activityType: activityType ?? this.activityType, + patch: patch ?? this.patch, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event indicating the end of a tool call -final class ToolCallEndEvent extends BaseEvent { - final String toolCallId; +/// Event wrapping a raw, uninterpreted upstream event payload. +/// +/// Three related but distinct concepts coexist on this class: +/// - [eventType]: always `EventType.raw` — the discriminator that routes wire +/// payloads here via `BaseEvent.fromJson`. +/// - [event]: the raw upstream event payload as decoded from the wire JSON +/// `event` field. May be any JSON shape, including `null`. +/// - [rawEvent]: inherited from [BaseEvent] — the verbatim wire JSON of the +/// *enclosing* SSE message (the whole `{type, event, ...}` map). Populated +/// by `_readRawEvent` when the producer includes a `rawEvent` / +/// `raw_event` key. Unrelated to the [event] field above. +final class RawEvent extends BaseEvent { + final dynamic event; + final String? source; - const ToolCallEndEvent({ - required this.toolCallId, + const RawEvent({ + required this.event, + this.source, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.toolCallEnd); + }) : super(eventType: EventType.raw); - factory ToolCallEndEvent.fromJson(Map json) { - return ToolCallEndEvent( - toolCallId: JsonDecoder.requireField(json, 'toolCallId'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + /// Decodes a [RawEvent] from a JSON map. + /// + /// **Cross-SDK note.** The `event` key MUST be present on the wire — this + /// Dart SDK aligns with the Python `event: Any` (required) schema rather + /// than the TypeScript `z.any()` schema which permits `undefined` (i.e. + /// an absent key). A TypeScript server that omits the `event` key entirely + /// will be rejected with `AGUIValidationError(field: 'event')`. + factory RawEvent.fromJson(Map json) { + // `event` may be any JSON shape but MUST be present — see the + // matching note on `StateSnapshotEvent.fromJson` for why we check + // key-presence rather than `requireField`. + if (!json.containsKey('event')) { + throw AGUIValidationError( + message: 'Missing required field', + field: 'event', + json: json, + ); + } + return RawEvent( + event: json['event'], + source: JsonDecoder.optionalField(json, 'source'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'toolCallId': toolCallId, - }; + ...super.toJson(), + 'event': event, + if (source != null) 'source': source, + }; + // See `_Unset` (top of file) for the sentinel rationale. Both `event` + // and `source` are nullable on the wire, so callers need explicit-clear + // semantics to drop a stale upstream payload. @override - ToolCallEndEvent copyWith({ - String? toolCallId, + RawEvent copyWith({ + Object? event = kUnsetSentinel, + Object? source = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { - return ToolCallEndEvent( - toolCallId: toolCallId ?? this.toolCallId, + return RawEvent( + event: identical(event, kUnsetSentinel) ? this.event : event, + source: + identical(source, kUnsetSentinel) ? this.source : source as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing a chunk of tool call content -final class ToolCallChunkEvent extends BaseEvent { - final String? toolCallId; - final String? toolCallName; - final String? parentMessageId; - final String? delta; +/// Event containing a custom event +final class CustomEvent extends BaseEvent { + final String name; + final dynamic value; - const ToolCallChunkEvent({ - this.toolCallId, - this.toolCallName, - this.parentMessageId, - this.delta, + const CustomEvent({ + required this.name, + required this.value, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.toolCallChunk); + }) : super(eventType: EventType.custom); - factory ToolCallChunkEvent.fromJson(Map json) { - return ToolCallChunkEvent( - toolCallId: JsonDecoder.optionalField(json, 'toolCallId'), - toolCallName: JsonDecoder.optionalField(json, 'toolCallName'), - parentMessageId: JsonDecoder.optionalField(json, 'parentMessageId'), - delta: JsonDecoder.optionalField(json, 'delta'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory CustomEvent.fromJson(Map json) { + // `value` may be any JSON shape but MUST be present — see the + // matching note on `StateSnapshotEvent.fromJson` for why we check + // key-presence rather than `requireField`. + if (!json.containsKey('value')) { + throw AGUIValidationError( + message: 'Missing required field', + field: 'value', + json: json, + ); + } + return CustomEvent( + name: JsonDecoder.requireField(json, 'name'), + value: json['value'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - if (toolCallId != null) 'toolCallId': toolCallId, - if (toolCallName != null) 'toolCallName': toolCallName, - if (parentMessageId != null) 'parentMessageId': parentMessageId, - if (delta != null) 'delta': delta, - }; + ...super.toJson(), + 'name': name, + 'value': value, + }; + // See `_Unset` (top of file) for the sentinel rationale. @override - ToolCallChunkEvent copyWith({ - String? toolCallId, - String? toolCallName, - String? parentMessageId, - String? delta, + CustomEvent copyWith({ + String? name, + Object? value = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { - return ToolCallChunkEvent( - toolCallId: toolCallId ?? this.toolCallId, - toolCallName: toolCallName ?? this.toolCallName, - parentMessageId: parentMessageId ?? this.parentMessageId, - delta: delta ?? this.delta, + return CustomEvent( + name: name ?? this.name, + value: identical(value, kUnsetSentinel) ? this.value : value, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing the result of a tool call -final class ToolCallResultEvent extends BaseEvent { - final String messageId; - final String toolCallId; - final String content; - final String? role; +// ============================================================================ +// Lifecycle Events +// ============================================================================ - const ToolCallResultEvent({ - required this.messageId, - required this.toolCallId, - required this.content, - this.role, +/// Event indicating that a run has started +final class RunStartedEvent extends BaseEvent { + final String threadId; + final String runId; + final String? parentRunId; + + /// Optional `RUN_STARTED` input snapshot. On the wire the `input` key + /// must hold a JSON object — `optionalField>` in + /// [RunStartedEvent.fromJson] rejects a wrong-typed value (string, list, + /// number, etc.) with `AGUIValidationError(field: 'input')`. An absent + /// or explicit-null `input` decodes as `null`. + final RunAgentInput? input; + + RunStartedEvent({ + required this.threadId, + required this.runId, + this.parentRunId, + this.input, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.toolCallResult); + }) : super(eventType: EventType.runStarted) { + if (rawEvent != null && + input != null && + input!.messages.any((m) => m.encryptedValue != null)) { + throw AGUIValidationError( + message: + 'Direct construction with rawEvent + cipher-bearing input.messages ' + 'violates the scrub invariant. Pass rawEvent: null or pre-scrub.', + field: 'rawEvent', + ); + } + } - factory ToolCallResultEvent.fromJson(Map json) { - return ToolCallResultEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - toolCallId: JsonDecoder.requireField(json, 'toolCallId'), - content: JsonDecoder.requireField(json, 'content'), - role: JsonDecoder.optionalField(json, 'role'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory RunStartedEvent.fromJson(Map json) { + final inputJson = JsonDecoder.optionalField>( + json, + 'input', + ); + RunAgentInput? input; + if (inputJson != null) { + try { + input = RunAgentInput.fromJson(inputJson); + } on AGUIValidationError catch (e) { + // Omit json: — e.json (the inner RunAgentInput payload) can carry + // encryptedValue via input.messages[*]. Omit cause: for the same + // reason: the cause chain exposes e.json to reflection-based log + // shippers. Surface only the field path and the non-cipher value. + throw AGUIValidationError( + message: e.message, + field: e.field != null ? 'input.${e.field}' : 'input', + value: e.value, + ); + } + } + // Auto-scrub rawEvent when any input message carries cipher data, mirroring + // the MessagesSnapshotEvent.fromJson invariant. + // + // ActivityMessage.fromJson silently strips wire-level encryptedValue from + // the structured field, so the structured-field predicate alone would miss + // a cipher on an ActivityMessage. We check the raw wire messages list + // directly for role == 'activity' entries that still carry a cipher key. + // + // Scope note: this predicate only sweeps input.messages (the structured + // RunAgentInput). If a malformed payload omits `input` entirely but carries + // encrypted material under a top-level key, that material is not caught + // here. The attack surface is narrow (requires a malformed payload AND an + // absent `input` key) and asymmetric with MessagesSnapshotEvent by design: + // RunStartedEvent only encrypts the input.messages path. + // + // SCRUB CONTRACT: this check assumes encryptedValue / encrypted_value is + // the only cipher-named key on any Message subtype. If a future subtype + // adds a different sensitive payload key, this hasCipher predicate MUST be + // extended in parallel. + final rawInputMessages = inputJson != null + ? (inputJson['messages'] is List + ? inputJson['messages'] as List + : const []) + : const []; + final hasCipher = input != null && + (input.messages.any((m) => m.encryptedValue != null) || + rawInputMessages.any((m) => + m is Map && + m['role'] == 'activity' && + (m.containsKey('encryptedValue') || + m.containsKey('encrypted_value')))); + return RunStartedEvent( + threadId: JsonDecoder.requireEitherField( + json, + 'threadId', + 'thread_id', + ), + runId: JsonDecoder.requireEitherField( + json, + 'runId', + 'run_id', + ), + parentRunId: JsonDecoder.optionalEitherField( + json, + 'parentRunId', + 'parent_run_id', + ), + input: input, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: hasCipher ? null : _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - 'toolCallId': toolCallId, - 'content': content, - if (role != null) 'role': role, - }; - + ...super.toJson(), + 'threadId': threadId, + 'runId': runId, + if (parentRunId != null) 'parentRunId': parentRunId, + if (input != null) 'input': input!.toJson(), + }; + + /// Creates a copy of this event with the given fields replaced. + /// + /// **Cipher-safety note.** If any message in the resolved `input.messages` + /// list carries cipher data (`encryptedValue != null`), the `rawEvent` + /// parameter is silently forced to `null` regardless of the value the caller + /// supplies. This mirrors the `fromJson` invariant that prevents the raw wire + /// map from leaking cipher payloads through `rawEvent`. Callers that have + /// already scrubbed a sanitized `rawEvent` map should construct a new + /// [RunStartedEvent] directly with `rawEvent: scrubbedMap` rather than + /// calling `copyWith`. + // See `_Unset` (top of file) for the sentinel rationale. @override - ToolCallResultEvent copyWith({ - String? messageId, - String? toolCallId, - String? content, - String? role, + RunStartedEvent copyWith({ + String? threadId, + String? runId, + Object? parentRunId = kUnsetSentinel, + Object? input = kUnsetSentinel, int? timestamp, - dynamic rawEvent, + Object? rawEvent = kUnsetSentinel, }) { - return ToolCallResultEvent( - messageId: messageId ?? this.messageId, - toolCallId: toolCallId ?? this.toolCallId, - content: content ?? this.content, - role: role ?? this.role, + // The sentinel check MUST come before the type check — if input IS the + // sentinel, `input is! RunAgentInput?` evaluates true (Object is not + // RunAgentInput?) and would incorrectly throw. Swapping the two conditions + // breaks all no-arg copyWith() calls. + if (!identical(input, kUnsetSentinel) && input is! RunAgentInput?) { + throw ArgumentError.value( + input, + 'input', + 'must be RunAgentInput?, null, or kUnsetSentinel', + ); + } + final newInput = identical(input, kUnsetSentinel) + ? this.input + : input as RunAgentInput?; + // Re-apply the fromJson cipher-scrub invariant on the resolved input. + final hasCipher = + newInput != null && newInput.messages.any((m) => m.encryptedValue != null); + // Log in all builds (including release) when a caller passes a non-null + // rawEvent that will be silently scrubbed. The force-to-null below is + // the authoritative safety measure; the log helps callers diagnose + // unexpected scrub in production without crashing. + if (hasCipher && + !identical(rawEvent, kUnsetSentinel) && + rawEvent != null) { + developer.log( + 'RunStartedEvent.copyWith: rawEvent is silently forced to null ' + 'when any input message carries encryptedValue. Construct directly if ' + 'you need to retain a sanitized rawEvent.', + name: 'ag_ui.cipher_scrub', + level: 900, // WARNING + ); + } + final dynamic resolvedRaw; + if (hasCipher) { + resolvedRaw = null; + } else if (identical(rawEvent, kUnsetSentinel)) { + resolvedRaw = this.rawEvent; + } else { + resolvedRaw = rawEvent; + } + return RunStartedEvent( + threadId: threadId ?? this.threadId, + runId: runId ?? this.runId, + parentRunId: identical(parentRunId, kUnsetSentinel) + ? this.parentRunId + : parentRunId as String?, + input: newInput, timestamp: timestamp ?? this.timestamp, - rawEvent: rawEvent ?? this.rawEvent, + rawEvent: resolvedRaw, ); } } -// ============================================================================ -// State Events -// ============================================================================ +/// Event indicating that a run has finished +final class RunFinishedEvent extends BaseEvent { + final String threadId; + final String runId; -/// Event containing a snapshot of the state -final class StateSnapshotEvent extends BaseEvent { - final State snapshot; + /// Optional run-completion payload (`z.any().optional()` / + /// `Optional[Any] = None` in TS/Python). On the wire, an explicit + /// `'result': null` and an absent `result` key are equivalent — both + /// produce a [RunFinishedEvent] with `result == null`, and [toJson] + /// drops the key when `result` is null. + /// + /// The [kUnsetSentinel] on [copyWith] (`Object? result = kUnsetSentinel`) + /// is for in-memory disambiguation only — it lets callers explicitly clear + /// a previously-set result without constructing a new event. It is NOT a + /// wire-protocol distinction: both `null` and absent produce identical + /// `toJson` output (key omitted). Do not mirror the + /// `ActivitySnapshotEvent.content` always-emit pattern here; the protocol + /// does not require [RunFinishedEvent.result] on the wire. If you need the + /// distinction visible in the wire output, construct a new [RunFinishedEvent] + /// directly with the field always emitted. + final dynamic result; - const StateSnapshotEvent({ - required this.snapshot, + const RunFinishedEvent({ + required this.threadId, + required this.runId, + this.result, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.stateSnapshot); + }) : super(eventType: EventType.runFinished); - factory StateSnapshotEvent.fromJson(Map json) { - return StateSnapshotEvent( - snapshot: json['snapshot'], - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory RunFinishedEvent.fromJson(Map json) { + // Unlike StateSnapshotEvent / RawEvent / CustomEvent / ActivitySnapshotEvent + // which use containsKey to enforce key presence, `result` is truly optional + // (canonical `z.any().optional()` / `Optional[Any] = None`). An absent key + // and an explicit `'result': null` are equivalent — both produce `result == null`. + return RunFinishedEvent( + threadId: JsonDecoder.requireEitherField( + json, + 'threadId', + 'thread_id', + ), + runId: JsonDecoder.requireEitherField( + json, + 'runId', + 'run_id', + ), + result: json['result'], + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'snapshot': snapshot, - }; + ...super.toJson(), + 'threadId': threadId, + 'runId': runId, + if (result != null) 'result': result, + }; + // See `_Unset` (top of file) for the sentinel rationale. @override - StateSnapshotEvent copyWith({ - State? snapshot, + RunFinishedEvent copyWith({ + String? threadId, + String? runId, + Object? result = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { - return StateSnapshotEvent( - snapshot: snapshot ?? this.snapshot, + return RunFinishedEvent( + threadId: threadId ?? this.threadId, + runId: runId ?? this.runId, + result: identical(result, kUnsetSentinel) ? this.result : result, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing a delta of the state (JSON Patch RFC 6902) -final class StateDeltaEvent extends BaseEvent { - final List delta; +/// Event indicating that a run has encountered an error +final class RunErrorEvent extends BaseEvent { + final String message; - const StateDeltaEvent({ - required this.delta, + /// Optional machine-readable error code. + final String? code; + + const RunErrorEvent({ + required this.message, + this.code, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.stateDelta); + }) : super(eventType: EventType.runError); - factory StateDeltaEvent.fromJson(Map json) { - return StateDeltaEvent( - delta: JsonDecoder.requireField>(json, 'delta'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory RunErrorEvent.fromJson(Map json) { + return RunErrorEvent( + message: JsonDecoder.requireField(json, 'message'), + code: JsonDecoder.optionalField(json, 'code'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'delta': delta, - }; + ...super.toJson(), + 'message': message, + if (code != null) 'code': code, + }; @override - StateDeltaEvent copyWith({ - List? delta, + RunErrorEvent copyWith({ + String? message, + Object? code = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { - return StateDeltaEvent( - delta: delta ?? this.delta, + return RunErrorEvent( + message: message ?? this.message, + code: identical(code, kUnsetSentinel) ? this.code : code as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing a snapshot of messages -final class MessagesSnapshotEvent extends BaseEvent { - final List messages; +/// Event indicating that a step has started +final class StepStartedEvent extends BaseEvent { + final String stepName; - const MessagesSnapshotEvent({ - required this.messages, + const StepStartedEvent({ + required this.stepName, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.messagesSnapshot); + }) : super(eventType: EventType.stepStarted); - factory MessagesSnapshotEvent.fromJson(Map json) { - return MessagesSnapshotEvent( - messages: JsonDecoder.requireListField>( + factory StepStartedEvent.fromJson(Map json) { + return StepStartedEvent( + stepName: JsonDecoder.requireEitherField( json, - 'messages', - ).map((item) => Message.fromJson(item)).toList(), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + 'stepName', + 'step_name', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'messages': messages.map((m) => m.toJson()).toList(), - }; + ...super.toJson(), + 'stepName': stepName, + }; @override - MessagesSnapshotEvent copyWith({ - List? messages, + StepStartedEvent copyWith({ + String? stepName, int? timestamp, dynamic rawEvent, }) { - return MessagesSnapshotEvent( - messages: messages ?? this.messages, + return StepStartedEvent( + stepName: stepName ?? this.stepName, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing a snapshot of activity state. -final class ActivitySnapshotEvent extends BaseEvent { - const ActivitySnapshotEvent({ - required this.messageId, - required this.activityType, - required this.content, - this.replace = true, +/// Event indicating that a step has finished +final class StepFinishedEvent extends BaseEvent { + final String stepName; + + const StepFinishedEvent({ + required this.stepName, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.activitySnapshot); + }) : super(eventType: EventType.stepFinished); - factory ActivitySnapshotEvent.fromJson(Map json) { - return ActivitySnapshotEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - activityType: JsonDecoder.requireField(json, 'activityType'), - content: JsonDecoder.requireField>(json, 'content'), - replace: json['replace'] as bool? ?? true, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory StepFinishedEvent.fromJson(Map json) { + return StepFinishedEvent( + stepName: JsonDecoder.requireEitherField( + json, + 'stepName', + 'step_name', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } - final String messageId; - final String activityType; - final Map content; - final bool replace; - @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - 'activityType': activityType, - 'content': content, - 'replace': replace, - }; + ...super.toJson(), + 'stepName': stepName, + }; @override - ActivitySnapshotEvent copyWith({ - String? messageId, - String? activityType, - Map? content, - bool? replace, + StepFinishedEvent copyWith({ + String? stepName, int? timestamp, dynamic rawEvent, }) { - return ActivitySnapshotEvent( - messageId: messageId ?? this.messageId, - activityType: activityType ?? this.activityType, - content: content ?? this.content, - replace: replace ?? this.replace, + return StepFinishedEvent( + stepName: stepName ?? this.stepName, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing a JSON Patch (RFC 6902) over an activity's state. -final class ActivityDeltaEvent extends BaseEvent { - const ActivityDeltaEvent({ +// ============================================================================ +// Reasoning Events +// ============================================================================ + +/// Role for reasoning messages (aligned with the AG-UI protocol). +/// +/// Currently a single-variant enum mirroring the canonical +/// `Literal["reasoning"]` (Python) / `z.literal("reasoning")` (TypeScript). +/// Modeled as an enum so a future role addition can land without churning +/// every call site. +enum ReasoningMessageRole { + reasoning('reasoning'); + + final String value; + const ReasoningMessageRole(this.value); + + /// Parses [value] into a [ReasoningMessageRole]. + /// + /// Throws [ArgumentError] for unknown values. Callers decoding from the + /// wire should use `ReasoningMessageStartEvent.fromJson`, which absorbs + /// the throw and falls back to [ReasoningMessageRole.reasoning] so a + /// future server-side role does not tear down the SSE stream. + static final Map _byValue = Map.unmodifiable({ + for (final r in ReasoningMessageRole.values) r.value: r, + }); + + static ReasoningMessageRole fromString(String value) { + return _byValue[value] ?? + (throw ArgumentError('Invalid reasoning message role: $value')); + } +} + +/// Subtype for [ReasoningEncryptedValueEvent]. +enum ReasoningEncryptedValueSubtype { + /// Wire spelling is `'tool-call'` with a hyphen — canonical across the + /// AG-UI protocol (Python `Literal["tool-call"]`, TypeScript + /// `z.literal("tool-call")`). The Dart symbol is `toolCall`; the dash is + /// intentional, not a typo. + toolCall('tool-call'), + message('message'); + + final String value; + const ReasoningEncryptedValueSubtype(this.value); + + /// Parses [value] into a [ReasoningEncryptedValueSubtype]. + /// + /// Throws [ArgumentError] for unknown values. The subtype is part of the + /// protocol contract — there is no graceful fallback at the event level + /// because choosing a default would silently mis-tag encrypted payloads. + /// Wire failures bubble up as [DecodingError] under the standard decoder + /// pipeline; consumers that want per-event recovery should set + /// `skipInvalidEvents: true` on `EventStreamAdapter`. + static final Map _byValue = + Map.unmodifiable({ + for (final s in ReasoningEncryptedValueSubtype.values) s.value: s, + }); + + /// Throws [AGUIValidationError] (not [ArgumentError]) on unknown values — + /// unlike [EventType.fromString] which throws [ArgumentError] so that + /// `BaseEvent.fromJson`'s narrow `on ArgumentError` catch can distinguish + /// unknown event types from factory bugs. Subtype is a cipher-data + /// discriminator with no safe fallback; throwing [AGUIValidationError] + /// directly surfaces it uniformly as [DecodingError] through the decoder + /// pipeline without a wrapping layer. + static ReasoningEncryptedValueSubtype fromString(String value) { + return _byValue[value] ?? + (throw AGUIValidationError( + message: 'Invalid reasoning encrypted value subtype: $value', + field: 'subtype', + value: value, + // Intentionally omit json: — this helper is called from cipher-data path. + )); + } +} + +/// Event indicating the start of a reasoning phase. +final class ReasoningStartEvent extends BaseEvent { + final String messageId; + + const ReasoningStartEvent({ required this.messageId, - required this.activityType, - required this.patch, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.activityDelta); + }) : super(eventType: EventType.reasoningStart); - factory ActivityDeltaEvent.fromJson(Map json) { - return ActivityDeltaEvent( - messageId: JsonDecoder.requireField(json, 'messageId'), - activityType: JsonDecoder.requireField(json, 'activityType'), - patch: JsonDecoder.requireField>(json, 'patch'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ReasoningStartEvent.fromJson(Map json) { + return ReasoningStartEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } - final String messageId; - final String activityType; - final List patch; - @override Map toJson() => { - ...super.toJson(), - 'messageId': messageId, - 'activityType': activityType, - 'patch': patch, - }; + ...super.toJson(), + 'messageId': messageId, + }; @override - ActivityDeltaEvent copyWith({ + ReasoningStartEvent copyWith({ String? messageId, - String? activityType, - List? patch, int? timestamp, dynamic rawEvent, }) { - return ActivityDeltaEvent( + return ReasoningStartEvent( messageId: messageId ?? this.messageId, - activityType: activityType ?? this.activityType, - patch: patch ?? this.patch, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing a raw event -final class RawEvent extends BaseEvent { - final dynamic event; - final String? source; +/// Event indicating the start of a reasoning message. +final class ReasoningMessageStartEvent extends BaseEvent { + final String messageId; + final ReasoningMessageRole role; - const RawEvent({ - required this.event, - this.source, + const ReasoningMessageStartEvent({ + required this.messageId, + this.role = ReasoningMessageRole.reasoning, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.raw); + }) : super(eventType: EventType.reasoningMessageStart); - factory RawEvent.fromJson(Map json) { - return RawEvent( - event: json['event'], - source: JsonDecoder.optionalField(json, 'source'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ReasoningMessageStartEvent.fromJson(Map json) { + // Validate the cheap required field FIRST so a missing-id error + // surfaces before any role-parsing work. + final messageId = JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ); + // `role` is required by the canonical TypeScript and Python schemas + // (see sdks/typescript/packages/core/src/events.ts and + // sdks/python/ag_ui/core/events.py). A missing `role` is a protocol + // violation and must fail decoding so it surfaces at the boundary + // instead of silently coercing downstream. + final roleStr = JsonDecoder.requireField(json, 'role'); + ReasoningMessageRole role; + try { + role = ReasoningMessageRole.fromString(roleStr); + } on ArgumentError { + // Forward-compat: a future server may introduce a new role *value* + // (e.g. an as-yet-unspecified reasoning sub-role). The field is + // present and string-typed, so this is a recoverable enum-mapping + // failure — keep the stream alive by defaulting to `reasoning`. + // + // We intentionally do NOT broaden to `catch (e)` or `on Exception`: + // a missing-key or wrong-typed `role` raises `AGUIValidationError` + // from `requireField` above, which MUST propagate to the + // decoder boundary as a protocol violation. Widening the catch + // would silently absorb those — the test at + // `event_test.dart` ("rejects missing role (parity with TS/Python)") + // is the regression guard for that contract. + role = ReasoningMessageRole.reasoning; + } + return ReasoningMessageStartEvent( + messageId: messageId, + role: role, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'event': event, - if (source != null) 'source': source, - }; + ...super.toJson(), + 'messageId': messageId, + 'role': role.value, + }; @override - RawEvent copyWith({ - dynamic event, - String? source, + ReasoningMessageStartEvent copyWith({ + String? messageId, + ReasoningMessageRole? role, int? timestamp, dynamic rawEvent, }) { - return RawEvent( - event: event ?? this.event, - source: source ?? this.source, + return ReasoningMessageStartEvent( + messageId: messageId ?? this.messageId, + role: role ?? this.role, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event containing a custom event -final class CustomEvent extends BaseEvent { - final String name; - final dynamic value; +/// Event containing a piece of reasoning message content. +final class ReasoningMessageContentEvent extends BaseEvent { + final String messageId; + final String delta; - const CustomEvent({ - required this.name, - required this.value, + const ReasoningMessageContentEvent({ + required this.messageId, + required this.delta, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.custom); + }) : super(eventType: EventType.reasoningMessageContent); - factory CustomEvent.fromJson(Map json) { - return CustomEvent( - name: JsonDecoder.requireField(json, 'name'), - value: json['value'], - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ReasoningMessageContentEvent.fromJson(Map json) { + // Validate the cheap required identifier FIRST so a missing-id error + // surfaces before any payload-validation work — same convention as + // `ReasoningMessageStartEvent.fromJson`. + final messageId = JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ); + // Empty `delta` is accepted to match canonical TS/Python schemas + // (`ReasoningMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str`). + final delta = JsonDecoder.requireField(json, 'delta'); + + return ReasoningMessageContentEvent( + messageId: messageId, + delta: delta, + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'name': name, - 'value': value, - }; + ...super.toJson(), + 'messageId': messageId, + 'delta': delta, + }; @override - CustomEvent copyWith({ - String? name, - dynamic value, + ReasoningMessageContentEvent copyWith({ + String? messageId, + String? delta, int? timestamp, dynamic rawEvent, }) { - return CustomEvent( - name: name ?? this.name, - value: value ?? this.value, + return ReasoningMessageContentEvent( + messageId: messageId ?? this.messageId, + delta: delta ?? this.delta, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -// ============================================================================ -// Lifecycle Events -// ============================================================================ - -/// Event indicating that a run has started -final class RunStartedEvent extends BaseEvent { - final String threadId; - final String runId; +/// Event indicating the end of a reasoning message. +final class ReasoningMessageEndEvent extends BaseEvent { + final String messageId; - const RunStartedEvent({ - required this.threadId, - required this.runId, + const ReasoningMessageEndEvent({ + required this.messageId, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.runStarted); + }) : super(eventType: EventType.reasoningMessageEnd); - factory RunStartedEvent.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final threadId = JsonDecoder.optionalField(json, 'threadId') ?? - JsonDecoder.requireField(json, 'thread_id'); - final runId = JsonDecoder.optionalField(json, 'runId') ?? - JsonDecoder.requireField(json, 'run_id'); - - return RunStartedEvent( - threadId: threadId, - runId: runId, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ReasoningMessageEndEvent.fromJson(Map json) { + return ReasoningMessageEndEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'threadId': threadId, - 'runId': runId, - }; + ...super.toJson(), + 'messageId': messageId, + }; @override - RunStartedEvent copyWith({ - String? threadId, - String? runId, + ReasoningMessageEndEvent copyWith({ + String? messageId, int? timestamp, dynamic rawEvent, }) { - return RunStartedEvent( - threadId: threadId ?? this.threadId, - runId: runId ?? this.runId, + return ReasoningMessageEndEvent( + messageId: messageId ?? this.messageId, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event indicating that a run has finished -final class RunFinishedEvent extends BaseEvent { - final String threadId; - final String runId; - final dynamic result; +/// Event containing a chunk of reasoning message content. +final class ReasoningMessageChunkEvent extends BaseEvent { + final String? messageId; + final String? delta; - const RunFinishedEvent({ - required this.threadId, - required this.runId, - this.result, + const ReasoningMessageChunkEvent({ + this.messageId, + this.delta, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.runFinished); + }) : super(eventType: EventType.reasoningMessageChunk); - factory RunFinishedEvent.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final threadId = JsonDecoder.optionalField(json, 'threadId') ?? - JsonDecoder.requireField(json, 'thread_id'); - final runId = JsonDecoder.optionalField(json, 'runId') ?? - JsonDecoder.requireField(json, 'run_id'); - - return RunFinishedEvent( - threadId: threadId, - runId: runId, - result: json['result'], - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ReasoningMessageChunkEvent.fromJson(Map json) { + return ReasoningMessageChunkEvent( + messageId: JsonDecoder.optionalEitherField( + json, + 'messageId', + 'message_id', + ), + delta: JsonDecoder.optionalField(json, 'delta'), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'threadId': threadId, - 'runId': runId, - if (result != null) 'result': result, - }; + ...super.toJson(), + if (messageId != null) 'messageId': messageId, + if (delta != null) 'delta': delta, + }; + // See `_Unset` (top of file) for the sentinel rationale. @override - RunFinishedEvent copyWith({ - String? threadId, - String? runId, - dynamic result, + ReasoningMessageChunkEvent copyWith({ + Object? messageId = kUnsetSentinel, + Object? delta = kUnsetSentinel, int? timestamp, dynamic rawEvent, }) { - return RunFinishedEvent( - threadId: threadId ?? this.threadId, - runId: runId ?? this.runId, - result: result ?? this.result, + return ReasoningMessageChunkEvent( + messageId: identical(messageId, kUnsetSentinel) + ? this.messageId + : messageId as String?, + delta: identical(delta, kUnsetSentinel) ? this.delta : delta as String?, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event indicating that a run has encountered an error -final class RunErrorEvent extends BaseEvent { - final String message; - final String? code; +/// Event indicating the end of a reasoning phase. +final class ReasoningEndEvent extends BaseEvent { + final String messageId; - const RunErrorEvent({ - required this.message, - this.code, + const ReasoningEndEvent({ + required this.messageId, super.timestamp, super.rawEvent, - }) : super(eventType: EventType.runError); + }) : super(eventType: EventType.reasoningEnd); - factory RunErrorEvent.fromJson(Map json) { - return RunErrorEvent( - message: JsonDecoder.requireField(json, 'message'), - code: JsonDecoder.optionalField(json, 'code'), - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ReasoningEndEvent.fromJson(Map json) { + return ReasoningEndEvent( + messageId: JsonDecoder.requireEitherField( + json, + 'messageId', + 'message_id', + ), + timestamp: JsonDecoder.optionalIntField(json, 'timestamp'), + rawEvent: _readRawEvent(json), ); } @override Map toJson() => { - ...super.toJson(), - 'message': message, - if (code != null) 'code': code, - }; + ...super.toJson(), + 'messageId': messageId, + }; @override - RunErrorEvent copyWith({ - String? message, - String? code, + ReasoningEndEvent copyWith({ + String? messageId, int? timestamp, dynamic rawEvent, }) { - return RunErrorEvent( - message: message ?? this.message, - code: code ?? this.code, + return ReasoningEndEvent( + messageId: messageId ?? this.messageId, timestamp: timestamp ?? this.timestamp, rawEvent: rawEvent ?? this.rawEvent, ); } } -/// Event indicating that a step has started -final class StepStartedEvent extends BaseEvent { - final String stepName; - - const StepStartedEvent({ - required this.stepName, - super.timestamp, - super.rawEvent, - }) : super(eventType: EventType.stepStarted); +// --------------------------------------------------------------------------- +// Cipher-safe field extraction helper +// --------------------------------------------------------------------------- - factory StepStartedEvent.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final stepName = JsonDecoder.optionalField(json, 'stepName') ?? - JsonDecoder.requireField(json, 'step_name'); - - return StepStartedEvent( - stepName: stepName, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], +/// Extracts a required [String] field without including [json] in any thrown +/// [AGUIValidationError] — use only for cipher-data payloads where forwarding +/// the raw map through log-shippers or reflection-based serializers would leak +/// sensitive data. +/// +/// [camelKey] is tried first (camelCase-wins precedence matching +/// [JsonDecoder.requireEitherField]). [snakeKey], if given, is the fallback. +String _requireCipherSafeString( + Map json, + String camelKey, [ + String? snakeKey, +]) { + final bool present = json.containsKey(camelKey) || + (snakeKey != null && json.containsKey(snakeKey)); + final rawValue = json.containsKey(camelKey) ? json[camelKey] : json[snakeKey]; + + if (!present) { + throw AGUIValidationError( + message: snakeKey != null + ? 'Missing required field "$camelKey" (or "$snakeKey")' + : 'Missing required field "$camelKey"', + field: camelKey, + // Intentionally omit json: — payload contains cipher data. ); } - - @override - Map toJson() => { - ...super.toJson(), - 'stepName': stepName, - }; - - @override - StepStartedEvent copyWith({ - String? stepName, - int? timestamp, - dynamic rawEvent, - }) { - return StepStartedEvent( - stepName: stepName ?? this.stepName, - timestamp: timestamp ?? this.timestamp, - rawEvent: rawEvent ?? this.rawEvent, + if (rawValue == null) { + throw AGUIValidationError( + message: 'Field "$camelKey" must not be null', + field: camelKey, + // Intentionally omit json: — payload contains cipher data. ); } + if (rawValue is! String) { + throw AGUIValidationError( + message: + 'Field "$camelKey" has incorrect type. Expected String, got ${rawValue.runtimeType}', + field: camelKey, + // Record only the runtime type, not the raw value — payload contains + // cipher data; even a wrong-typed value could be sensitive material. + value: rawValue.runtimeType.toString(), + // Intentionally omit json: — payload contains cipher data. + ); + } + return rawValue; } -/// Event indicating that a step has finished -final class StepFinishedEvent extends BaseEvent { - final String stepName; +/// Event containing an encrypted value for a message or tool call. +/// +/// **Cipher-safety guarantees.** All three wire fields ([subtype], [entityId], +/// [encryptedValue]) are parsed via the internal `_requireCipherSafeString` +/// helper, which omits `json:` from every thrown [AGUIValidationError] so +/// cipher payloads cannot leak through reflection-based error serializers or +/// log shippers. [BaseEvent.rawEvent] is unconditionally set to `null` in +/// `fromJson` — the verbatim wire map carries `encryptedValue` and must not +/// be re-exposed downstream. The `copyWith` method intentionally omits a +/// `rawEvent` parameter for the same reason; if you need a scrubbed +/// `rawEvent`, construct a new [ReasoningEncryptedValueEvent] directly. +/// +/// **Forward-compat note.** A future server-side [subtype] value will cause +/// [ReasoningEncryptedValueSubtype.fromString] to throw, which propagates +/// out of `fromJson` as an [AGUIValidationError] (wrapped in a +/// [DecodingError] when reached through [EventDecoder]). To keep streams +/// alive across an unknown subtype, opt in to per-event recovery via +/// `EventStreamAdapter(skipInvalidEvents: true)` — the rest of the SDK's +/// enums absorb unknown values at the event-decoding boundary, but the +/// encrypted-payload subtype has no sensible default to fall back to. +final class ReasoningEncryptedValueEvent extends BaseEvent { + final ReasoningEncryptedValueSubtype subtype; + final String entityId; + final String encryptedValue; - const StepFinishedEvent({ - required this.stepName, + // SECURITY: `rawEvent` is intentionally absent from this constructor. + // Pinning it to null in the super-initializer prevents callers from + // re-attaching a wire map (which carries `encryptedValue`) and bypassing + // the cipher-scrub invariant enforced in `fromJson` and `copyWith`. + const ReasoningEncryptedValueEvent({ + required this.subtype, + required this.entityId, + required this.encryptedValue, super.timestamp, - super.rawEvent, - }) : super(eventType: EventType.stepFinished); + }) : super( + eventType: EventType.reasoningEncryptedValue, + rawEvent: null, + ); - factory StepFinishedEvent.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final stepName = JsonDecoder.optionalField(json, 'stepName') ?? - JsonDecoder.requireField(json, 'step_name'); - - return StepFinishedEvent( - stepName: stepName, - timestamp: JsonDecoder.optionalField(json, 'timestamp'), - rawEvent: json['rawEvent'], + factory ReasoningEncryptedValueEvent.fromJson(Map json) { + // All three required fields use [_requireCipherSafeString] rather than + // `requireField`/`requireEitherField` so that every error path omits + // `json:` — the payload contains cipher data and forwarding the full wire + // map to `AGUIValidationError.json` would leak it through reflection-based + // error serializers and log shippers. See [_requireCipherSafeString]. + final subtypeRaw = _requireCipherSafeString(json, 'subtype'); + // fromString now throws AGUIValidationError directly on unknown values, + // so no try/catch wrapper is needed — the error propagates correctly + // through the decoder pipeline to DecodingError. + final subtype = ReasoningEncryptedValueSubtype.fromString(subtypeRaw); + + // entityId and encryptedValue are accepted as plain strings (including + // empty) to match canonical schemas: TS `z.string()` and Python `str` + // (no `min_length`). The strict subtype discriminator above stays — + // unknown subtypes still throw. + final entityId = _requireCipherSafeString(json, 'entityId', 'entity_id'); + final encryptedValue = + _requireCipherSafeString(json, 'encryptedValue', 'encrypted_value'); + + // rawEvent is explicitly set to null — unlike every other factory in this + // file, forwarding _readRawEvent(json) would store the full cipher payload + // in BaseEvent.rawEvent, undoing all the cipher-data scrubbing above. + // Proxies that need the raw wire form should maintain their own copy before + // calling fromJson. + return ReasoningEncryptedValueEvent( + subtype: subtype, + entityId: entityId, + encryptedValue: encryptedValue, + timestamp: JsonDecoder.optionalCipherSafeIntField(json, 'timestamp'), + // rawEvent: omitted — constructor pins rawEvent: null in its super-initializer. ); } @override Map toJson() => { - ...super.toJson(), - 'stepName': stepName, - }; - + ...super.toJson(), + 'subtype': subtype.value, + 'entityId': entityId, + 'encryptedValue': encryptedValue, + }; + + // SECURITY: `rawEvent` is intentionally omitted from `copyWith`. + // `fromJson` always pins `rawEvent: null` to prevent the cipher payload + // in `encryptedValue` from leaking through the raw wire map. Accepting + // a `rawEvent` parameter here would let callers re-attach that map and + // undo the scrub — `MessagesSnapshotEvent.copyWith` applies the same + // restriction for the same reason. Callers that genuinely need a non-null + // `rawEvent` (e.g. a proxy that has already stripped `encryptedValue`) + // must construct a new `ReasoningEncryptedValueEvent` directly. @override - StepFinishedEvent copyWith({ - String? stepName, + ReasoningEncryptedValueEvent copyWith({ + ReasoningEncryptedValueSubtype? subtype, + String? entityId, + String? encryptedValue, int? timestamp, - dynamic rawEvent, }) { - return StepFinishedEvent( - stepName: stepName ?? this.stepName, + // The three `?? this.field` reads are safe — unlike nullable fields that use + // the kUnsetSentinel discipline elsewhere, these are required non-nullable + // constructor parameters, so `this.subtype`, `this.entityId`, and + // `this.encryptedValue` are always non-null. Passing null for any of them + // silently preserves the existing value; it cannot clear a required field. + return ReasoningEncryptedValueEvent( + subtype: subtype ?? this.subtype, + entityId: entityId ?? this.entityId, + encryptedValue: encryptedValue ?? this.encryptedValue, timestamp: timestamp ?? this.timestamp, - rawEvent: rawEvent ?? this.rawEvent, + // rawEvent: always null — enforced by the constructor's super-initializer. ); } -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/internal/sse_constants.dart b/sdks/community/dart/lib/src/internal/sse_constants.dart new file mode 100644 index 0000000000..b2c12ab825 --- /dev/null +++ b/sdks/community/dart/lib/src/internal/sse_constants.dart @@ -0,0 +1,6 @@ +/// Default cap for SSE data buffers, shared by [SseParser] and +/// [EventStreamAdapter] to prevent the two layers from drifting apart. +/// +/// Measured in UTF-16 code units (Dart's internal string unit). ASCII has a +/// 1:1 ratio; supplementary characters (emoji, etc.) count as two. +const int kSseDefaultMaxDataCodeUnits = 8 * 1024 * 1024; diff --git a/sdks/community/dart/lib/src/internal/text.dart b/sdks/community/dart/lib/src/internal/text.dart new file mode 100644 index 0000000000..52f54a8ebb --- /dev/null +++ b/sdks/community/dart/lib/src/internal/text.dart @@ -0,0 +1,10 @@ +// Truncate [s] to at most [maxLen] UTF-16 code units, backing up by 1 if the +// cut falls on the high surrogate of a pair, to avoid emitting lone surrogates. +String safeTruncate(String s, int maxLen) { + if (maxLen <= 0) return ''; + if (s.length <= maxLen) return s; + var end = maxLen; + final cu = s.codeUnitAt(end - 1); + if (cu >= 0xD800 && cu <= 0xDBFF) end--; // high surrogate: back up + return s.substring(0, end); +} diff --git a/sdks/community/dart/lib/src/sse/backoff_strategy.dart b/sdks/community/dart/lib/src/sse/backoff_strategy.dart index af89b24cfe..06148e0777 100644 --- a/sdks/community/dart/lib/src/sse/backoff_strategy.dart +++ b/sdks/community/dart/lib/src/sse/backoff_strategy.dart @@ -4,7 +4,7 @@ import 'dart:math'; abstract class BackoffStrategy { /// Calculate the next delay based on attempt number. Duration nextDelay(int attempt); - + /// Reset the backoff state. void reset(); } @@ -31,15 +31,15 @@ class ExponentialBackoff implements BackoffStrategy { Duration nextDelay(int attempt) { // Calculate base delay with exponential backoff final baseDelayMs = initialDelay.inMilliseconds * pow(multiplier, attempt); - + // Cap at max delay final cappedDelayMs = min(baseDelayMs, maxDelay.inMilliseconds); - + // Add jitter (±jitterFactor * delay) final jitterRange = cappedDelayMs * jitterFactor; final jitter = (_random.nextDouble() * 2 - 1) * jitterRange; final finalDelayMs = max(0, cappedDelayMs + jitter); - + return Duration(milliseconds: finalDelayMs.round()); } @@ -57,7 +57,7 @@ class ExponentialBackoff implements BackoffStrategy { class LegacyBackoffStrategy implements BackoffStrategy { final ExponentialBackoff _delegate; int _attempt = 0; - + LegacyBackoffStrategy({ Duration initialDelay = const Duration(seconds: 1), Duration maxDelay = const Duration(seconds: 30), @@ -69,7 +69,7 @@ class LegacyBackoffStrategy implements BackoffStrategy { multiplier: multiplier, jitterFactor: jitterFactor, ); - + /// Calculate the next delay with exponential backoff and jitter (stateful). /// This is the legacy method that maintains internal state. Duration nextDelayStateful() { @@ -77,19 +77,19 @@ class LegacyBackoffStrategy implements BackoffStrategy { _attempt++; return delay; } - + @override Duration nextDelay(int attempt) => _delegate.nextDelay(attempt); - + @override void reset() { _attempt = 0; _delegate.reset(); } - + /// Get the current attempt number. int get attempt => _attempt; - + // Delegate getters for compatibility Duration get initialDelay => _delegate.initialDelay; Duration get maxDelay => _delegate.maxDelay; @@ -110,4 +110,4 @@ class ConstantBackoff implements BackoffStrategy { void reset() { // No state to reset } -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/sse/sse_client.dart b/sdks/community/dart/lib/src/sse/sse_client.dart index 64707dbd62..60f03552f8 100644 --- a/sdks/community/dart/lib/src/sse/sse_client.dart +++ b/sdks/community/dart/lib/src/sse/sse_client.dart @@ -11,19 +11,28 @@ class SseClient { final http.Client _httpClient; final Duration _idleTimeout; final BackoffStrategy _backoffStrategy; - + + /// Maximum number of UTF-16 code units allowed in a single SSE data block. + /// + /// Passed to [SseParser] so the parse-layer cap matches the adapter-layer + /// cap set on [EventStreamAdapter]. Defaults to 8 MiB (8 × 1024 × 1024 + /// code units), matching [SseParser]'s own default. + final int maxDataCodeUnits; + StreamController? _controller; StreamSubscription? _subscription; http.StreamedResponse? _currentResponse; Timer? _idleTimer; + Timer? _reconnectTimer; String? _lastEventId; Duration? _serverRetryDuration; bool _isClosed = false; bool _isConnecting = false; + bool _hasEverConnected = false; int _reconnectAttempt = 0; /// Creates a new SSE client. - /// + /// /// [httpClient] - The HTTP client to use for connections. /// [idleTimeout] - Maximum time to wait for data before reconnecting. /// [backoffStrategy] - Strategy for calculating reconnection delays. @@ -31,12 +40,22 @@ class SseClient { http.Client? httpClient, Duration idleTimeout = const Duration(seconds: 45), BackoffStrategy? backoffStrategy, + this.maxDataCodeUnits = 8 * 1024 * 1024, }) : _httpClient = httpClient ?? http.Client(), _idleTimeout = idleTimeout, - _backoffStrategy = backoffStrategy ?? LegacyBackoffStrategy(); + _backoffStrategy = backoffStrategy ?? LegacyBackoffStrategy() { + if (idleTimeout <= Duration.zero) { + throw ArgumentError.value( + idleTimeout, + 'idleTimeout', + 'idleTimeout must be positive; zero or negative values trigger an ' + 'immediate reconnect storm', + ); + } + } /// Connect to an SSE endpoint and return a stream of messages. - /// + /// /// [url] - The SSE endpoint URL. /// [headers] - Optional additional headers to send with the request. /// [requestTimeout] - Optional timeout for the initial connection. @@ -61,14 +80,19 @@ class SseClient { } /// Parse an existing byte stream as SSE messages. - /// + /// + /// **Stateless.** Creates a fresh [SseParser] per call; does not touch the + /// client's reconnection state (`_lastEventId`, `_reconnectAttempt`, + /// `_subscription`). Independent of [connect]; safe to call without a prior + /// [connect] call or concurrently with an active [connect] session. + /// /// [stream] - The byte stream to parse. /// [headers] - Optional response headers for context. Stream parseStream( Stream> stream, { Map? headers, }) { - final parser = SseParser(); + final parser = SseParser(maxDataCodeUnits: maxDataCodeUnits); return parser.parseBytes(stream); } @@ -79,9 +103,9 @@ class SseClient { Duration? requestTimeout, ) async { if (_isClosed || _isConnecting) return; - + _isConnecting = true; - + try { // Prepare headers final requestHeaders = { @@ -89,7 +113,7 @@ class SseClient { 'Cache-Control': 'no-cache', ...?headers, }; - + // Add Last-Event-ID header if we have one (for reconnection) if (_lastEventId != null) { requestHeaders['Last-Event-ID'] = _lastEventId!; @@ -98,33 +122,35 @@ class SseClient { // Create the request final request = http.Request('GET', url); request.headers.addAll(requestHeaders); - + // Send the request with optional timeout final responseFuture = _httpClient.send(request); final response = requestTimeout != null ? await responseFuture.timeout(requestTimeout) : await responseFuture; - + _currentResponse = response; - + // Check for successful response if (response.statusCode != 200) { - throw Exception('SSE connection failed with status ${response.statusCode}'); + throw Exception( + 'SSE connection failed with status ${response.statusCode}'); } - + // Reset backoff on successful connection _backoffStrategy.reset(); _reconnectAttempt = 0; - + _hasEverConnected = true; + // Create parser for this connection - final parser = SseParser(); - + final parser = SseParser(maxDataCodeUnits: maxDataCodeUnits); + // Set up idle timeout _resetIdleTimer(); - + // Parse the stream final messageStream = parser.parseBytes(response.stream); - + // Listen to messages _subscription?.cancel(); _subscription = messageStream.listen( @@ -133,15 +159,15 @@ class SseClient { if (message.id != null) { _lastEventId = message.id; } - + // Update retry duration if specified by server if (message.retry != null) { _serverRetryDuration = message.retry; } - + // Reset idle timer on each message _resetIdleTimer(); - + // Forward the message _controller?.add(message); }, @@ -153,7 +179,7 @@ class SseClient { }, cancelOnError: false, ); - + _isConnecting = false; } catch (error) { _isConnecting = false; @@ -180,7 +206,17 @@ class SseClient { Duration? requestTimeout, ) { if (_isClosed) return; - + + // Surface the first connection failure directly to the consumer rather than + // entering the reconnect loop — a server that never accepted the initial + // request is unlikely to accept a retry, and silently looping would mask + // the root cause from the caller. + if (!_hasEverConnected) { + _controller?.addError(error); + _controller?.close(); + return; + } + // Schedule reconnection if we have connection info if (url != null) { _scheduleReconnection(url, headers, requestTimeout); @@ -196,11 +232,11 @@ class SseClient { Duration? requestTimeout, ) { if (_isClosed) return; - + _idleTimer?.cancel(); _subscription?.cancel(); _currentResponse = null; - + // Schedule reconnection if we have connection info if (url != null) { _scheduleReconnection(url, headers, requestTimeout); @@ -214,13 +250,17 @@ class SseClient { Duration? requestTimeout, ) { if (_isClosed) return; - + // Calculate delay (use server retry if available, otherwise backoff) _reconnectAttempt++; - final delay = _serverRetryDuration ?? _backoffStrategy.nextDelay(_reconnectAttempt); - - // Schedule reconnection - Timer(delay, () { + final delay = + _serverRetryDuration ?? _backoffStrategy.nextDelay(_reconnectAttempt); + + // Schedule reconnection. Store the timer so close() can cancel it and + // avoid a connect() call racing against a concurrent close(). + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(delay, () { + _reconnectTimer = null; if (!_isClosed) { _connect(url, headers, requestTimeout); } @@ -230,9 +270,11 @@ class SseClient { /// Close the connection and clean up resources. Future close() async { if (_isClosed) return; - + _isClosed = true; _idleTimer?.cancel(); + _reconnectTimer?.cancel(); + _reconnectTimer = null; await _subscription?.cancel(); _currentResponse = null; await _controller?.close(); @@ -241,8 +283,9 @@ class SseClient { } /// Check if the client is currently connected. - bool get isConnected => _controller != null && !_isClosed && _currentResponse != null; + bool get isConnected => + _controller != null && !_isClosed && _currentResponse != null; /// Get the last event ID received. String? get lastEventId => _lastEventId; -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/sse/sse_message.dart b/sdks/community/dart/lib/src/sse/sse_message.dart index 87334e130c..776d720797 100644 --- a/sdks/community/dart/lib/src/sse/sse_message.dart +++ b/sdks/community/dart/lib/src/sse/sse_message.dart @@ -20,5 +20,6 @@ class SseMessage { }); @override - String toString() => 'SseMessage(event: $event, id: $id, data: $data, retry: $retry)'; -} \ No newline at end of file + String toString() => + 'SseMessage(event: $event, id: $id, data: $data, retry: $retry)'; +} diff --git a/sdks/community/dart/lib/src/sse/sse_parser.dart b/sdks/community/dart/lib/src/sse/sse_parser.dart index ae3f43afbf..a12ca185b6 100644 --- a/sdks/community/dart/lib/src/sse/sse_parser.dart +++ b/sdks/community/dart/lib/src/sse/sse_parser.dart @@ -1,18 +1,73 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:developer' as developer; +import '../internal/sse_constants.dart'; import 'sse_message.dart'; /// Parses Server-Sent Events according to the WHATWG specification. +/// +/// `SseParser` instances are intended to be **per-connection**. The +/// `_eventBuffer`, `_dataBuffer`, `_retry`, and `_hasDataField` fields +/// are reset between events via [_resetBuffers], but `_lastEventId` is +/// intentionally sticky across messages on the same connection (per the +/// SSE spec: the last `id:` field is preserved so a reconnecting client +/// can supply it via the `Last-Event-ID` request header). +/// +/// If you reuse a single `SseParser` instance across multiple +/// independent streams (e.g. in tests), `_lastEventId` carries across — +/// which is consistent with the spec's reconnection semantics but can +/// be surprising in test harnesses. Construct a fresh parser per stream +/// when you want clean isolation, or call [reset] to clear all parser +/// state including `_lastEventId`. The streaming-side counterpart in +/// `EventStreamAdapter.fromRawSseStream` keeps its parsing state in +/// per-invocation locals and does not have this concern. class SseParser { + /// Maximum number of UTF-16 code units the `_dataBuffer` may accumulate + /// before a message is dispatched. Prevents a malicious or misbehaving SSE + /// producer from growing the buffer without bound across `data:` lines, + /// causing an OOM before the terminating blank line arrives. + /// + /// **Note:** The cap is measured in UTF-16 code units (Dart's internal string + /// unit), not UTF-8 bytes. ASCII content has a 1:1 ratio; BMP characters + /// outside ASCII still count as one code unit; supplementary characters + /// (emoji, etc.) count as two. For most JSON payloads the difference is + /// negligible, but the name reflects what is actually measured. + /// + /// Default: 8 MiB (8 × 1024 × 1024 code units). Adjust via the + /// [SseParser.new] constructor when your use-case legitimately requires + /// larger payloads. + final int maxDataCodeUnits; + + /// Maximum number of UTF-16 code units the `id:` field value may contain. + /// Caps the sticky `_lastEventId` value to prevent a malicious server from + /// growing the stored id across reconnects via an oversized `id:` line. + static const int maxIdCodeUnits = 1024; + + // `_eventBuffer` stores the SSE `event:` field for the current message. + // Unlike `_dataBuffer`, it is REPLACED (not appended) on each `event:` line + // per the WHATWG SSE spec, so its maximum size is bounded by the line + // splitter upstream rather than accumulating across lines. Only `_dataBuffer` + // needs an explicit `maxDataCodeUnits` cap because it accumulates across multiple + // `data:` lines within a single message. final _eventBuffer = StringBuffer(); final _dataBuffer = StringBuffer(); String? _lastEventId; Duration? _retry; bool _hasDataField = false; + SseParser({this.maxDataCodeUnits = kSseDefaultMaxDataCodeUnits}); + + /// Clears all parser state, including the otherwise-sticky + /// `_lastEventId`. Use when reusing a parser instance across + /// independent streams that should not share reconnection state. + void reset() { + _resetBuffers(); + _lastEventId = null; + } + /// Parses SSE data and yields messages. - /// + /// /// The input should be a stream of text lines from an SSE endpoint. /// Empty lines trigger message dispatch. Stream parseLines(Stream lines) async* { @@ -22,7 +77,7 @@ class SseParser { yield message; } } - + // Dispatch any remaining buffered message final finalMessage = _dispatchEvent(); if (finalMessage != null) { @@ -31,23 +86,30 @@ class SseParser { } /// Parses raw bytes from an SSE stream. + /// + /// Routes through [parseLines] so the end-of-stream flush in + /// [parseLines] also fires here — a byte source that closes without + /// a trailing blank line still emits its final buffered event. Stream parseBytes(Stream> bytes) { - return utf8.decoder + // Per WHATWG SSE spec the BOM is stripped once at the very start of the + // stream, not from every line. A mid-stream U+FEFF that happens to be the + // first character of a data line would otherwise be silently consumed. + var firstLine = true; + final lines = utf8.decoder .bind(bytes) .transform(const LineSplitter()) .transform(StreamTransformer.fromHandlers( - handleData: (String line, EventSink sink) { - // Remove BOM if present at the start - if (line.isNotEmpty && line.codeUnitAt(0) == 0xFEFF) { - line = line.substring(1); - } - sink.add(line); - }, - )) - .asyncExpand((String line) { - final message = _processLine(line); - return message != null ? Stream.value(message) : Stream.empty(); - }); + handleData: (String line, EventSink sink) { + if (firstLine) { + firstLine = false; + if (line.isNotEmpty && line.codeUnitAt(0) == 0xFEFF) { + line = line.substring(1); + } + } + sink.add(line); + }, + )); + return parseLines(lines); } /// Process a single line according to SSE spec. @@ -85,20 +147,84 @@ class SseParser { void _processField(String field, String value) { switch (field) { case 'event': - _eventBuffer.write(value); + // Per WHATWG: "If the field name is 'event', set the event type + // buffer to field value." The buffer is REPLACED on each `event:` + // line, not appended to. The previous `_eventBuffer.write(value)` + // concatenated repeated `event:` lines within a single dispatch + // block — spec-non-compliant and divergent from the canonical + // SDKs. + // Defense-in-depth cap: a single oversized `event:` line cannot + // allocate unbounded memory before the dispatch blank line arrives. + // The cap mirrors the `data:` path in the same method. + if (value.length > maxDataCodeUnits) { + _resetBuffers(); + throw FormatException( + 'SSE event field exceeds $maxDataCodeUnits-code-unit limit ' + '(${value.length} code units)', + ); + } + _eventBuffer + ..clear() + ..write(value); break; case 'data': - _hasDataField = true; - if (_dataBuffer.isNotEmpty) { - _dataBuffer.writeln(); // Add newline between data fields + // Per WHATWG: every `data:` field appends `\n` BEFORE its value + // (the trailing `\n` is then stripped at dispatch). The previous + // `_dataBuffer.isNotEmpty` heuristic skipped the leading `\n` + // when the first `data:` line was empty, collapsing + // `data:\ndata: x` to `"x"` instead of the spec-correct `"\nx"`. + // Use `_hasDataField` to track "have we already received a + // `data:` field in this block?" — which is the actual + // spec-mandated condition. Mirrors the `inDataBlock` flag pattern + // in `EventStreamAdapter.appendDataLine`. + // Guard against unbounded growth from a malicious/misbehaving + // producer. Reject the entire message if the accumulated data + // would exceed [maxDataCodeUnits], reset buffers, and throw so the + // caller's stream adapter can surface a structured error instead + // of quietly OOM-ing. + final newlineBytes = + _hasDataField ? 1 : 0; // \n separator between lines + if (_dataBuffer.length + newlineBytes + value.length > + maxDataCodeUnits) { + _resetBuffers(); + throw FormatException( + 'SSE data field exceeds $maxDataCodeUnits-code-unit limit ' + '(current ${_dataBuffer.length} + incoming ' + '${newlineBytes + value.length} code units)', + ); + } + if (_hasDataField) { + _dataBuffer.write('\n'); // explicit \n, not platform line terminator } + _hasDataField = true; _dataBuffer.write(value); break; case 'id': - // id field doesn't contain newlines - if (!value.contains('\n') && !value.contains('\r')) { - _lastEventId = value; + // Per WHATWG SSE spec: id values must not contain \n, \r, or \x00 + // (NUL). NUL-bearing ids are silently ignored and the prior + // `_lastEventId` survives unchanged. Cap at ≤1024 UTF-16 code units + // (~1–4 KB on the wire depending on encoding) to prevent a malicious + // server from growing the stored value across reconnects via an + // oversized `id:` line (the value persists for the lifetime of the + // connection and propagates via `Last-Event-ID` headers). + if (value.contains('\n') || + value.contains('\r') || + value.contains('\x00')) { + // Spec-mandated silent drop — no log needed. + break; + } + if (value.length > maxIdCodeUnits) { + // Defense-in-depth cap (non-spec). Log so operators can detect + // misbehaving SSE producers. `_lastEventId` is NOT updated; the + // prior value is preserved (used as Last-Event-ID on reconnect). + developer.log( + 'SSE id field dropped: length ${value.length} exceeds ' + 'maxIdCodeUnits ($maxIdCodeUnits). _lastEventId not updated.', + name: 'ag_ui.sse_parser', + ); + break; } + _lastEventId = value; break; case 'retry': final milliseconds = int.tryParse(value); @@ -118,7 +244,7 @@ class SseParser { // to dispatch an event. An empty data buffer means no 'data' field was received. // However, 'data' field with empty value should still dispatch (with empty string). // We track this by checking if the data buffer has been written to at all. - + // For simplicity, we'll dispatch if we have any event-related fields set // but only if at least one data field was received (even if empty) if (!_hasDataField) { @@ -148,4 +274,4 @@ class SseParser { /// Gets the last event ID (for reconnection). String? get lastEventId => _lastEventId; -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/types/base.dart b/sdks/community/dart/lib/src/types/base.dart index 4a44a96030..643548036a 100644 --- a/sdks/community/dart/lib/src/types/base.dart +++ b/sdks/community/dart/lib/src/types/base.dart @@ -6,6 +6,8 @@ library; import 'dart:convert'; +import '../internal/text.dart'; + /// Base class for all AG-UI models with JSON serialization support. /// /// All protocol models extend this class to provide consistent JSON @@ -33,48 +35,98 @@ mixin TypeDiscriminator { String get type; } -/// Represents a validation error during JSON decoding. +/// Base exception for AG-UI protocol errors. /// -/// Thrown when JSON data does not match the expected schema for -/// AG-UI protocol models. -class AGUIValidationError implements Exception { +/// The root exception class for all AG-UI protocol-related errors. +/// `AgUiError` (lib/src/client/errors.dart) and [AGUIValidationError] +/// both extend this class — so callers can catch the entire SDK error +/// surface with `on AGUIError`. Catching `on AgUiError` covers +/// transport / decoder / runtime errors but NOT direct-factory +/// `AGUIValidationError`. See README → "Errors" for the catch-recipe. +class AGUIError implements Exception { + /// Human-readable error message. final String message; + + const AGUIError(this.message); + + @override + String toString() => 'AGUIError: $message'; +} + +/// Represents a validation error during JSON decoding. +/// +/// Thrown by `fromJson` factories at the wire-decoding boundary. Extends +/// [AGUIError] so `on AGUIError` catches both factory-side and +/// runtime-side failures uniformly. The separate `ValidationError` in +/// `lib/src/client/errors.dart` is thrown by `Validators.requireNonEmpty` +/// inside `EventDecoder.validate`. When events are decoded through the +/// public [EventDecoder] pipeline, both classes are caught and re-thrown +/// as `DecodingError` — see `decoder.dart` for the wrapping logic. Direct +/// callers of `Event.fromJson` see this `AGUIValidationError` directly. +class AGUIValidationError extends AGUIError { final String? field; final dynamic value; + + /// The originating JSON payload that failed validation. + /// + /// **Sensitive-data warning.** This carries the entire wire payload + /// the factory was given, including cipher fields like + /// `encryptedValue` / `encrypted_value` on the + /// `REASONING_ENCRYPTED_VALUE` / `ToolMessage` / `ReasoningMessage` / + /// `BaseMessage` decode paths. The default `toString()` does NOT emit + /// this field, so error printing is safe by default — but consumers + /// that reflect-serialize errors (e.g. + /// `log.error('decode failed', extra: {'error': error})` with a + /// reflection-based serializer) will leak the cipher payload. For + /// log lines shipped to external sinks, prefer [field] and [value] + /// over [json]. final Map? json; + /// Originating exception, if this validation error was raised in + /// response to another error (e.g. a wrong-typed field caught inside a + /// `transform` callback). Preserves structured info that would + /// otherwise be flattened by `'$e'` interpolation. + final Object? cause; + const AGUIValidationError({ - required this.message, + required String message, this.field, this.value, this.json, - }); + this.cause, + }) : super(message); @override String toString() { final buffer = StringBuffer('AGUIValidationError: $message'); if (field != null) buffer.write(' (field: $field)'); - if (value != null) buffer.write(' (value: $value)'); + if (value != null) { + final valueStr = value.toString(); + final excerpt = valueStr.length > 100 + ? '${safeTruncate(valueStr, 100)}...' + : valueStr; + buffer.write(' (value: $excerpt)'); + } + if (cause != null) buffer.write('\nCaused by: $cause'); return buffer.toString(); } } -/// Base exception for AG-UI protocol errors. -/// -/// The root exception class for all AG-UI protocol-related errors. -class AGUIError implements Exception { - final String message; - - const AGUIError(this.message); - - @override - String toString() => 'AGUIError: $message'; -} - /// Utility for tolerant JSON decoding that ignores unknown fields. /// /// Provides helper methods for safely extracting and validating fields /// from JSON maps, with proper error handling. +/// +/// camelCase/snake_case parity is handled by [requireEitherField] and +/// [optionalEitherField] for keys whose two spellings differ — +/// e.g. `messageId` / `message_id`, `toolCallId` / `tool_call_id`, +/// `parentRunId` / `parent_run_id`. Single-word keys whose camelCase and +/// snake_case spellings are identical (`delta`, `name`, `title`, +/// `replace`, `content`, `value`, `event`, `source`, `code`, `subtype`, +/// `messages`, `patch`, `snapshot`, `role`, `result`, `input`, +/// `timestamp`, `details`, `error`, `state`) are read with the bare +/// [requireField] / [optionalField] helpers — they don't need +/// `*EitherField` because there's no second spelling to fall back to. class JsonDecoder { /// Safely extracts a required field from JSON. static T requireField( @@ -103,19 +155,23 @@ class JsonDecoder { if (transform != null) { try { return transform(value); + } on AGUIError { + rethrow; } catch (e) { throw AGUIValidationError( message: 'Failed to transform field: $e', field: field, value: value, json: json, + cause: e, ); } } if (value is! T) { throw AGUIValidationError( - message: 'Field has incorrect type. Expected $T, got ${value.runtimeType}', + message: + 'Field has incorrect type. Expected $T, got ${value.runtimeType}', field: field, value: value, json: json, @@ -136,23 +192,27 @@ class JsonDecoder { } final value = json[field]; - + if (transform != null) { try { return transform(value); + } on AGUIError { + rethrow; } catch (e) { throw AGUIValidationError( message: 'Failed to transform field: $e', field: field, value: value, json: json, + cause: e, ); } } if (value is! T) { throw AGUIValidationError( - message: 'Field has incorrect type. Expected $T, got ${value.runtimeType}', + message: + 'Field has incorrect type. Expected $T, got ${value.runtimeType}', field: field, value: value, json: json, @@ -162,33 +222,255 @@ class JsonDecoder { return value; } + /// Reads a required field that may arrive under either of two keys. + /// + /// Servers in this protocol use camelCase (TypeScript) or snake_case + /// (Python) field names interchangeably. Resolution is by KEY PRESENCE + /// via `containsKey` — matching the rule documented on + /// [optionalEitherField]: + /// • If [camelKey] is present (even when its value is explicitly + /// `null`), [camelKey] wins and [snakeKey] is NOT consulted. + /// • [snakeKey] is consulted ONLY when [camelKey] is entirely absent. + /// + /// If neither key resolves to a non-null value, throws an + /// [AGUIValidationError] naming BOTH keys — avoiding the misleading + /// "missing message_id" error when the caller actually sent `messageId`. + /// + /// Note on short-circuit behavior: if [camelKey] is present but holds + /// a wrong-typed value, [optionalField] throws and the [snakeKey] + /// fallback is NOT attempted — a payload that carries both keys with + /// conflicting types is a protocol violation, and surfacing the type + /// error at [camelKey] is more useful than silently rescuing via the + /// snake_case alias. The same rule applies to [optionalEitherField]. + static T requireEitherField( + Map json, + String camelKey, + String snakeKey, + ) { + if (json.containsKey(camelKey)) { + final v = optionalField(json, camelKey); + if (v == null) { + throw AGUIValidationError( + message: 'Required field "$camelKey" is present but null', + field: camelKey, + json: json, + ); + } + return v; + } + if (json.containsKey(snakeKey)) { + final v = optionalField(json, snakeKey); + if (v == null) { + throw AGUIValidationError( + message: 'Required field "$snakeKey" is present but null', + field: snakeKey, + json: json, + ); + } + return v; + } + throw AGUIValidationError( + message: 'Missing required field "$camelKey" (or "$snakeKey")', + field: camelKey, + json: json, + ); + } + + /// Reads an optional field that may arrive under either of two keys. + /// + /// Resolution is by KEY presence, matching the contract documented on + /// [requireEitherField]: if `camelKey` is present in `json` (even when + /// its value is explicitly `null`), the camelCase value wins. + /// `snakeKey` is consulted only when `camelKey` is entirely absent. + /// + /// This `containsKey` rule replaced the prior `??`-chain implementation, + /// which fell through to `snakeKey` whenever the camelCase value was + /// `null`-or-absent — silently overriding an explicit-null camelCase + /// payload with a populated snake_case one. + /// + /// **Error field name note.** When the snake_case path is taken (camelKey + /// absent) and a type mismatch occurs, [optionalField] reports the error + /// using [snakeKey] as the field name — the wire spelling, not the + /// canonical camelCase name. Callers that need to report the canonical + /// name in error messages should catch [AGUIValidationError] and remap + /// `field` to [camelKey] themselves. + /// + /// **Consumer guidance for error-driven field routing.** If you write an + /// error handler that matches on `e.field` to route errors by field name + /// (e.g. `if (e.field == 'toolCallId') ...`), be aware that the error may + /// carry the snake_case spelling (`'tool_call_id'`) when the Python-side wire + /// payload was the one that failed validation. Match both spellings or use + /// a prefix/contains check to stay wire-format agnostic. + static T? optionalEitherField( + Map json, + String camelKey, + String snakeKey, + ) { + if (json.containsKey(camelKey)) { + return optionalField(json, camelKey); + } + return optionalField(json, snakeKey); + } + + /// Reads an optional integer field, accepting either `int` or `num` + /// on the wire. + /// + /// JS/TS producers serialize all numbers through a single Number type, + /// so a server emitting `Date.now() / 1000` (or any fractional value) + /// arrives in Dart as `double`. `optionalField` rejects that with + /// `AGUIValidationError` even when the value is integer-shaped. This + /// helper accepts any `num` and coerces via `.floor()`, matching + /// TS `Math.floor` rounding semantics (rounds toward −∞ for negative + /// values, identical to `.toInt()` for non-negative). + /// + /// Non-finite `num` values (`NaN`, `±Infinity`) are rejected with an + /// `AGUIValidationError` rather than letting `.floor()` throw a raw + /// `UnsupportedError` — keeping all decode failures in the AG-UI error + /// hierarchy. + static int? optionalIntField( + Map json, + String field, + ) { + if (!json.containsKey(field) || json[field] == null) return null; + final value = json[field]; + if (value is num) { + if (value.isNaN || value.isInfinite) { + throw AGUIValidationError( + message: 'Field is non-finite (NaN or Infinity)', + field: field, + value: value, + json: json, + ); + } + // Guard BEFORE the `is int` fast-return: on Dart-on-JS every number is + // a 64-bit double, so `1.0 is int` is `true`. Without this ordering the + // 2^53 check would be bypassed for any double-valued integer that happens + // to pass `is int` — the guard must come first so the range check always + // executes regardless of platform. 2^53 is the largest integer exactly + // representable as a 64-bit IEEE 754 double. + const maxSafeInt = 9007199254740992; // 2^53 + if (value > maxSafeInt || value < -maxSafeInt) { + throw AGUIValidationError( + message: 'Field value out of safe int range (±2^53)', + field: field, + value: value, + json: json, + ); + } + if (value is int) return value; + return value.floor(); + } + throw AGUIValidationError( + message: + 'Field has incorrect type. Expected int or num, got ${value.runtimeType}', + field: field, + value: value, + json: json, + ); + } + + /// Cipher-safe variant of [optionalIntField]. + /// + /// Identical in behavior but intentionally omits `json:` from every thrown + /// [AGUIValidationError]. Use this on event factories (e.g. + /// `ReasoningEncryptedValueEvent.fromJson`) where the `json` map may contain + /// an `encryptedValue` cipher field. Including `json:` on those error paths + /// would surface the raw cipher payload via + /// `AGUIValidationError.json` — the exact leakage that + /// `_requireCipherSafeString` and the factory's `rawEvent: null` pin are + /// designed to prevent. + /// + /// **All cipher-safe helpers a new cipher-bearing event factory must use:** + /// - [_requireCipherSafeString] — required string field without json: leak + /// - [optionalCipherSafeIntField] — optional int field without json: leak + /// - Set `rawEvent: null` unconditionally in the factory return (or + /// conditionally via a `hasCipher` predicate like MessagesSnapshotEvent) + /// - Throw [AGUIValidationError] without `json:` on every error path + /// + /// If a new helper is needed for a different type (e.g. cipher-safe bool or + /// list), add it here with an identical json:-omitting pattern and list it + /// in this block. + static int? optionalCipherSafeIntField( + Map json, + String field, + ) { + if (!json.containsKey(field) || json[field] == null) return null; + final value = json[field]; + if (value is num) { + if (value.isNaN || value.isInfinite) { + throw AGUIValidationError( + message: 'Field is non-finite (NaN or Infinity)', + field: field, + value: value, + // Intentionally omit json: — payload may carry cipher data. + ); + } + const maxSafeInt = 9007199254740992; // 2^53 + if (value > maxSafeInt || value < -maxSafeInt) { + throw AGUIValidationError( + message: 'Field value out of safe int range (±2^53)', + field: field, + value: value, + // Intentionally omit json: — payload may carry cipher data. + ); + } + if (value is int) return value; + return value.floor(); + } + throw AGUIValidationError( + message: + 'Field has incorrect type. Expected int or num, got ${value.runtimeType}', + field: field, + value: value, + // Intentionally omit json: — payload may carry cipher data. + ); + } + /// Safely extracts a list field from JSON. + /// + /// Use this when the elements have a concrete element type that the SDK + /// strongly types (`requireListField>` for nested + /// records, etc.) — the inner per-element type check provides the type + /// safety. Wrong-typed elements raise [AGUIValidationError] eagerly with + /// `field: '$field[$i]'` so the decoder pipeline can preserve the + /// originating index instead of flattening to a generic `field: 'json'`. + /// For loosely-typed payloads where the elements are intentionally + /// `dynamic`, prefer `requireField>` to avoid an + /// unnecessary check. static List requireListField( Map json, String field, { T Function(dynamic)? itemTransform, }) { final list = requireField>(json, field); - + if (itemTransform != null) { - return list.map((item) { + final out = []; + for (var i = 0; i < list.length; i++) { try { - return itemTransform(item); + out.add(itemTransform(list[i])); } catch (e) { throw AGUIValidationError( - message: 'Failed to transform list item: $e', - field: field, - value: item, + message: 'Failed to transform list item', + field: '$field[$i]', + value: list[i], json: json, + cause: e, ); } - }).toList(); + } + return out; } - return list.cast(); + return _eagerCast(list, field, json); // view, not copy } /// Safely extracts an optional list field from JSON. + /// + /// Mirrors [requireListField]'s eager element-type validation when no + /// transform is supplied, so a malformed list element raises + /// [AGUIValidationError] with the originating index instead of leaking + /// a `TypeError` to the decoder catch-all. static List? optionalListField( Map json, String field, { @@ -196,41 +478,173 @@ class JsonDecoder { }) { final list = optionalField>(json, field); if (list == null) return null; - + if (itemTransform != null) { - return list.map((item) { + final out = []; + for (var i = 0; i < list.length; i++) { try { - return itemTransform(item); + out.add(itemTransform(list[i])); } catch (e) { throw AGUIValidationError( - message: 'Failed to transform list item: $e', - field: field, - value: item, + message: 'Failed to transform list item', + field: '$field[$i]', + value: list[i], json: json, + cause: e, ); } - }).toList(); + } + return out; } + return _eagerCast(list, field, json); // view, not copy + } + + /// Reads an optional list field that may arrive under either of two + /// keys, with the same eager element-type validation as + /// [optionalListField] / [requireListField]. + /// + /// Composes the dual-key resolution rule from [optionalEitherField] + /// (camelCase wins when present, even when the list is empty; snake_case + /// is consulted ONLY when camelCase is absent) with the index-aware + /// element-type errors from [_eagerCast]. Use this when a list-shaped + /// field has both camelCase and snake_case wire spellings AND the + /// elements have a concrete type the SDK strongly types. + /// + /// The behavior matches [optionalListField] when [itemTransform] is + /// supplied: the transform is wrapped in a per-element try/catch + /// producing an [AGUIValidationError] with `field: '$resolvedKey[$i]'`. + /// Without [itemTransform], element type mismatches are reported with + /// `field: '$camelKey[$i]'`. + static List? optionalEitherListField( + Map json, + String camelKey, + String snakeKey, { + T Function(dynamic)? itemTransform, + }) { + // Resolve the wire spelling BEFORE calling optionalEitherField so that + // error messages produced by _eagerCast (and itemTransform errors) use + // the key that was actually present on the wire — matching the contract + // documented on optionalEitherField (snakeKey wins when camelKey absent). + final resolvedKey = json.containsKey(camelKey) ? camelKey : snakeKey; + final list = optionalEitherField>(json, camelKey, snakeKey); + if (list == null) return null; + + if (itemTransform != null) { + final out = []; + for (var i = 0; i < list.length; i++) { + try { + out.add(itemTransform(list[i])); + } catch (e) { + throw AGUIValidationError( + message: 'Failed to transform list item', + field: '$resolvedKey[$i]', + value: list[i], + json: json, + cause: e, + ); + } + } + return out; + } + + return _eagerCast(list, resolvedKey, json); // view, not copy + } + + /// Eagerly validates element types in a list and returns a typed view. + /// + /// Replaces `list.cast()`'s lazy view (which raises a raw `TypeError` + /// at access time, swallowed by the decoder catch-all and flattened to + /// `field: 'json'`) with a fail-fast loop that names the bad index. + /// + /// **Field-naming convention**: errors report `'$field[$i]'` (e.g. + /// `"messages[2]"`). Per-factory list decoders that re-wrap validation + /// errors from nested factories use a more precise `'$field[$i].$nestedField'` + /// form (e.g. `"messages[2].role"`) — `_eagerCast` cannot do this + /// because it only checks the element's Dart type, not its internal shape. + /// + /// **View semantics**: returns a lazy `cast()` view over the original + /// `List`, not a new copy. This avoids a second O(n) allocation + /// on hot paths (MESSAGES_SNAPSHOT, StateDelta, etc.), but callers must + /// not mutate the original list after receiving the view — a mutation that + /// introduces a wrong-typed element would bypass this validation and raise + /// a raw `TypeError` at access time. All current call sites consume the + /// result immediately and do not retain the original reference. + static List _eagerCast( + List list, + String field, + Map json, + ) { + // Validate-then-cast: iterate once to emit structured errors, then return + // a lazy cast view instead of copying into a new list — avoids a second + // O(n) allocation on the hot path (MESSAGES_SNAPSHOT, StateDelta, etc.). + for (var i = 0; i < list.length; i++) { + final item = list[i]; + if (item is! T) { + throw AGUIValidationError( + message: + 'List item has incorrect type. Expected $T, got ${item.runtimeType}', + field: '$field[$i]', + value: item, + json: json, + ); + } + } return list.cast(); } } +/// Shared sentinel for `copyWith` methods across all AG-UI type families. +/// +/// Each copyWith that guards a nullable field uses `Object? field = kUnsetSentinel` +/// and checks `identical(field, kUnsetSentinel)` to distinguish "argument +/// omitted" (preserve current value) from "argument explicitly null" (clear +/// the field). The class is private to prevent re-construction — the only valid +/// sentinel is this canonical constant. +class _CopyWithSentinel { + const _CopyWithSentinel(); +} + +/// Single shared sentinel instance used across all AG-UI `copyWith` methods. +/// +/// This constant IS part of the public API — it is exported from `ag_ui.dart`. +/// The backing type ([_CopyWithSentinel]) is intentionally private to prevent +/// re-construction; the only valid sentinel is this canonical constant. +/// +/// External consumers can use `identical(field, kUnsetSentinel)` to test for +/// the sentinel, but cannot declare method parameters with the private backing +/// type in their own libraries. To implement sentinel semantics in an external +/// `copyWith`, declare the parameter as `Object?` and test with +/// `identical(field, kUnsetSentinel)`: +/// ```dart +/// MyType copyWith({Object? nullableField = kUnsetSentinel}) { +/// final resolved = identical(nullableField, kUnsetSentinel) +/// ? this.nullableField +/// : nullableField as TargetType?; +/// return MyType(nullableField: resolved); +/// } +/// ``` +const _CopyWithSentinel kUnsetSentinel = _CopyWithSentinel(); + /// Converts snake_case to camelCase String snakeToCamel(String snake) { final parts = snake.split('_'); if (parts.isEmpty) return snake; - - return parts.first + - parts.skip(1).map((part) => - part.isEmpty ? '' : part[0].toUpperCase() + part.substring(1) - ).join(); + + return parts.first + + parts + .skip(1) + .map((part) => + part.isEmpty ? '' : part[0].toUpperCase() + part.substring(1)) + .join(); } /// Converts camelCase to snake_case String camelToSnake(String camel) { - return camel.replaceAllMapped( - RegExp(r'[A-Z]'), - (match) => '_${match.group(0)!.toLowerCase()}', - ).replaceFirst(RegExp(r'^_'), ''); -} \ No newline at end of file + return camel + .replaceAllMapped( + RegExp(r'[A-Z]'), + (match) => '_${match.group(0)!.toLowerCase()}', + ) + .replaceFirst(RegExp(r'^_'), ''); +} diff --git a/sdks/community/dart/lib/src/types/context.dart b/sdks/community/dart/lib/src/types/context.dart index 849045ebb5..19fa9c6fdd 100644 --- a/sdks/community/dart/lib/src/types/context.dart +++ b/sdks/community/dart/lib/src/types/context.dart @@ -5,6 +5,9 @@ import 'base.dart'; import 'message.dart'; import 'tool.dart'; +// `kUnsetSentinel` (from `base.dart`) is the shared sentinel for all +// `copyWith` methods in this file. + /// Additional context for the agent class Context extends AGUIModel { final String description; @@ -24,9 +27,9 @@ class Context extends AGUIModel { @override Map toJson() => { - 'description': description, - 'value': value, - }; + 'description': description, + 'value': value, + }; @override Context copyWith({ @@ -40,10 +43,15 @@ class Context extends AGUIModel { } } -/// Input for running an agent +/// Input for running an agent. +/// +/// The optional [parentRunId] mirrors the canonical TS/Python +/// `RunAgentInput.parentRunId` / `parent_run_id` field; it links the +/// run to a parent run in nested-run scenarios. class RunAgentInput extends AGUIModel { final String threadId; final String runId; + final String? parentRunId; final dynamic state; final List messages; final List tools; @@ -53,6 +61,7 @@ class RunAgentInput extends AGUIModel { const RunAgentInput({ required this.threadId, required this.runId, + this.parentRunId, this.state, required this.messages, required this.tools, @@ -61,76 +70,160 @@ class RunAgentInput extends AGUIModel { }); factory RunAgentInput.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final threadId = JsonDecoder.optionalField(json, 'threadId') ?? - JsonDecoder.optionalField(json, 'thread_id'); - final runId = JsonDecoder.optionalField(json, 'runId') ?? - JsonDecoder.optionalField(json, 'run_id'); - - if (threadId == null) { - throw AGUIValidationError( - message: 'Missing required field: threadId or thread_id', - field: 'threadId', - json: json, - ); - } - if (runId == null) { - throw AGUIValidationError( - message: 'Missing required field: runId or run_id', - field: 'runId', - json: json, - ); - } - return RunAgentInput( - threadId: threadId, - runId: runId, - state: json['state'], - messages: JsonDecoder.requireListField>( + threadId: JsonDecoder.requireEitherField( json, - 'messages', - ).map((item) => Message.fromJson(item)).toList(), - tools: JsonDecoder.requireListField>( + 'threadId', + 'thread_id', + ), + runId: JsonDecoder.requireEitherField( json, - 'tools', - ).map((item) => Tool.fromJson(item)).toList(), - context: JsonDecoder.requireListField>( + 'runId', + 'run_id', + ), + parentRunId: JsonDecoder.optionalEitherField( json, - 'context', - ).map((item) => Context.fromJson(item)).toList(), - forwardedProps: json['forwardedProps'] ?? json['forwarded_props'], + 'parentRunId', + 'parent_run_id', + ), + state: json['state'], + messages: () { + final raw = JsonDecoder.requireListField>( + json, + 'messages', + ); + final out = []; + for (var i = 0; i < raw.length; i++) { + try { + out.add(Message.fromJson(raw[i])); + } on AGUIValidationError catch (e) { + // Drop json: — the inner payload may carry encryptedValue or tool + // arguments. Preserve cause: when the inner error already cleared + // its own json: (e.json == null), meaning the inner factory was + // cipher-aware and the cause chain is safe to forward. + throw AGUIValidationError( + message: e.message, + field: 'messages[$i].${e.field ?? 'unknown'}', + value: e.value, + cause: e.json == null ? e : null, + ); + } catch (e) { + throw AGUIValidationError( + message: 'Failed to decode message at index $i: $e', + field: 'messages[$i]', + cause: e, + ); + } + } + return out; + }(), + tools: () { + final raw = JsonDecoder.requireListField>( + json, + 'tools', + ); + final out = []; + for (var i = 0; i < raw.length; i++) { + try { + out.add(Tool.fromJson(raw[i])); + } on AGUIValidationError catch (e) { + // Drop json: — tool arguments may be sensitive. Preserve cause: + // when e.json == null (inner factory already scrubbed it). + throw AGUIValidationError( + message: e.message, + field: 'tools[$i].${e.field ?? 'unknown'}', + value: e.value, + cause: e.json == null ? e : null, + ); + } catch (e) { + throw AGUIValidationError( + message: 'Failed to decode tool at index $i: $e', + field: 'tools[$i]', + cause: e, + ); + } + } + return out; + }(), + context: () { + final raw = JsonDecoder.requireListField>( + json, + 'context', + ); + final out = []; + for (var i = 0; i < raw.length; i++) { + try { + out.add(Context.fromJson(raw[i])); + } on AGUIValidationError catch (e) { + // Drop json: — context values may carry sensitive data. Preserve + // cause: when e.json == null (inner factory already scrubbed it). + throw AGUIValidationError( + message: e.message, + field: 'context[$i].${e.field ?? 'unknown'}', + value: e.value, + cause: e.json == null ? e : null, + ); + } catch (e) { + throw AGUIValidationError( + message: 'Failed to decode context at index $i: $e', + field: 'context[$i]', + cause: e, + ); + } + } + return out; + }(), + // `forwardedProps` is intentionally `dynamic` (any JSON shape), + // so the inline KEY-presence chain is preferred over + // `optionalEitherField` (which requires a concrete `T`). Behavior + // matches the helper: `camelKey` wins when the key is present (even + // when its value is explicitly `null`); `snake_case` is consulted + // ONLY when camelCase is entirely absent. + forwardedProps: json.containsKey('forwardedProps') + ? json['forwardedProps'] + : json['forwarded_props'], ); } @override Map toJson() => { - 'threadId': threadId, - 'runId': runId, - if (state != null) 'state': state, - 'messages': messages.map((m) => m.toJson()).toList(), - 'tools': tools.map((t) => t.toJson()).toList(), - 'context': context.map((c) => c.toJson()).toList(), - if (forwardedProps != null) 'forwardedProps': forwardedProps, - }; + 'threadId': threadId, + 'runId': runId, + if (parentRunId != null) 'parentRunId': parentRunId, + if (state != null) 'state': state, + 'messages': messages.map((m) => m.toJson()).toList(), + 'tools': tools.map((t) => t.toJson()).toList(), + 'context': context.map((c) => c.toJson()).toList(), + if (forwardedProps != null) 'forwardedProps': forwardedProps, + }; + // `parentRunId`, `state`, and `forwardedProps` are nullable — + // sentinel lets callers clear them explicitly via `copyWith(field: null)`. + // Mirrors the message-class sentinel in lib/src/types/message.dart. @override RunAgentInput copyWith({ String? threadId, String? runId, - dynamic state, + Object? parentRunId = kUnsetSentinel, + Object? state = kUnsetSentinel, List? messages, List? tools, List? context, - dynamic forwardedProps, + Object? forwardedProps = kUnsetSentinel, }) { return RunAgentInput( threadId: threadId ?? this.threadId, runId: runId ?? this.runId, - state: state ?? this.state, + parentRunId: identical(parentRunId, kUnsetSentinel) + ? this.parentRunId + : parentRunId as String?, + state: identical(state, kUnsetSentinel) ? this.state : state, messages: messages ?? this.messages, tools: tools ?? this.tools, context: context ?? this.context, - forwardedProps: forwardedProps ?? this.forwardedProps, + forwardedProps: identical(forwardedProps, kUnsetSentinel) + ? this.forwardedProps + : forwardedProps, ); } } @@ -148,54 +241,42 @@ class Run extends AGUIModel { }); factory Run.fromJson(Map json) { - // Handle both camelCase and snake_case field names - final threadId = JsonDecoder.optionalField(json, 'threadId') ?? - JsonDecoder.optionalField(json, 'thread_id'); - final runId = JsonDecoder.optionalField(json, 'runId') ?? - JsonDecoder.optionalField(json, 'run_id'); - - if (threadId == null) { - throw AGUIValidationError( - message: 'Missing required field: threadId or thread_id', - field: 'threadId', - json: json, - ); - } - if (runId == null) { - throw AGUIValidationError( - message: 'Missing required field: runId or run_id', - field: 'runId', - json: json, - ); - } - return Run( - threadId: threadId, - runId: runId, + threadId: JsonDecoder.requireEitherField( + json, + 'threadId', + 'thread_id', + ), + runId: JsonDecoder.requireEitherField( + json, + 'runId', + 'run_id', + ), result: json['result'], ); } @override Map toJson() => { - 'threadId': threadId, - 'runId': runId, - if (result != null) 'result': result, - }; + 'threadId': threadId, + 'runId': runId, + if (result != null) 'result': result, + }; + // `result` is nullable — sentinel for explicit-clear semantics. @override Run copyWith({ String? threadId, String? runId, - dynamic result, + Object? result = kUnsetSentinel, }) { return Run( threadId: threadId ?? this.threadId, runId: runId ?? this.runId, - result: result ?? this.result, + result: identical(result, kUnsetSentinel) ? this.result : result, ); } } /// Type alias for state (can be any type) -typedef State = dynamic; \ No newline at end of file +typedef State = dynamic; diff --git a/sdks/community/dart/lib/src/types/message.dart b/sdks/community/dart/lib/src/types/message.dart index c34c99a3e1..b05f3c1e7f 100644 --- a/sdks/community/dart/lib/src/types/message.dart +++ b/sdks/community/dart/lib/src/types/message.dart @@ -1,35 +1,80 @@ /// Message types for AG-UI protocol. /// /// This library defines the message types used in agent-user conversations, -/// including user, assistant, system, tool, and developer messages. +/// including user, assistant, system, tool, developer, activity, and +/// reasoning messages. library; import 'base.dart'; import 'tool.dart'; +// `kUnsetSentinel` (from `base.dart`) is the shared sentinel for all +// `copyWith` methods in this file. The pattern lets callers distinguish +// "argument omitted" (preserve current value via `?? this.field`) from +// "argument explicitly null" (clear the field). Compared with `identical(...)`. + /// Role types for messages in the AG-UI protocol. /// -/// Defines the possible roles a message can have in a conversation. +/// Mirrors the canonical TypeScript and Python `Message` discriminated +/// unions (see `sdks/typescript/packages/core/src/types.ts` and +/// `sdks/python/ag_ui/core/types.py`). The `activity` and `reasoning` +/// values exist so `MESSAGES_SNAPSHOT` payloads carrying those message +/// shapes decode in Dart with the same schema as the other SDKs. enum MessageRole { developer('developer'), system('system'), assistant('assistant'), user('user'), tool('tool'), - activity('activity'); + + /// Wire spelling is `'activity'` (lowercase, single word) — canonical + /// across the AG-UI protocol (TS `Literal["activity"]`, Python + /// `Literal["activity"]`). The Dart symbol matches; this enum value + /// pins the wire constant for [MessageRole.fromString] dispatch into + /// [ActivityMessage]. Mirrors the wire-spelling-pinning style used by + /// [ReasoningEncryptedValueSubtype.toolCall] (where the spelling + /// difference is more consequential). + /// + /// **Cipher asymmetry:** unlike [reasoning], `activity` messages never + /// carry cipher data in the structured field — [ActivityMessage.fromJson] + /// silently strips any wire-level `encryptedValue`. See [ActivityMessage] + /// class-doc for the rationale. + activity('activity'), + + /// Wire spelling is `'reasoning'` (lowercase, single word) — canonical + /// across the AG-UI protocol. The Dart symbol matches; this enum value + /// pins the wire constant for [MessageRole.fromString] dispatch into + /// [ReasoningMessage]. + reasoning('reasoning'); final String value; const MessageRole(this.value); + /// Parses [value] into a [MessageRole]. + /// + /// Unlike `TextMessageRole.fromString` / `ReasoningMessageRole.fromString` + /// (which throw `ArgumentError` and are absorbed at the event-factory + /// level for forward-compat), this enum throws [AGUIValidationError] + /// directly — the value is the discriminator that selects which + /// [Message] subtype's `fromJson` to dispatch to, so an unknown role + /// has no safe default. Mis-tagging a `MESSAGES_SNAPSHOT` payload + /// would corrupt the snapshot rather than just lose one field. + /// + /// Through the public [EventDecoder] pipeline, this surfaces as + /// `DecodingError(field: 'role')`. Direct callers of `Message.fromJson` + /// see `AGUIValidationError` directly. See `dart-enum-parsing-safety.md` + /// for the closed-vs-open enum rationale. + static final Map _byValue = Map.unmodifiable({ + for (final r in MessageRole.values) r.value: r, + }); + static MessageRole fromString(String value) { - return MessageRole.values.firstWhere( - (role) => role.value == value, - orElse: () => throw AGUIValidationError( - message: 'Invalid message role: $value', - field: 'role', - value: value, - ), - ); + return _byValue[value] ?? + (throw AGUIValidationError( + message: 'Invalid message role: $value', + field: 'role', + value: value, + )); } } @@ -39,17 +84,44 @@ enum MessageRole { /// Each message has a role, optional content, and may include additional metadata. /// /// Use the [Message.fromJson] factory to deserialize messages from JSON. +/// +/// Known parity gap with the canonical TS/Python SDKs: the canonical +/// `BaseMessageSchema.id` is `z.string()` (non-nullable). Dart keeps +/// `id` typed `String?` for legacy reasons but every concrete subtype +/// constructor declares it `required`, so a constructed in-memory +/// instance is null-safe by convention. A future major version may +/// tighten the type. See CHANGELOG → "Known parity gaps". sealed class Message extends AGUIModel with TypeDiscriminator { final String? id; final MessageRole role; final String? content; final String? name; + /// Opaque cipher payload preserved verbatim across proxy hops. + /// + /// Mirrors the canonical TS `BaseMessageSchema.encryptedValue: + /// z.string().optional()` and Python `BaseMessage.encrypted_value: + /// Optional[str]` — every concrete subtype that extends `BaseMessage` + /// (Developer/System/Assistant/User/Tool) inherits this field. The + /// canonical `ActivityMessage` and `ReasoningMessage` are NOT + /// `BaseMessage` extensions; in this Dart sealed-class hierarchy they + /// inherit the field too but their `fromJson` / `toJson` ignore it + /// (`ActivityMessage`) or inherit it through the sealed parent without + /// re-declaring locally (`ReasoningMessage` passes it via + /// `super.encryptedValue` — there is no shadowing field on that subtype). + /// + /// Wire dual-key: factories read both `encryptedValue` (TS-canonical) + /// and `encrypted_value` (Python-canonical) via + /// [JsonDecoder.optionalEitherField]. `toJson` emits the camelCase + /// spelling. + final String? encryptedValue; + const Message({ this.id, required this.role, this.content, this.name, + this.encryptedValue, }); @override @@ -58,8 +130,28 @@ sealed class Message extends AGUIModel with TypeDiscriminator { /// Factory constructor to create specific message types from JSON factory Message.fromJson(Map json) { final roleStr = JsonDecoder.requireField(json, 'role'); - final role = MessageRole.fromString(roleStr); + final MessageRole role; + try { + role = MessageRole.fromString(roleStr); + } on AGUIValidationError catch (e) { + // Drop json: — the message map may carry encryptedValue. Preserve + // cause: because MessageRole.fromString errors do not embed raw JSON + // (e.json == null), so the cause chain is safe to forward. + throw AGUIValidationError( + message: e.message, + field: e.field, + value: e.value, + cause: e, + ); + } + // `MessageRole.fromString` deliberately throws on unknown values rather + // than falling back to a default — unlike `TextMessageRole.fromString` + // and `ReasoningMessageRole.fromString`, which absorb `ArgumentError` for + // forward-compat. The role is the *dispatch discriminator*: an unknown role + // has no safe default subtype. Changing this to a fallback would silently + // mis-tag a MESSAGES_SNAPSHOT message, corrupting the list instead of + // surfacing the wire violation at the decoder boundary. switch (role) { case MessageRole.developer: return DeveloperMessage.fromJson(json); @@ -73,22 +165,29 @@ sealed class Message extends AGUIModel with TypeDiscriminator { return ToolMessage.fromJson(json); case MessageRole.activity: return ActivityMessage.fromJson(json); + case MessageRole.reasoning: + return ReasoningMessage.fromJson(json); + // No `default` clause — exhaustive switch on the [MessageRole] enum + // (analyzer-enforced). A new MessageRole value will produce a compile + // error here, which is the desired outcome rather than a runtime + // fall-through. } } @override Map toJson() => { - if (id != null) 'id': id, - 'role': role.value, - if (content != null) 'content': content, - if (name != null) 'name': name, - }; + if (id != null) 'id': id, + 'role': role.value, + if (content != null) 'content': content, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + }; } /// Developer message with required content. /// /// Used for system-level or developer-facing messages in the conversation. -class DeveloperMessage extends Message { +final class DeveloperMessage extends Message { @override final String content; @@ -96,6 +195,7 @@ class DeveloperMessage extends Message { required super.id, required this.content, super.name, + super.encryptedValue, }) : super(role: MessageRole.developer); factory DeveloperMessage.fromJson(Map json) { @@ -103,19 +203,43 @@ class DeveloperMessage extends Message { id: JsonDecoder.requireField(json, 'id'), content: JsonDecoder.requireField(json, 'content'), name: JsonDecoder.optionalField(json, 'name'), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), ); } + // Emit `content` unconditionally — it is constructor-required and non-null + // on this subtype. The parent's conditional `if (content != null) 'content'` + // would also work by construction, but emitting it here makes the contract + // explicit and independent of the parent implementation. + @override + Map toJson() => { + if (id != null) 'id': id, + 'role': role.value, + 'content': content, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + }; + + // `name` and `encryptedValue` are nullable on the parent — use the + // sentinel so callers can clear either explicitly. See [kUnsetSentinel]. @override DeveloperMessage copyWith({ String? id, String? content, - String? name, + Object? name = kUnsetSentinel, + Object? encryptedValue = kUnsetSentinel, }) { return DeveloperMessage( id: id ?? this.id, content: content ?? this.content, - name: name ?? this.name, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } @@ -123,7 +247,7 @@ class DeveloperMessage extends Message { /// System message with required content. /// /// Represents system-level instructions or context provided to the agent. -class SystemMessage extends Message { +final class SystemMessage extends Message { @override final String content; @@ -131,6 +255,7 @@ class SystemMessage extends Message { required super.id, required this.content, super.name, + super.encryptedValue, }) : super(role: MessageRole.system); factory SystemMessage.fromJson(Map json) { @@ -138,19 +263,39 @@ class SystemMessage extends Message { id: JsonDecoder.requireField(json, 'id'), content: JsonDecoder.requireField(json, 'content'), name: JsonDecoder.optionalField(json, 'name'), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), ); } + @override + Map toJson() => { + if (id != null) 'id': id, + 'role': role.value, + 'content': content, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + }; + + // `name` and `encryptedValue` are nullable on the parent — sentinel + // for explicit-clear semantics. @override SystemMessage copyWith({ String? id, String? content, - String? name, + Object? name = kUnsetSentinel, + Object? encryptedValue = kUnsetSentinel, }) { return SystemMessage( id: id ?? this.id, content: content ?? this.content, - name: name ?? this.name, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } @@ -159,7 +304,7 @@ class SystemMessage extends Message { /// /// Represents responses from the AI assistant, which may include /// text content and/or tool call requests. -class AssistantMessage extends Message { +final class AssistantMessage extends Message { final List? toolCalls; const AssistantMessage({ @@ -167,78 +312,199 @@ class AssistantMessage extends Message { super.content, super.name, this.toolCalls, + super.encryptedValue, }) : super(role: MessageRole.assistant); factory AssistantMessage.fromJson(Map json) { + // KEY-level dual-key resolution with eager element-type validation. + // Documented precedence rule (see [JsonDecoder.requireEitherField] + // dartdoc): if camelCase `toolCalls` is present, it wins even when the + // list is empty; snake_case `tool_calls` is consulted ONLY when + // camelCase is absent. The pre-fix `??`-on-value chain incorrectly + // surfaced `tool_calls` whenever camelCase resolved to null OR an + // empty list — silently dropping snake_case data on payloads that + // (incorrectly) carry both keys. The regression test + // `message_test.dart:401-446` ("AssistantMessage.fromJson dual-key + // precedence") pins this contract. + // + // Element-type validation: `optionalEitherListField` reports + // `field: 'toolCalls[$i]'` on a malformed nested element rather than + // letting a raw `TypeError` leak from the `as Map` + // cast — same convention as `MessagesSnapshotEvent.fromJson`. + final rawToolCalls = + JsonDecoder.optionalEitherListField>( + json, + 'toolCalls', + 'tool_calls', + ); return AssistantMessage( id: JsonDecoder.requireField(json, 'id'), content: JsonDecoder.optionalField(json, 'content'), name: JsonDecoder.optionalField(json, 'name'), - toolCalls: JsonDecoder.optionalListField>( + toolCalls: rawToolCalls == null + ? null + : () { + final result = []; + for (var i = 0; i < rawToolCalls.length; i++) { + try { + result.add(ToolCall.fromJson(rawToolCalls[i])); + } catch (e) { + if (e is AGUIValidationError) { + // Omit `json:` — ToolCall.fromJson can set e.json to a + // payload with sensitive `arguments`. Preserve `cause:` + // when the inner error already scrubbed its own `json:` + // (cipher-aware path) so the stack trace survives. + throw AGUIValidationError( + message: e.message, + field: e.field != null ? 'toolCalls[$i].${e.field}' : 'toolCalls[$i]', + value: e.value, + cause: e.json == null ? e : null, + ); + } + throw AGUIValidationError( + message: 'Failed to decode tool call at index $i: $e', + field: 'toolCalls[$i]', + cause: e, + ); + } + } + return result; + }(), + encryptedValue: JsonDecoder.optionalEitherField( json, - 'toolCalls', - )?.map((item) => ToolCall.fromJson(item)).toList() ?? - JsonDecoder.optionalListField>( - json, - 'tool_calls', - )?.map((item) => ToolCall.fromJson(item)).toList(), + 'encryptedValue', + 'encrypted_value', + ), ); } @override Map toJson() => { - ...super.toJson(), - if (toolCalls != null && toolCalls!.isNotEmpty) - 'toolCalls': toolCalls!.map((tc) => tc.toJson()).toList(), - }; + ...super.toJson(), + // Emit `toolCalls` whenever the in-memory field is non-null, even + // when empty, so the round-trip `fromJson(m.toJson()) == m` is + // symmetric. The previous `&& toolCalls!.isNotEmpty` guard dropped + // the key on empty lists, which decoded back to `null` instead of + // `[]` and made tests that depend on field-by-field equality + // surprising. + if (toolCalls != null) + 'toolCalls': toolCalls!.map((tc) => tc.toJson()).toList(), + }; + // See [kUnsetSentinel] for the sentinel rationale. `content`, + // `name`, `toolCalls`, and `encryptedValue` are all nullable on + // `AssistantMessage`, so callers may legitimately want to clear any + // of them via `copyWith`. @override AssistantMessage copyWith({ String? id, - String? content, - String? name, - List? toolCalls, + Object? content = kUnsetSentinel, + Object? name = kUnsetSentinel, + Object? toolCalls = kUnsetSentinel, + Object? encryptedValue = kUnsetSentinel, }) { return AssistantMessage( id: id ?? this.id, - content: content ?? this.content, - name: name ?? this.name, - toolCalls: toolCalls ?? this.toolCalls, + content: identical(content, kUnsetSentinel) + ? this.content + : content as String?, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, + toolCalls: identical(toolCalls, kUnsetSentinel) + ? this.toolCalls + : toolCalls as List?, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } -/// User message with required content. +/// User message with text or multimodal content. /// -/// Represents input from the user in the conversation. +/// Represents input from the user in the conversation. The content is a union +/// of plain text or an ordered list of multimodal parts, modeled by +/// [UserMessageContent]. Use the default constructor for text, or +/// [UserMessage.multimodal] for a list of [InputContent] parts. class UserMessage extends Message { - @override - final String content; + /// The user message content: [TextContent] or [MultimodalContent]. + final UserMessageContent messageContent; - const UserMessage({ + /// Creates a text user message; [content] is wrapped in [TextContent]. + /// + /// Not `const` because it wraps [content] at runtime. For a compile-time + /// constant, use [UserMessage.fromContent] with a `const` [TextContent]. + UserMessage({ required super.id, - required this.content, + required String content, + super.name, + super.encryptedValue, + }) : messageContent = TextContent(content), + super(role: MessageRole.user); + + /// Creates a multimodal user message from an ordered list of [parts]. + UserMessage.multimodal({ + required super.id, + required List parts, + super.name, + super.encryptedValue, + }) : messageContent = MultimodalContent(parts), + super(role: MessageRole.user); + + /// Creates a user message from a [UserMessageContent] union value. + const UserMessage.fromContent({ + required super.id, + required this.messageContent, super.name, + super.encryptedValue, }) : super(role: MessageRole.user); factory UserMessage.fromJson(Map json) { - return UserMessage( + return UserMessage.fromContent( id: JsonDecoder.requireField(json, 'id'), - content: JsonDecoder.requireField(json, 'content'), + messageContent: UserMessageContent.fromJson(json['content']), name: JsonDecoder.optionalField(json, 'name'), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), ); } + /// The text of this message, or `null` when the content is multimodal. + /// + /// Projects [messageContent] so existing text-only readers keep working. + @override + String? get content => switch (messageContent) { + TextContent(:final text) => text, + MultimodalContent() => null, + }; + + @override + Map toJson() => { + if (id != null) 'id': id, + 'role': role.value, + 'content': messageContent.toJson(), + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + }; + + // `name` and `encryptedValue` are nullable on the parent — sentinel + // for explicit-clear semantics. @override UserMessage copyWith({ String? id, - String? content, - String? name, + UserMessageContent? messageContent, + Object? name = kUnsetSentinel, + Object? encryptedValue = kUnsetSentinel, }) { - return UserMessage( + return UserMessage.fromContent( id: id ?? this.id, - content: content ?? this.content, - name: name ?? this.name, + messageContent: messageContent ?? this.messageContent, + name: identical(name, kUnsetSentinel) ? this.name : name as String?, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } @@ -246,70 +512,104 @@ class UserMessage extends Message { /// Tool message with tool call result. /// /// Contains the result of a tool execution, linked to a specific tool call -/// via the [toolCallId] field. -class ToolMessage extends Message { +/// via the [toolCallId] field. The optional [encryptedValue] mirrors the +/// canonical TypeScript `ToolMessageSchema` and Python `ToolMessage` and +/// carries an opaque cipher payload that a Dart proxy must forward +/// verbatim to a downstream agent. +final class ToolMessage extends Message { @override final String content; final String toolCallId; final String? error; const ToolMessage({ - super.id, + required super.id, required this.content, required this.toolCallId, this.error, + super.encryptedValue, }) : super(role: MessageRole.tool); factory ToolMessage.fromJson(Map json) { - final toolCallId = JsonDecoder.optionalField(json, 'toolCallId') ?? - JsonDecoder.optionalField(json, 'tool_call_id'); - - if (toolCallId == null) { - throw AGUIValidationError( - message: 'Missing required field: toolCallId or tool_call_id', - field: 'toolCallId', - json: json, - ); - } - return ToolMessage( - id: JsonDecoder.optionalField(json, 'id'), + id: JsonDecoder.requireField(json, 'id'), content: JsonDecoder.requireField(json, 'content'), - toolCallId: toolCallId, + toolCallId: JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), error: JsonDecoder.optionalField(json, 'error'), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), ); } + // Explicit field-by-field emission rather than ...super.toJson() spread: + // ToolMessage's constructor does not accept `name`, so the inherited + // Message.name field is always null here and the explicit form is safe. + // If Message.toJson() ever gains a new common field, this override must be + // updated in parallel to avoid silently dropping it. @override Map toJson() => { - ...super.toJson(), - 'toolCallId': toolCallId, - if (error != null) 'error': error, - }; + if (id != null) 'id': id, + 'role': role.value, + 'content': content, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + 'toolCallId': toolCallId, + if (error != null) 'error': error, + }; + // `error` and `encryptedValue` are nullable — use the sentinel so a + // caller can explicitly clear either via `copyWith(error: null)` / + // `copyWith(encryptedValue: null)`. Mirrors the event-class sentinel + // discipline. @override ToolMessage copyWith({ String? id, String? content, String? toolCallId, - String? error, + Object? error = kUnsetSentinel, + Object? encryptedValue = kUnsetSentinel, }) { return ToolMessage( id: id ?? this.id, content: content ?? this.content, toolCallId: toolCallId ?? this.toolCallId, - error: error ?? this.error, + error: identical(error, kUnsetSentinel) ? this.error : error as String?, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } -/// Activity message carrying structured progress state. +/// Activity message embedded in a `MESSAGES_SNAPSHOT` payload. +/// +/// Mirrors the canonical TypeScript `ActivityMessageSchema` +/// (`sdks/typescript/packages/core/src/types.ts`) and the Python +/// `ActivityMessage` model (`sdks/python/ag_ui/core/types.py`). The wire +/// shape is `{id, role: 'activity', activityType, content}` where +/// `content` is a JSON object (`z.record(z.any())` / `Dict[str, Any]`). /// -/// `activityType` identifies the shape of `content`, a free-form map of -/// activity-specific fields (e.g. `{progress: 0.5}` for an upload). -/// Emitted by the backend alongside `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA` -/// events and included in `MESSAGES_SNAPSHOT` replays. -class ActivityMessage extends Message { +/// The Dart in-memory accessor for the wire `content` field is named +/// [activityContent] to avoid shadowing the parent [Message.content] +/// (which is `String?`). The wire key remains `content` in [toJson] / +/// [fromJson] for protocol parity. +/// +/// **`encryptedValue` note.** `ActivityMessage` inherits [encryptedValue] +/// from [Message] but intentionally does not expose it in the constructor, +/// [fromJson], or [toJson]. In the canonical protocol `ActivityMessage` is +/// NOT a `BaseMessage` extension (unlike Developer/System/Assistant/User/Tool +/// messages), so cipher-payload forwarding does not apply here. If the wire +/// payload contains `encryptedValue` / `encrypted_value`, [fromJson] strips +/// it silently (matching TS zod-default strip behavior). In-memory instances +/// constructed via [copyWith] on a parent [Message] may inherit the field, +/// but [toJson] never emits it. +final class ActivityMessage extends Message { final String activityType; final Map activityContent; @@ -319,10 +619,27 @@ class ActivityMessage extends Message { required this.activityContent, }) : super(role: MessageRole.activity); + // ActivityMessage never carries cipher data — override the inherited getter + // to guarantee null. fromJson silently strips any inbound encryptedValue; + // this override ensures no in-memory path (copyWith, subclassing) can + // accidentally set it, making the cipher-scrub predicate in + // MessagesSnapshotEvent.fromJson permanently reliable. + @override + String? get encryptedValue => null; + factory ActivityMessage.fromJson(Map json) { + // `ActivityMessage` is NOT a `BaseMessage` extension in the canonical + // protocol — cipher-payload forwarding does not apply. Strip any inbound + // `encryptedValue` / `encrypted_value` silently, matching TS zod-default + // strip behavior. A hard-fail here would make Dart the only SDK that tears + // down the stream when a proxy emits the field (TS strips, Python preserves). return ActivityMessage( id: JsonDecoder.requireField(json, 'id'), - activityType: JsonDecoder.requireField(json, 'activityType'), + activityType: JsonDecoder.requireEitherField( + json, + 'activityType', + 'activity_type', + ), activityContent: JsonDecoder.requireField>(json, 'content'), ); @@ -330,22 +647,589 @@ class ActivityMessage extends Message { @override Map toJson() => { - if (id != null) 'id': id, - 'role': role.value, - 'activityType': activityType, - 'content': activityContent, - }; + // Explicitly skip super.toJson() — the inherited Message.content field + // must not appear in the wire output (activityContent is the `content` + // key here). Using ...super.toJson() would rely on map-spread + // overwrite order to mask any future super.content emission. + if (id != null) 'id': id, + 'role': role.value, + 'activityType': activityType, + 'content': activityContent, + }; + // `id` is nullable on the parent `Message` — use the sentinel so a caller + // can explicitly clear it via `copyWith(id: null)`. The bare `?? this.id` + // pattern cannot distinguish "omitted" from "explicitly null". @override ActivityMessage copyWith({ - String? id, + Object? id = kUnsetSentinel, String? activityType, Map? activityContent, }) { return ActivityMessage( - id: id ?? this.id, + id: identical(id, kUnsetSentinel) ? this.id : id as String?, activityType: activityType ?? this.activityType, activityContent: activityContent ?? this.activityContent, ); } -} \ No newline at end of file +} + +/// Reasoning message emitted by models that expose their chain-of-thought. +/// +/// Mirrors `ReasoningMessage` in the Python and TypeScript reference SDKs. +/// [content] is the visible reasoning text; [thinking] is an opaque +/// extended-thinking blob; [encryptedValue] carries the encrypted thinking +/// payload when the server uses encrypted extended thinking. +class ReasoningMessage extends Message { + /// Optional visible reasoning / chain-of-thought text. + @override + final String? content; + + /// Optional opaque extended-thinking blob. + final String? thinking; + + const ReasoningMessage({ + super.id, + this.content, + this.thinking, + super.encryptedValue, + }) : super(role: MessageRole.reasoning); + + factory ReasoningMessage.fromJson(Map json) { + return ReasoningMessage( + id: JsonDecoder.optionalField(json, 'id'), + content: JsonDecoder.optionalField(json, 'content'), + thinking: JsonDecoder.optionalField(json, 'thinking'), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), + ); + } + + @override + Map toJson() => { + ...super.toJson(), + if (thinking != null) 'thinking': thinking, + }; + + // `encryptedValue` is nullable on the parent — sentinel lets callers clear it. + @override + ReasoningMessage copyWith({ + String? id, + String? content, + String? thinking, + Object? encryptedValue = kUnsetSentinel, + }) { + return ReasoningMessage( + id: id ?? this.id, + content: content ?? this.content, + thinking: thinking ?? this.thinking, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, + ); + } +} + +/// Reads a MIME type from JSON, accepting both `mimeType` and `mime_type`. +String? _readMimeType(Map json) => + JsonDecoder.optionalField(json, 'mimeType') ?? + JsonDecoder.optionalField(json, 'mime_type'); + +/// The source of a multimodal [InputContent] part. +/// +/// A discriminated union on `type`: [DataSource] (inline data, e.g. base64) +/// or [UrlSource] (a remote URL). Use [InputContentSource.fromJson] to decode. +sealed class InputContentSource extends AGUIModel { + const InputContentSource(); + + /// The source discriminator: `data` or `url`. + String get sourceType; + + /// Decodes an [InputContentSource] from JSON, dispatching on `type`. + factory InputContentSource.fromJson(Map json) { + final type = JsonDecoder.requireField(json, 'type'); + switch (type) { + case 'data': + return DataSource.fromJson(json); + case 'url': + return UrlSource.fromJson(json); + default: + throw AGUIValidationError( + message: 'Invalid input content source type: $type', + field: 'type', + value: type, + json: json, + ); + } + } +} + +/// Inline content source carrying a data payload (e.g. base64-encoded bytes). +/// +/// [mimeType] is required for data sources. +class DataSource extends InputContentSource { + /// The inline data payload, typically base64-encoded. + final String value; + + /// The MIME type of [value]. Required. + final String mimeType; + + const DataSource({required this.value, required this.mimeType}); + + @override + String get sourceType => 'data'; + + factory DataSource.fromJson(Map json) { + final mimeType = _readMimeType(json); + if (mimeType == null) { + throw AGUIValidationError( + message: 'DataSource requires a mimeType', + field: 'mimeType', + json: json, + ); + } + return DataSource( + value: JsonDecoder.requireField(json, 'value'), + mimeType: mimeType, + ); + } + + @override + Map toJson() => { + 'type': sourceType, + 'value': value, + 'mimeType': mimeType, + }; + + @override + DataSource copyWith({String? value, String? mimeType}) => DataSource( + value: value ?? this.value, + mimeType: mimeType ?? this.mimeType, + ); +} + +/// Remote content source referenced by URL. +/// +/// [mimeType] is optional for URL sources. +class UrlSource extends InputContentSource { + /// The URL of the content. + final String value; + + /// The optional MIME type of the referenced content. + final String? mimeType; + + const UrlSource({required this.value, this.mimeType}); + + @override + String get sourceType => 'url'; + + factory UrlSource.fromJson(Map json) => UrlSource( + value: JsonDecoder.requireField(json, 'value'), + mimeType: _readMimeType(json), + ); + + @override + Map toJson() => { + 'type': sourceType, + 'value': value, + if (mimeType != null) 'mimeType': mimeType, + }; + + @override + UrlSource copyWith({String? value, Object? mimeType = _absent}) => UrlSource( + value: value ?? this.value, + mimeType: identical(mimeType, _absent) ? this.mimeType : mimeType as String?, + ); +} + +/// Parses the shared `source` (+ optional `metadata`) of a media input part. +({InputContentSource source, Object? metadata}) _parseMediaPart( + Map json, + String type, +) { + final rawSource = json['source']; + if (rawSource is! Map) { + throw AGUIValidationError( + message: '$type input content requires a source object', + field: 'source', + value: rawSource, + json: json, + ); + } + return ( + source: InputContentSource.fromJson(rawSource), + metadata: json['metadata'] as Object?, + ); +} + +/// Serializes the shared shape of a media input part. +Map _mediaToJson( + String type, + InputContentSource source, + Object? metadata, +) => { + 'type': type, + 'source': source.toJson(), + if (metadata != null) 'metadata': metadata, + }; + +/// Sentinel value used in [copyWith] methods to distinguish "not provided" +/// from `null`, allowing callers to clear optional fields by passing `null`. +const _absent = Object(); + +/// A single typed part of a multimodal [UserMessage]. +/// +/// A discriminated union on `type`: [TextInputContent], [ImageInputContent], +/// [AudioInputContent], [VideoInputContent], [DocumentInputContent], or the +/// legacy [BinaryInputContent]. Use [InputContent.fromJson] to decode. +sealed class InputContent extends AGUIModel with TypeDiscriminator { + const InputContent(); + + /// Decodes an [InputContent] from JSON, dispatching on `type`. + factory InputContent.fromJson(Map json) { + final type = JsonDecoder.requireField(json, 'type'); + switch (type) { + case 'text': + return TextInputContent.fromJson(json); + case 'image': + return ImageInputContent.fromJson(json); + case 'audio': + return AudioInputContent.fromJson(json); + case 'video': + return VideoInputContent.fromJson(json); + case 'document': + return DocumentInputContent.fromJson(json); + case 'binary': + return BinaryInputContent.fromJson(json); + default: + throw AGUIValidationError( + message: 'Invalid input content type: $type', + field: 'type', + value: type, + json: json, + ); + } + } +} + +/// Plain text part of a multimodal message. +class TextInputContent extends InputContent { + /// The text payload. + final String text; + + const TextInputContent(this.text); + + @override + String get type => 'text'; + + factory TextInputContent.fromJson(Map json) => + TextInputContent(JsonDecoder.requireField(json, 'text')); + + @override + Map toJson() => {'type': type, 'text': text}; + + @override + TextInputContent copyWith({String? text}) => + TextInputContent(text ?? this.text); +} + +/// Image part of a multimodal message. +class ImageInputContent extends InputContent { + /// The image source (data or URL). + final InputContentSource source; + + /// Free-form, provider-specific metadata. Serialized only when non-null. + final Object? metadata; + + const ImageInputContent({required this.source, this.metadata}); + + @override + String get type => 'image'; + + factory ImageInputContent.fromJson(Map json) { + final parsed = _parseMediaPart(json, 'image'); + return ImageInputContent(source: parsed.source, metadata: parsed.metadata); + } + + @override + Map toJson() => _mediaToJson(type, source, metadata); + + @override + ImageInputContent copyWith({ + InputContentSource? source, + Object? metadata = _absent, + }) => + ImageInputContent( + source: source ?? this.source, + metadata: identical(metadata, _absent) ? this.metadata : metadata, + ); +} + +/// Audio part of a multimodal message. +class AudioInputContent extends InputContent { + /// The audio source (data or URL). + final InputContentSource source; + + /// Free-form, provider-specific metadata. Serialized only when non-null. + final Object? metadata; + + const AudioInputContent({required this.source, this.metadata}); + + @override + String get type => 'audio'; + + factory AudioInputContent.fromJson(Map json) { + final parsed = _parseMediaPart(json, 'audio'); + return AudioInputContent(source: parsed.source, metadata: parsed.metadata); + } + + @override + Map toJson() => _mediaToJson(type, source, metadata); + + @override + AudioInputContent copyWith({ + InputContentSource? source, + Object? metadata = _absent, + }) => + AudioInputContent( + source: source ?? this.source, + metadata: identical(metadata, _absent) ? this.metadata : metadata, + ); +} + +/// Video part of a multimodal message. +class VideoInputContent extends InputContent { + /// The video source (data or URL). + final InputContentSource source; + + /// Free-form, provider-specific metadata. Serialized only when non-null. + final Object? metadata; + + const VideoInputContent({required this.source, this.metadata}); + + @override + String get type => 'video'; + + factory VideoInputContent.fromJson(Map json) { + final parsed = _parseMediaPart(json, 'video'); + return VideoInputContent(source: parsed.source, metadata: parsed.metadata); + } + + @override + Map toJson() => _mediaToJson(type, source, metadata); + + @override + VideoInputContent copyWith({ + InputContentSource? source, + Object? metadata = _absent, + }) => + VideoInputContent( + source: source ?? this.source, + metadata: identical(metadata, _absent) ? this.metadata : metadata, + ); +} + +/// Document part of a multimodal message. +class DocumentInputContent extends InputContent { + /// The document source (data or URL). + final InputContentSource source; + + /// Free-form, provider-specific metadata. Serialized only when non-null. + final Object? metadata; + + const DocumentInputContent({required this.source, this.metadata}); + + @override + String get type => 'document'; + + factory DocumentInputContent.fromJson(Map json) { + final parsed = _parseMediaPart(json, 'document'); + return DocumentInputContent( + source: parsed.source, + metadata: parsed.metadata, + ); + } + + @override + Map toJson() => _mediaToJson(type, source, metadata); + + @override + DocumentInputContent copyWith({ + InputContentSource? source, + Object? metadata = _absent, + }) => + DocumentInputContent( + source: source ?? this.source, + metadata: identical(metadata, _absent) ? this.metadata : metadata, + ); +} + +/// Legacy binary content part. +/// +/// Requires a non-empty [mimeType] and at least one of [id], [url], or [data]. +class BinaryInputContent extends InputContent { + /// The MIME type of the binary payload. Required and non-empty. + final String mimeType; + + /// An opaque identifier for previously-uploaded content. + final String? id; + + /// A URL referencing the content. + final String? url; + + /// An inline data payload (e.g. base64-encoded). + final String? data; + + /// An optional display filename. + final String? filename; + + const BinaryInputContent({ + required this.mimeType, + this.id, + this.url, + this.data, + this.filename, + }) : assert(mimeType != '', 'BinaryInputContent requires a non-empty mimeType'), + assert( + id != null || url != null || data != null, + 'BinaryInputContent requires at least one of id, url, or data', + ); + + @override + String get type => 'binary'; + + factory BinaryInputContent.fromJson(Map json) { + final mimeType = _readMimeType(json); + // Non-empty mimeType matches the Go reader (types.go:423, enforced on + // unmarshal); intentionally stricter than TS, whose z.string() accepts "". + if (mimeType == null || mimeType.isEmpty) { + throw AGUIValidationError( + message: 'BinaryInputContent requires a non-empty mimeType', + field: 'mimeType', + json: json, + ); + } + final id = JsonDecoder.optionalField(json, 'id'); + final url = JsonDecoder.optionalField(json, 'url'); + final data = JsonDecoder.optionalField(json, 'data'); + if (id == null && url == null && data == null) { + throw AGUIValidationError( + message: 'BinaryInputContent requires at least one of id, url, or data', + field: 'id', + json: json, + ); + } + return BinaryInputContent( + mimeType: mimeType, + id: id, + url: url, + data: data, + filename: JsonDecoder.optionalField(json, 'filename'), + ); + } + + @override + Map toJson() => { + 'type': type, + 'mimeType': mimeType, + if (id != null) 'id': id, + if (url != null) 'url': url, + if (data != null) 'data': data, + if (filename != null) 'filename': filename, + }; + + @override + BinaryInputContent copyWith({ + String? mimeType, + Object? id = _absent, + Object? url = _absent, + Object? data = _absent, + Object? filename = _absent, + }) => + BinaryInputContent( + mimeType: mimeType ?? this.mimeType, + id: identical(id, _absent) ? this.id : id as String?, + url: identical(url, _absent) ? this.url : url as String?, + data: identical(data, _absent) ? this.data : data as String?, + filename: identical(filename, _absent) ? this.filename : filename as String?, + ); +} + +/// The content union for a [UserMessage]: plain text or multimodal parts. +/// +/// Mirrors the canonical `string | InputContent[]` shape. [toJson] returns a +/// `String` for [TextContent] or a `List` for [MultimodalContent]. +/// +/// Unlike the other models this does not extend `AGUIModel`: its [toJson] must +/// return a bare `String` or `List`, not a `Map`. +sealed class UserMessageContent { + const UserMessageContent(); + + /// Serializes to a JSON `String` (text) or `List` (multimodal parts). + Object toJson(); + + /// Decodes from a raw `content` value: a `String` or a `List` of parts. + factory UserMessageContent.fromJson(Object? raw) { + if (raw is String) { + return TextContent(raw); + } + if (raw is List) { + final parts = []; + for (var i = 0; i < raw.length; i++) { + final item = raw[i]; + if (item is! Map) { + throw AGUIValidationError( + message: 'UserMessage content part at index $i must be an object', + field: 'content[$i]', + value: item, + ); + } + try { + parts.add(InputContent.fromJson(item)); + } on AGUIValidationError catch (e) { + throw AGUIValidationError( + message: 'Invalid content part at index $i: ${e.message}', + field: 'content[$i]', + value: item, + ); + } + } + // Decode is tolerant: an empty list is a structurally valid + // MultimodalContent (mirrors canonical TS `z.array`, which accepts []). + // The non-empty invariant is enforced on the send side by + // Validators.validateUserMessageContent. + return MultimodalContent(parts); + } + throw AGUIValidationError( + message: 'UserMessage content must be a String or a List of parts', + field: 'content', + value: raw, + ); + } +} + +/// Plain-text user message content. Serializes to a JSON `String`. +class TextContent extends UserMessageContent { + /// The text payload. + final String text; + + const TextContent(this.text); + + @override + String toJson() => text; +} + +/// Multimodal user message content. Serializes to a JSON `List`. +class MultimodalContent extends UserMessageContent { + /// The ordered list of content parts. + final List parts; + + const MultimodalContent(this.parts); + + @override + List> toJson() => + parts.map((part) => part.toJson()).toList(); +} diff --git a/sdks/community/dart/lib/src/types/tool.dart b/sdks/community/dart/lib/src/types/tool.dart index c0283f4cdc..3004a6440e 100644 --- a/sdks/community/dart/lib/src/types/tool.dart +++ b/sdks/community/dart/lib/src/types/tool.dart @@ -6,6 +6,9 @@ library; import 'base.dart'; +// `kUnsetSentinel` (from `base.dart`) is the shared sentinel for all +// `copyWith` methods in this file. + /// Represents a function call within a tool call. /// /// Contains the function name and serialized arguments for execution. @@ -27,9 +30,9 @@ class FunctionCall extends AGUIModel { @override Map toJson() => { - 'name': name, - 'arguments': arguments, - }; + 'name': name, + 'arguments': arguments, + }; @override FunctionCall copyWith({ @@ -47,15 +50,21 @@ class FunctionCall extends AGUIModel { /// /// Tool calls allow the assistant to request execution of external functions /// or tools to gather information or perform actions. +/// +/// The optional [encryptedValue] is an opaque cipher payload that a Dart +/// proxy must forward verbatim. It mirrors the canonical TS/Python +/// `ToolCall.encryptedValue` / `ToolCall.encrypted_value` field. class ToolCall extends AGUIModel { final String id; final String type; final FunctionCall function; + final String? encryptedValue; const ToolCall({ required this.id, this.type = 'function', required this.function, + this.encryptedValue, }); factory ToolCall.fromJson(Map json) { @@ -65,26 +74,39 @@ class ToolCall extends AGUIModel { function: FunctionCall.fromJson( JsonDecoder.requireField>(json, 'function'), ), + encryptedValue: JsonDecoder.optionalEitherField( + json, + 'encryptedValue', + 'encrypted_value', + ), ); } @override Map toJson() => { - 'id': id, - 'type': type, - 'function': function.toJson(), - }; - + 'id': id, + 'type': type, + 'function': function.toJson(), + if (encryptedValue != null) 'encryptedValue': encryptedValue, + }; + + // `encryptedValue` is nullable — sentinel lets callers clear it + // explicitly. Mirrors the message-class sentinel in + // lib/src/types/message.dart. @override ToolCall copyWith({ String? id, String? type, FunctionCall? function, + Object? encryptedValue = kUnsetSentinel, }) { return ToolCall( id: id ?? this.id, type: type ?? this.type, function: function ?? this.function, + encryptedValue: identical(encryptedValue, kUnsetSentinel) + ? this.encryptedValue + : encryptedValue as String?, ); } } @@ -93,15 +115,22 @@ class ToolCall extends AGUIModel { /// /// Defines a tool that can be called by the assistant, including its /// name, description, and parameter schema. +/// +/// [metadata] mirrors the canonical TS `ToolSchema.metadata: +/// z.record(z.any()).optional()` and Python's `extra='allow'` config. +/// A Dart proxy that decodes a tool list from a TS server and re-emits +/// it will round-trip arbitrary tool metadata without dropping it. class Tool extends AGUIModel { final String name; final String description; final dynamic parameters; // JSON Schema for the tool parameters + final Map? metadata; const Tool({ required this.name, required this.description, this.parameters, + this.metadata, }); factory Tool.fromJson(Map json) { @@ -109,26 +138,41 @@ class Tool extends AGUIModel { name: JsonDecoder.requireField(json, 'name'), description: JsonDecoder.requireField(json, 'description'), parameters: json['parameters'], // Allow any JSON Schema + metadata: JsonDecoder.optionalField>( + json, + 'metadata', + ), ); } @override Map toJson() => { - 'name': name, - 'description': description, - if (parameters != null) 'parameters': parameters, - }; - + 'name': name, + 'description': description, + if (parameters != null) 'parameters': parameters, + if (metadata != null) 'metadata': metadata, + }; + + // Both `parameters` and `metadata` are nullable — sentinels let callers + // clear either field explicitly via `copyWith(field: null)`. Without the + // sentinel, `copyWith(metadata: null)` would silently retain the existing + // value because the `?? this.field` fallback treats explicit-null and + // "omitted" identically. @override Tool copyWith({ String? name, String? description, - dynamic parameters, + Object? parameters = kUnsetSentinel, + Object? metadata = kUnsetSentinel, }) { return Tool( name: name ?? this.name, description: description ?? this.description, - parameters: parameters ?? this.parameters, + parameters: + identical(parameters, kUnsetSentinel) ? this.parameters : parameters, + metadata: identical(metadata, kUnsetSentinel) + ? this.metadata + : metadata as Map?, ); } } @@ -146,19 +190,12 @@ class ToolResult extends AGUIModel { }); factory ToolResult.fromJson(Map json) { - final toolCallId = JsonDecoder.optionalField(json, 'toolCallId') ?? - JsonDecoder.optionalField(json, 'tool_call_id'); - - if (toolCallId == null) { - throw AGUIValidationError( - message: 'Missing required field: toolCallId or tool_call_id', - field: 'toolCallId', - json: json, - ); - } - return ToolResult( - toolCallId: toolCallId, + toolCallId: JsonDecoder.requireEitherField( + json, + 'toolCallId', + 'tool_call_id', + ), content: JsonDecoder.requireField(json, 'content'), error: JsonDecoder.optionalField(json, 'error'), ); @@ -166,21 +203,23 @@ class ToolResult extends AGUIModel { @override Map toJson() => { - 'toolCallId': toolCallId, - 'content': content, - if (error != null) 'error': error, - }; + 'toolCallId': toolCallId, + 'content': content, + if (error != null) 'error': error, + }; + // `error` is nullable — sentinel lets callers clear it explicitly via + // `copyWith(error: null)`. Mirrors `ToolCall.encryptedValue` above. @override ToolResult copyWith({ String? toolCallId, String? content, - String? error, + Object? error = kUnsetSentinel, }) { return ToolResult( toolCallId: toolCallId ?? this.toolCallId, content: content ?? this.content, - error: error ?? this.error, + error: identical(error, kUnsetSentinel) ? this.error : error as String?, ); } -} \ No newline at end of file +} diff --git a/sdks/community/dart/lib/src/types/types.dart b/sdks/community/dart/lib/src/types/types.dart index 362801122c..da24b1b1d3 100644 --- a/sdks/community/dart/lib/src/types/types.dart +++ b/sdks/community/dart/lib/src/types/types.dart @@ -4,4 +4,4 @@ library; export 'base.dart'; export 'message.dart'; export 'tool.dart'; -export 'context.dart'; \ No newline at end of file +export 'context.dart'; diff --git a/sdks/community/dart/pubspec.lock b/sdks/community/dart/pubspec.lock new file mode 100644 index 0000000000..814aefff6a --- /dev/null +++ b/sdks/community/dart/pubspec.lock @@ -0,0 +1,397 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + url: "https://pub.dev" + source: hosted + version: "99.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + url: "https://pub.dev" + source: hosted + version: "12.1.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + meta: + dependency: "direct main" + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + url: "https://pub.dev" + source: hosted + version: "1.31.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + test_core: + dependency: transitive + description: + name: test_core + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + url: "https://pub.dev" + source: hosted + version: "0.6.17" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + url: "https://pub.dev" + source: hosted + version: "15.1.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0 <4.0.0" diff --git a/sdks/community/dart/pubspec.yaml b/sdks/community/dart/pubspec.yaml index 43b14854ec..b1e0183417 100644 --- a/sdks/community/dart/pubspec.yaml +++ b/sdks/community/dart/pubspec.yaml @@ -1,6 +1,6 @@ name: ag_ui description: Dart SDK for AG-UI protocol - standardizing agent-user interactions through event-based communication -version: 0.1.0 +version: 0.3.0 homepage: https://github.com/ag-ui-protocol/ag-ui repository: https://github.com/ag-ui-protocol/ag-ui/tree/main/sdks/community/dart issue_tracker: https://github.com/ag-ui-protocol/ag-ui/issues diff --git a/sdks/community/dart/test/ag_ui_test.dart b/sdks/community/dart/test/ag_ui_test.dart index 10c2dcd08b..d6ef81e35c 100644 --- a/sdks/community/dart/test/ag_ui_test.dart +++ b/sdks/community/dart/test/ag_ui_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { group('AG-UI SDK', () { test('has correct version', () { - expect(agUiVersion, '0.1.0'); + expect(agUiVersion, '0.2.0'); }); test('can initialize', () { diff --git a/sdks/community/dart/test/client/client_test.dart b/sdks/community/dart/test/client/client_test.dart index 0efc34b0f8..51d0831977 100644 --- a/sdks/community/dart/test/client/client_test.dart +++ b/sdks/community/dart/test/client/client_test.dart @@ -13,9 +13,9 @@ import 'package:ag_ui/src/sse/backoff_strategy.dart'; // Custom mock client that supports streaming responses class MockStreamingClient extends http.BaseClient { final Future Function(http.BaseRequest) _handler; - + MockStreamingClient(this._handler); - + @override Future send(http.BaseRequest request) async { return _handler(request); @@ -26,14 +26,16 @@ void main() { group('AgUiClient', () { late AgUiClient client; late MockStreamingClient mockHttpClient; - + setUp(() { mockHttpClient = MockStreamingClient((request) async { // Default mock response return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\n\n'), - utf8.encode('data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, @@ -49,26 +51,32 @@ void main() { test('sends correct request and receives stream events', () async { final expectedRunId = 'run_123'; final expectedThreadId = 'thread_456'; - + mockHttpClient = MockStreamingClient((request) async { expect(request.method, equals('POST')); - expect(request.url.toString(), equals('https://api.example.com/test_endpoint')); + expect(request.url.toString(), + equals('https://api.example.com/test_endpoint')); expect(request.headers['Content-Type'], contains('application/json')); expect(request.headers['Accept'], contains('text/event-stream')); - + if (request is http.Request) { final body = json.decode(request.body) as Map; expect(body['messages'], isA()); expect(body['config']['temperature'], equals(0.7)); } - + return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_STARTED","threadId":"$expectedThreadId","runId":"$expectedRunId"}\n\n'), - utf8.encode('data: {"type":"TEXT_MESSAGE_START","messageId":"msg1","role":"assistant"}\n\n'), - utf8.encode('data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg1","delta":"Hello!"}\n\n'), - utf8.encode('data: {"type":"TEXT_MESSAGE_END","messageId":"msg1"}\n\n'), - utf8.encode('data: {"type":"RUN_FINISHED","threadId":"$expectedThreadId","runId":"$expectedRunId"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_STARTED","threadId":"$expectedThreadId","runId":"$expectedRunId"}\n\n'), + utf8.encode( + 'data: {"type":"TEXT_MESSAGE_START","messageId":"msg1","role":"assistant"}\n\n'), + utf8.encode( + 'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg1","delta":"Hello!"}\n\n'), + utf8.encode( + 'data: {"type":"TEXT_MESSAGE_END","messageId":"msg1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","threadId":"$expectedThreadId","runId":"$expectedRunId"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, @@ -80,24 +88,27 @@ void main() { httpClient: mockHttpClient, ); - final events = await client.runAgent( - 'test_endpoint', - SimpleRunAgentInput( - messages: [UserMessage(id: 'msg1', content: 'Hello')], - config: {'temperature': 0.7}, - ), - ).toList(); + final events = await client + .runAgent( + 'test_endpoint', + SimpleRunAgentInput( + messages: [UserMessage(id: 'msg1', content: 'Hello')], + config: {'temperature': 0.7}, + ), + ) + .toList(); expect(events.length, greaterThan(0)); - + final runStarted = events.whereType().first; expect(runStarted.runId, equals(expectedRunId)); expect(runStarted.threadId, equals(expectedThreadId)); - + final runFinished = events.whereType().first; expect(runFinished.runId, equals(expectedRunId)); - - final textMessages = events.whereType().toList(); + + final textMessages = + events.whereType().toList(); expect(textMessages.isNotEmpty, isTrue); expect(textMessages.first.delta, equals('Hello!')); }); @@ -123,7 +134,8 @@ void main() { ); expect( - () => client.runAgent('test_endpoint', SimpleRunAgentInput()).toList(), + () => + client.runAgent('test_endpoint', SimpleRunAgentInput()).toList(), throwsA(isA()), ); }); @@ -146,8 +158,9 @@ void main() { ); expect( - () => client.runAgent('test_endpoint', SimpleRunAgentInput()).toList(), - throwsA(isA()), + () => + client.runAgent('test_endpoint', SimpleRunAgentInput()).toList(), + throwsA(isA()), ); }); }); @@ -157,9 +170,11 @@ void main() { mockHttpClient = MockStreamingClient((request) async { return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\n\n'), utf8.encode('data: invalid json\n\n'), // Invalid JSON - utf8.encode('data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, @@ -177,24 +192,26 @@ void main() { // Note: In a production implementation, you might want to skip invalid events // but the current implementation throws on decode errors expect( - () => client.runAgent('test_endpoint', SimpleRunAgentInput()).toList(), + () => + client.runAgent('test_endpoint', SimpleRunAgentInput()).toList(), throwsA(isA()), ); }); test('supports cancellation', () async { final cancelToken = CancelToken(); - + mockHttpClient = MockStreamingClient((request) async { // Use async generator for lazy evaluation that respects cancellation Stream> generateEvents() async* { for (int i = 0; i < 10; i++) { await Future.delayed(Duration(milliseconds: 100)); if (cancelToken.isCancelled) break; - yield utf8.encode('data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg1","delta":"chunk$i"}\n\n'); + yield utf8.encode( + 'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg1","delta":"chunk$i"}\n\n'); } } - + return http.StreamedResponse( generateEvents(), 200, @@ -210,11 +227,13 @@ void main() { ); final events = []; - final subscription = client.runAgent( - 'test_endpoint', - SimpleRunAgentInput(), - cancelToken: cancelToken, - ).listen(events.add); + final subscription = client + .runAgent( + 'test_endpoint', + SimpleRunAgentInput(), + cancelToken: cancelToken, + ) + .listen(events.add); // Cancel after a short delay await Future.delayed(Duration(milliseconds: 250)); @@ -231,12 +250,13 @@ void main() { group('endpoint methods', () { test('runAgenticChat uses correct endpoint', () async { String? capturedUrl; - + mockHttpClient = MockStreamingClient((request) async { capturedUrl = request.url.toString(); return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, @@ -254,12 +274,13 @@ void main() { test('runHumanInTheLoop uses correct endpoint', () async { String? capturedUrl; - + mockHttpClient = MockStreamingClient((request) async { capturedUrl = request.url.toString(); return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, @@ -272,19 +293,21 @@ void main() { ); await client.runHumanInTheLoop(SimpleRunAgentInput()).toList(); - expect(capturedUrl, equals('https://api.example.com/human_in_the_loop')); + expect( + capturedUrl, equals('https://api.example.com/human_in_the_loop')); }); }); group('configuration', () { test('respects custom headers', () async { Map? capturedHeaders; - + mockHttpClient = MockStreamingClient((request) async { capturedHeaders = request.headers; return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, @@ -303,7 +326,7 @@ void main() { ); await client.runAgent('test', SimpleRunAgentInput()).toList(); - + expect(capturedHeaders?['X-API-Key'], equals('secret-key')); expect(capturedHeaders?['X-Custom-Header'], equals('custom-value')); }); @@ -314,4 +337,4 @@ void main() { // this at the application layer, not the protocol layer. }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/client/config_test.dart b/sdks/community/dart/test/client/config_test.dart index 4a43f17cb7..66824a9b48 100644 --- a/sdks/community/dart/test/client/config_test.dart +++ b/sdks/community/dart/test/client/config_test.dart @@ -273,4 +273,4 @@ void main() { }); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/client/errors_test.dart b/sdks/community/dart/test/client/errors_test.dart index 8e52bf0d83..feed813ff2 100644 --- a/sdks/community/dart/test/client/errors_test.dart +++ b/sdks/community/dart/test/client/errors_test.dart @@ -23,7 +23,8 @@ void main() { 'Test message', cause: cause, ); - expect(error.toString(), contains('Caused by: Exception: Original error')); + expect( + error.toString(), contains('Caused by: Exception: Original error')); }); }); @@ -34,7 +35,8 @@ void main() { endpoint: 'https://api.example.com/runs', statusCode: 500, ); - expect(error.toString(), contains('endpoint: https://api.example.com/runs')); + expect( + error.toString(), contains('endpoint: https://api.example.com/runs')); expect(error.toString(), contains('status: 500')); }); @@ -58,9 +60,9 @@ void main() { }); }); - group('TimeoutError', () { + group('AGUITimeoutError', () { test('includes timeout duration', () { - final error = TimeoutError( + final error = AGUITimeoutError( 'Operation timed out', timeout: Duration(seconds: 30), operation: 'POST /runs', @@ -68,6 +70,16 @@ void main() { expect(error.toString(), contains('timeout: 30s')); expect(error.toString(), contains('operation: POST /runs')); }); + + test('deprecated TimeoutError typedef resolves to AGUITimeoutError', () { + // Backward-compat: pre-rename callers using the bare name still work. + // ignore: deprecated_member_use_from_same_package + final TimeoutError error = AGUITimeoutError( + 'Legacy alias', + timeout: Duration(seconds: 5), + ); + expect(error, isA()); + }); }); group('CancellationError', () { @@ -141,7 +153,8 @@ void main() { ); expect(error.toString(), contains('rule: run-lifecycle')); expect(error.toString(), contains('state: idle')); - expect(error.toString(), contains('expected: RUN_STARTED before other events')); + expect(error.toString(), + contains('expected: RUN_STARTED before other events')); }); }); @@ -190,4 +203,4 @@ void main() { // Test implementation of AgUiError for testing class TestError extends AgUiError { TestError(super.message, {super.details, super.cause}); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/client/http_endpoints_test.dart b/sdks/community/dart/test/client/http_endpoints_test.dart index b2c9044bcd..f907ad03cb 100644 --- a/sdks/community/dart/test/client/http_endpoints_test.dart +++ b/sdks/community/dart/test/client/http_endpoints_test.dart @@ -15,9 +15,9 @@ import 'package:ag_ui/src/sse/backoff_strategy.dart'; // Custom mock client that supports streaming responses class MockStreamingClient extends http.BaseClient { final Future Function(http.BaseRequest) _handler; - + MockStreamingClient(this._handler); - + @override Future send(http.BaseRequest request) async { return _handler(request); @@ -28,7 +28,7 @@ void main() { group('AgUiClient HTTP Endpoints', () { late AgUiClient client; late MockStreamingClient mockHttpClient; - + setUp(() { mockHttpClient = MockStreamingClient((request) async { // Default 404 response @@ -37,7 +37,7 @@ void main() { 404, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -47,11 +47,11 @@ void main() { httpClient: mockHttpClient, ); }); - + tearDown(() async { await client.close(); }); - + group('runAgent', () { test('sends correct POST request with SimpleRunAgentInput', () async { // Arrange @@ -67,27 +67,29 @@ void main() { config: {'temperature': 0.7}, metadata: {'source': 'test'}, ); - + String? capturedBody; Map? capturedHeaders; - + mockHttpClient = MockStreamingClient((request) async { if (request is http.Request) { capturedBody = request.body; } capturedHeaders = request.headers; - + // Return SSE stream with a simple event return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_STARTED","thread_id":"thread_123","run_id":"run_456"}\n\n'), - utf8.encode('data: {"type":"RUN_FINISHED","thread_id":"thread_123","run_id":"run_456"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_STARTED","thread_id":"thread_123","run_id":"run_456"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","thread_id":"thread_123","run_id":"run_456"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -95,29 +97,27 @@ void main() { ), httpClient: mockHttpClient, ); - + // Act - final events = await client - .runAgent('agentic_chat', input) - .toList(); - + final events = await client.runAgent('agentic_chat', input).toList(); + // Assert expect(capturedBody, isNotNull); expect(capturedHeaders?['Content-Type'], contains('application/json')); expect(capturedHeaders?['Accept'], contains('text/event-stream')); - + final bodyJson = json.decode(capturedBody!); - expect(bodyJson['thread_id'], 'thread_123'); - expect(bodyJson['run_id'], 'run_456'); + expect(bodyJson['threadId'], 'thread_123'); + expect(bodyJson['runId'], 'run_456'); expect(bodyJson['messages'], hasLength(1)); expect(bodyJson['config']['temperature'], 0.7); expect(bodyJson['metadata']['source'], 'test'); - + expect(events, hasLength(2)); expect(events[0], isA()); expect(events[1], isA()); }); - + test('handles 4xx errors correctly', () async { // Arrange mockHttpClient = MockStreamingClient((request) async { @@ -126,7 +126,7 @@ void main() { 400, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -134,9 +134,9 @@ void main() { ), httpClient: mockHttpClient, ); - + final input = SimpleRunAgentInput(threadId: 'test'); - + // Act & Assert expect( () => client.runAgent('test_endpoint', input).toList(), @@ -145,7 +145,7 @@ void main() { .having((e) => e.message, 'message', contains('failed'))), ); }); - + test('handles 5xx errors correctly', () async { // Arrange mockHttpClient = MockStreamingClient((request) async { @@ -154,7 +154,7 @@ void main() { 500, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -162,9 +162,9 @@ void main() { ), httpClient: mockHttpClient, ); - + final input = SimpleRunAgentInput(threadId: 'test'); - + // Act & Assert expect( () => client.runAgent('test_endpoint', input).toList(), @@ -172,7 +172,7 @@ void main() { .having((e) => e.statusCode, 'statusCode', 500)), ); }); - + test('handles timeout correctly', () async { // Arrange mockHttpClient = MockStreamingClient((request) async { @@ -183,7 +183,7 @@ void main() { 200, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -192,24 +192,24 @@ void main() { ), httpClient: mockHttpClient, ); - + final input = SimpleRunAgentInput(threadId: 'test'); - + // Act & Assert expect( () => client.runAgent('test_endpoint', input).toList(), - throwsA(isA()), + throwsA(isA()), ); }); - + test('handles cancellation correctly', () async { // Arrange final completer = Completer(); - + mockHttpClient = MockStreamingClient((request) async { return completer.future; }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -217,25 +217,25 @@ void main() { ), httpClient: mockHttpClient, ); - + final input = SimpleRunAgentInput(threadId: 'test'); final cancelToken = CancelToken(); - + // Act final futureEvents = client .runAgent('test_endpoint', input, cancelToken: cancelToken) .toList(); - + // Cancel the request await Future.delayed(const Duration(milliseconds: 10)); cancelToken.cancel(); - + // Complete the request after cancellation completer.complete(http.StreamedResponse( Stream.empty(), 200, )); - + // Assert expect( futureEvents, @@ -244,21 +244,23 @@ void main() { ); }); }); - + group('specific agent endpoints', () { setUp(() { mockHttpClient = MockStreamingClient((request) async { // Return a minimal SSE response return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\n\n'), - utf8.encode('data: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -267,21 +269,22 @@ void main() { httpClient: mockHttpClient, ); }); - + test('runAgenticChat calls correct endpoint', () async { String? capturedUrl; - + mockHttpClient = MockStreamingClient((request) async { capturedUrl = request.url.toString(); return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -289,25 +292,26 @@ void main() { ), httpClient: mockHttpClient, ); - + await client.runAgenticChat(SimpleRunAgentInput()).toList(); expect(capturedUrl, 'http://localhost:8000/agentic_chat'); }); - + test('runHumanInTheLoop calls correct endpoint', () async { String? capturedUrl; - + mockHttpClient = MockStreamingClient((request) async { capturedUrl = request.url.toString(); return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -315,25 +319,26 @@ void main() { ), httpClient: mockHttpClient, ); - + await client.runHumanInTheLoop(SimpleRunAgentInput()).toList(); expect(capturedUrl, 'http://localhost:8000/human_in_the_loop'); }); - + test('runToolBasedGenerativeUi calls correct endpoint', () async { String? capturedUrl; - + mockHttpClient = MockStreamingClient((request) async { capturedUrl = request.url.toString(); return http.StreamedResponse( Stream.fromIterable([ - utf8.encode('data: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}\n\n'), + utf8.encode( + 'data: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}\n\n'), ]), 200, headers: {'content-type': 'text/event-stream'}, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -341,12 +346,12 @@ void main() { ), httpClient: mockHttpClient, ); - + await client.runToolBasedGenerativeUi(SimpleRunAgentInput()).toList(); expect(capturedUrl, 'http://localhost:8000/tool_based_generative_ui'); }); }); - + group('error handling and validation', () { test('validates base URL', () async { client = AgUiClient( @@ -355,13 +360,13 @@ void main() { maxRetries: 0, ), ); - + expect( () => client.runAgent('test', SimpleRunAgentInput()).toList(), throwsA(isA()), ); }); - + test('validates thread ID when present', () async { mockHttpClient = MockStreamingClient((request) async { return http.StreamedResponse( @@ -369,7 +374,7 @@ void main() { 200, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -377,15 +382,15 @@ void main() { ), httpClient: mockHttpClient, ); - + final input = SimpleRunAgentInput(threadId: ''); // Empty thread ID - + expect( () => client.runAgent('test', input).toList(), throwsA(isA()), ); }); - + test('handles malformed SSE data gracefully', () async { mockHttpClient = MockStreamingClient((request) async { return http.StreamedResponse( @@ -397,7 +402,7 @@ void main() { headers: {'content-type': 'text/event-stream'}, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -405,7 +410,7 @@ void main() { ), httpClient: mockHttpClient, ); - + // When malformed data is encountered, the stream should error // This is the expected behavior - fail fast on invalid data expect( @@ -414,16 +419,16 @@ void main() { ); }); }); - + group('request retry logic', () { test('retries on 5xx errors with backoff', () async { int attemptCount = 0; final attemptTimes = []; - + mockHttpClient = MockStreamingClient((request) async { attemptCount++; attemptTimes.add(DateTime.now()); - + if (attemptCount < 3) { return http.StreamedResponse( Stream.value(utf8.encode('Server Error')), @@ -435,7 +440,7 @@ void main() { 200, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -446,26 +451,26 @@ void main() { ), httpClient: mockHttpClient, ); - + // Use _sendRequest for testing retry logic final response = await client.sendRequestForTesting( 'GET', 'http://localhost:8000/test', ); - + expect(response.statusCode, 200); expect(attemptCount, 3); - + // Check that delays were applied if (attemptTimes.length >= 2) { final delay1 = attemptTimes[1].difference(attemptTimes[0]); expect(delay1.inMilliseconds, greaterThanOrEqualTo(90)); } }); - + test('does not retry on 4xx errors', () async { int attemptCount = 0; - + mockHttpClient = MockStreamingClient((request) async { attemptCount++; return http.StreamedResponse( @@ -473,7 +478,7 @@ void main() { 400, ); }); - + client = AgUiClient( config: AgUiClientConfig( baseUrl: 'http://localhost:8000', @@ -481,12 +486,12 @@ void main() { ), httpClient: mockHttpClient, ); - + final response = await client.sendRequestForTesting( 'GET', 'http://localhost:8000/test', ); - + expect(response.statusCode, 400); expect(attemptCount, 1); // No retries }); @@ -509,12 +514,12 @@ extension TestHelper on AgUiClient { // Test backoff strategy class FixedBackoffStrategy implements BackoffStrategy { final Duration delay; - + FixedBackoffStrategy(this.delay); - + @override Duration nextDelay(int attempt) => delay; - + @override void reset() {} -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/client/validators_test.dart b/sdks/community/dart/test/client/validators_test.dart index 418b3f5867..4122080e0b 100644 --- a/sdks/community/dart/test/client/validators_test.dart +++ b/sdks/community/dart/test/client/validators_test.dart @@ -1,11 +1,13 @@ import 'package:test/test.dart'; import 'package:ag_ui/src/client/errors.dart'; import 'package:ag_ui/src/client/validators.dart'; +import 'package:ag_ui/src/types/message.dart'; void main() { group('Validators.requireNonEmpty', () { test('accepts non-empty strings', () { - expect(() => Validators.requireNonEmpty('value', 'field'), returnsNormally); + expect( + () => Validators.requireNonEmpty('value', 'field'), returnsNormally); }); test('rejects null strings', () { @@ -45,9 +47,13 @@ void main() { group('Validators.validateUrl', () { test('accepts valid HTTP URLs', () { - expect(() => Validators.validateUrl('http://example.com', 'url'), returnsNormally); - expect(() => Validators.validateUrl('https://api.example.com/path', 'url'), returnsNormally); - expect(() => Validators.validateUrl('https://example.com:8080', 'url'), returnsNormally); + expect(() => Validators.validateUrl('http://example.com', 'url'), + returnsNormally); + expect( + () => Validators.validateUrl('https://api.example.com/path', 'url'), + returnsNormally); + expect(() => Validators.validateUrl('https://example.com:8080', 'url'), + returnsNormally); }); test('rejects invalid URLs', () { @@ -74,6 +80,28 @@ void main() { .having((e) => e.constraint, 'constraint', 'non-empty')), ); }); + + test('rejects credential-bearing URLs (userInfo component)', () { + expect( + () => Validators.validateUrl('http://user:pass@example.com', 'url'), + throwsA(isA() + .having((e) => e.constraint, 'constraint', 'no-user-credentials')), + ); + expect( + () => Validators.validateUrl('https://token@api.example.com', 'url'), + throwsA(isA() + .having((e) => e.constraint, 'constraint', 'no-user-credentials')), + ); + }); + + test('rejects bare-scheme URL http://', () { + // Uri.parse('http://').hasAuthority is true but host is '' — the + // uri.host.isEmpty check is load-bearing and must be exercised. + expect( + () => Validators.validateUrl('http://', 'baseUrl'), + throwsA(isA()), + ); + }); }); group('Validators.validateAgentId', () { @@ -89,7 +117,8 @@ void main() { () => Validators.validateAgentId('agent@123'), throwsA(isA() .having((e) => e.field, 'field', 'agentId') - .having((e) => e.constraint, 'constraint', 'alphanumeric-with-hyphens-underscores')), + .having((e) => e.constraint, 'constraint', + 'alphanumeric-with-hyphens-underscores')), ); }); @@ -124,7 +153,10 @@ void main() { group('Validators.validateRunId', () { test('accepts valid run IDs', () { expect(() => Validators.validateRunId('run-123'), returnsNormally); - expect(() => Validators.validateRunId('550e8400-e29b-41d4-a716-446655440000'), returnsNormally); + expect( + () => + Validators.validateRunId('550e8400-e29b-41d4-a716-446655440000'), + returnsNormally); }); test('rejects too long IDs', () { @@ -147,7 +179,10 @@ void main() { group('Validators.validateThreadId', () { test('accepts valid thread IDs', () { expect(() => Validators.validateThreadId('thread-123'), returnsNormally); - expect(() => Validators.validateThreadId('550e8400-e29b-41d4-a716-446655440000'), returnsNormally); + expect( + () => Validators.validateThreadId( + '550e8400-e29b-41d4-a716-446655440000'), + returnsNormally); }); test('rejects too long IDs', () { @@ -160,27 +195,37 @@ void main() { }); }); - group('Validators.validateMessageContent', () { - test('accepts valid content types', () { - expect(() => Validators.validateMessageContent('Hello world'), returnsNormally); - expect(() => Validators.validateMessageContent({'text': 'Hello'}), returnsNormally); - expect(() => Validators.validateMessageContent(['item1', 'item2']), returnsNormally); + group('Validators.validateUserMessageContent', () { + test('accepts text content', () { + expect( + () => Validators.validateUserMessageContent(const TextContent('Hello')), + returnsNormally, + ); }); - test('rejects null content', () { + test('accepts multimodal content with valid parts', () { + // Regression: multimodal content must validate, not throw on a null + // `content` getter as the retired validateMessageContent did. + final content = MultimodalContent([ + TextInputContent('look'), + const ImageInputContent( + source: UrlSource(value: 'https://example.com/i.png'), + ), + ]); expect( - () => Validators.validateMessageContent(null), - throwsA(isA() - .having((e) => e.field, 'field', 'content') - .having((e) => e.constraint, 'constraint', 'non-null')), + () => Validators.validateUserMessageContent(content), + returnsNormally, ); }); - test('rejects invalid types', () { + test('rejects empty parts list', () { expect( - () => Validators.validateMessageContent(123), + () => Validators.validateUserMessageContent( + const MultimodalContent([]), + ), throwsA(isA() - .having((e) => e.constraint, 'constraint', 'valid-type')), + .having((e) => e.field, 'field', 'content') + .having((e) => e.constraint, 'constraint', 'non-empty')), ); }); }); @@ -188,8 +233,10 @@ void main() { group('Validators.validateTimeout', () { test('accepts valid timeouts', () { expect(() => Validators.validateTimeout(null), returnsNormally); - expect(() => Validators.validateTimeout(Duration(seconds: 30)), returnsNormally); - expect(() => Validators.validateTimeout(Duration(minutes: 5)), returnsNormally); + expect(() => Validators.validateTimeout(Duration(seconds: 30)), + returnsNormally); + expect(() => Validators.validateTimeout(Duration(minutes: 5)), + returnsNormally); }); test('rejects negative timeouts', () { @@ -240,7 +287,8 @@ void main() { () => Validators.validateJson(null, 'test'), throwsA(isA() .having((e) => e.field, 'field', 'test') - .having((e) => e.expectedType, 'expectedType', 'Map')), + .having( + (e) => e.expectedType, 'expectedType', 'Map')), ); }); @@ -249,16 +297,20 @@ void main() { () => Validators.validateJson('string', 'test'), throwsA(isA() .having((e) => e.field, 'field', 'test') - .having((e) => e.expectedType, 'expectedType', 'Map')), + .having( + (e) => e.expectedType, 'expectedType', 'Map')), ); }); }); group('Validators.validateEventType', () { test('accepts valid event types', () { - expect(() => Validators.validateEventType('RUN_STARTED'), returnsNormally); - expect(() => Validators.validateEventType('TEXT_MESSAGE_START'), returnsNormally); - expect(() => Validators.validateEventType('TOOL_CALL_END'), returnsNormally); + expect( + () => Validators.validateEventType('RUN_STARTED'), returnsNormally); + expect(() => Validators.validateEventType('TEXT_MESSAGE_START'), + returnsNormally); + expect( + () => Validators.validateEventType('TOOL_CALL_END'), returnsNormally); }); test('rejects invalid formats', () { @@ -283,9 +335,12 @@ void main() { group('Validators.validateStatusCode', () { test('accepts success status codes', () { - expect(() => Validators.validateStatusCode(200, '/api/test'), returnsNormally); - expect(() => Validators.validateStatusCode(201, '/api/test'), returnsNormally); - expect(() => Validators.validateStatusCode(204, '/api/test'), returnsNormally); + expect(() => Validators.validateStatusCode(200, '/api/test'), + returnsNormally); + expect(() => Validators.validateStatusCode(201, '/api/test'), + returnsNormally); + expect(() => Validators.validateStatusCode(204, '/api/test'), + returnsNormally); }); test('throws on client errors', () { @@ -328,8 +383,7 @@ void main() { test('rejects events without data field', () { expect( () => Validators.validateSseEvent({'id': '123'}), - throwsA(isA() - .having((e) => e.field, 'field', 'data')), + throwsA(isA().having((e) => e.field, 'field', 'data')), ); }); }); @@ -344,14 +398,16 @@ void main() { test('accepts RUN_STARTED after RUN_FINISHED', () { expect( - () => Validators.validateEventSequence('RUN_STARTED', 'RUN_FINISHED', 'finished'), + () => Validators.validateEventSequence( + 'RUN_STARTED', 'RUN_FINISHED', 'finished'), returnsNormally, ); }); test('rejects RUN_STARTED in wrong sequence', () { expect( - () => Validators.validateEventSequence('RUN_STARTED', 'TEXT_MESSAGE_START', 'running'), + () => Validators.validateEventSequence( + 'RUN_STARTED', 'TEXT_MESSAGE_START', 'running'), throwsA(isA() .having((e) => e.rule, 'rule', 'run-lifecycle')), ); @@ -367,7 +423,8 @@ void main() { test('rejects tool calls outside of run', () { expect( - () => Validators.validateEventSequence('TOOL_CALL_START', 'RUN_FINISHED', 'idle'), + () => Validators.validateEventSequence( + 'TOOL_CALL_START', 'RUN_FINISHED', 'idle'), throwsA(isA() .having((e) => e.rule, 'rule', 'tool-call-lifecycle')), ); @@ -375,7 +432,8 @@ void main() { test('accepts tool calls within run', () { expect( - () => Validators.validateEventSequence('TOOL_CALL_START', 'RUN_STARTED', 'running'), + () => Validators.validateEventSequence( + 'TOOL_CALL_START', 'RUN_STARTED', 'running'), returnsNormally, ); }); @@ -412,8 +470,8 @@ void main() { 'TestModel', (data) => TestModel(data['id'] as String, data['name'] as String), ), - throwsA(isA() - .having((e) => e.field, 'field', 'TestModel')), + throwsA( + isA().having((e) => e.field, 'field', 'TestModel')), ); }); }); @@ -468,4 +526,4 @@ class TestModel { final String id; final String name; TestModel(this.id, this.name); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/encoder/client_codec_test.dart b/sdks/community/dart/test/encoder/client_codec_test.dart index 2ab873bcf5..a5006f6e05 100644 --- a/sdks/community/dart/test/encoder/client_codec_test.dart +++ b/sdks/community/dart/test/encoder/client_codec_test.dart @@ -53,6 +53,11 @@ void main() { }); test('encodeRunAgentInput handles empty input', () { + // Required fields (state, messages, tools, context, forwardedProps) are + // always emitted — falling back to empty containers when null — so strict + // servers (pydantic BaseModel with required fields) do not reject the + // payload with 422. Optional fields (threadId, runId, etc.) are omitted + // when null. final input = SimpleRunAgentInput( messages: [], ); @@ -60,12 +65,11 @@ void main() { final encoded = encoder.encodeRunAgentInput(input); expect(encoded, isA>()); - expect(encoded['messages'], isEmpty); - // These fields are always included with defaults for API consistency - expect(encoded['state'], equals({})); - expect(encoded['tools'], isEmpty); - expect(encoded['context'], isEmpty); - expect(encoded['forwardedProps'], equals({})); + expect(encoded['messages'], isEmpty); // explicit empty list → emitted + expect(encoded['state'], isEmpty); // null → emitted as {} + expect(encoded['tools'], isEmpty); // null → emitted as [] + expect(encoded['context'], isEmpty); // null → emitted as [] + expect(encoded['forwardedProps'], isEmpty); // null → emitted as {} }); test('encodeUserMessage encodes UserMessage correctly', () { @@ -95,8 +99,8 @@ void main() { expect(encoded['id'], equals('msg-simple')); }); - test('encodeToolResult encodes ToolResult with all fields', () { - final result = codec.ToolResult( + test('encodeToolResult encodes ClientToolResult with all fields', () { + final result = codec.ClientToolResult( toolCallId: 'call_123', result: {'data': 'test result'}, error: 'Some error occurred', @@ -113,7 +117,7 @@ void main() { }); test('encodeToolResult handles result without optional fields', () { - final result = codec.ToolResult( + final result = codec.ClientToolResult( toolCallId: 'call_456', result: 'Simple result', ); @@ -136,7 +140,7 @@ void main() { 'number': 42.5, }; - final result = codec.ToolResult( + final result = codec.ClientToolResult( toolCallId: 'call_789', result: complexResult, ); @@ -147,7 +151,7 @@ void main() { }); test('encodeToolResult handles null result', () { - final result = codec.ToolResult( + final result = codec.ClientToolResult( toolCallId: 'call_null', result: null, ); @@ -172,9 +176,9 @@ void main() { }); }); - group('ToolResult', () { + group('ClientToolResult', () { test('creates with required fields only', () { - final result = codec.ToolResult( + final result = codec.ClientToolResult( toolCallId: 'id_123', result: 'test', ); @@ -186,7 +190,7 @@ void main() { }); test('creates with all fields', () { - final result = codec.ToolResult( + final result = codec.ClientToolResult( toolCallId: 'id_456', result: {'key': 'value'}, error: 'Error message', @@ -200,7 +204,7 @@ void main() { }); test('const constructor works', () { - const result = codec.ToolResult( + const result = codec.ClientToolResult( toolCallId: 'const_id', result: 'const_result', ); @@ -211,24 +215,25 @@ void main() { test('handles different result types', () { // String result - var result = codec.ToolResult(toolCallId: '1', result: 'string'); + var result = codec.ClientToolResult(toolCallId: '1', result: 'string'); expect(result.result, isA()); // Number result - result = codec.ToolResult(toolCallId: '2', result: 42); + result = codec.ClientToolResult(toolCallId: '2', result: 42); expect(result.result, isA()); // Boolean result - result = codec.ToolResult(toolCallId: '3', result: true); + result = codec.ClientToolResult(toolCallId: '3', result: true); expect(result.result, isA()); // List result - result = codec.ToolResult(toolCallId: '4', result: [1, 2, 3]); + result = codec.ClientToolResult(toolCallId: '4', result: [1, 2, 3]); expect(result.result, isA()); // Map result - result = codec.ToolResult(toolCallId: '5', result: {'nested': 'object'}); + result = + codec.ClientToolResult(toolCallId: '5', result: {'nested': 'object'}); expect(result.result, isA()); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/encoder/decoder_test.dart b/sdks/community/dart/test/encoder/decoder_test.dart index 3af8496b6c..1a2214eb9f 100644 --- a/sdks/community/dart/test/encoder/decoder_test.dart +++ b/sdks/community/dart/test/encoder/decoder_test.dart @@ -1,8 +1,10 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:ag_ui/src/client/errors.dart'; import 'package:ag_ui/src/encoder/decoder.dart'; +import 'package:ag_ui/src/encoder/stream_adapter.dart'; import 'package:ag_ui/src/events/events.dart'; import 'package:ag_ui/src/types/base.dart'; import 'package:ag_ui/src/types/message.dart'; @@ -18,9 +20,10 @@ void main() { group('decode', () { test('decodes simple text message start event', () { - final json = '{"type":"TEXT_MESSAGE_START","messageId":"msg123","role":"assistant"}'; + final json = + '{"type":"TEXT_MESSAGE_START","messageId":"msg123","role":"assistant"}'; final event = decoder.decode(json); - + expect(event, isA()); final textEvent = event as TextMessageStartEvent; expect(textEvent.messageId, equals('msg123')); @@ -28,9 +31,10 @@ void main() { }); test('decodes text message content event', () { - final json = '{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg123","delta":"Hello, world!"}'; + final json = + '{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg123","delta":"Hello, world!"}'; final event = decoder.decode(json); - + expect(event, isA()); final textEvent = event as TextMessageContentEvent; expect(textEvent.messageId, equals('msg123')); @@ -38,9 +42,10 @@ void main() { }); test('decodes tool call events', () { - final json = '{"type":"TOOL_CALL_START","toolCallId":"tool456","toolCallName":"search"}'; + final json = + '{"type":"TOOL_CALL_START","toolCallId":"tool456","toolCallName":"search"}'; final event = decoder.decode(json); - + expect(event, isA()); final toolEvent = event as ToolCallStartEvent; expect(toolEvent.toolCallId, equals('tool456')); @@ -49,31 +54,38 @@ void main() { test('throws DecodingError for invalid JSON', () { final invalidJson = 'not valid json'; - + expect( () => decoder.decode(invalidJson), throwsA(isA() - .having((e) => e.message, 'message', contains('Invalid JSON')) - .having((e) => e.actualValue, 'actualValue', equals(invalidJson))), + .having((e) => e.message, 'message', contains('Invalid JSON')) + // actualValue is a length sentinel (''), not the raw + // payload — avoids forwarding cipher data through error handlers. + .having((e) => e.actualValue, 'actualValue', + equals('<${invalidJson.length} chars>'))), ); }); test('throws DecodingError for missing required fields', () { final json = '{"type":"TEXT_MESSAGE_START"}'; // Missing messageId - + expect( () => decoder.decode(json), - throwsA(isA()), // Event creation fails before validation + throwsA( + isA()), // Event creation fails before validation ); }); - test('throws DecodingError for empty delta in content event', () { - final json = '{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg123","delta":""}'; - - expect( - () => decoder.decode(json), - throwsA(isA()), // Event creation fails - ); + test('accepts empty delta in TEXT_MESSAGE_CONTENT (canonical parity)', + () { + // Canonical TS/Python schemas allow empty `delta`. Decoder + // pipeline must mirror the relaxed contract end-to-end. + final json = + '{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg123","delta":""}'; + + final event = decoder.decode(json); + expect(event, isA()); + expect((event as TextMessageContentEvent).delta, isEmpty); }); }); @@ -84,9 +96,9 @@ void main() { 'threadId': 'thread789', 'runId': 'run012', }; - + final event = decoder.decodeJson(json); - + expect(event, isA()); final runEvent = event as RunStartedEvent; expect(runEvent.threadId, equals('thread789')); @@ -99,9 +111,9 @@ void main() { 'thread_id': 'thread789', 'run_id': 'run012', }; - + final event = decoder.decodeJson(json); - + expect(event, isA()); final runEvent = event as RunStartedEvent; expect(runEvent.threadId, equals('thread789')); @@ -123,9 +135,9 @@ void main() { }, }, }; - + final event = decoder.decodeJson(json); - + expect(event, isA()); final stateEvent = event as StateSnapshotEvent; expect(stateEvent.snapshot, isA()); @@ -149,9 +161,9 @@ void main() { }, ], }; - + final event = decoder.decodeJson(json); - + expect(event, isA()); final messagesEvent = event as MessagesSnapshotEvent; expect(messagesEvent.messages.length, equals(2)); @@ -171,9 +183,9 @@ void main() { 'parentMessageId': 'msg123', 'timestamp': 1234567890, }; - + final event = decoder.decodeJson(json); - + expect(event, isA()); final toolEvent = event as ToolCallStartEvent; expect(toolEvent.parentMessageId, equals('msg123')); @@ -185,9 +197,9 @@ void main() { 'type': 'TEXT_MESSAGE_CHUNK', 'messageId': 'msg123', }; - + final event = decoder.decodeJson(json); - + expect(event, isA()); final chunkEvent = event as TextMessageChunkEvent; expect(chunkEvent.messageId, equals('msg123')); @@ -198,18 +210,20 @@ void main() { group('decodeSSE', () { test('decodes complete SSE message', () { - final sseMessage = 'data: {"type":"TEXT_MESSAGE_START","messageId":"msg123"}\n\n'; + final sseMessage = + 'data: {"type":"TEXT_MESSAGE_START","messageId":"msg123"}\n\n'; final event = decoder.decodeSSE(sseMessage); - + expect(event, isA()); final textEvent = event as TextMessageStartEvent; expect(textEvent.messageId, equals('msg123')); }); test('decodes SSE message without space after colon', () { - final sseMessage = 'data:{"type":"TEXT_MESSAGE_END","messageId":"msg123"}\n\n'; + final sseMessage = + 'data:{"type":"TEXT_MESSAGE_END","messageId":"msg123"}\n\n'; final event = decoder.decodeSSE(sseMessage); - + expect(event, isA()); final textEvent = event as TextMessageEndEvent; expect(textEvent.messageId, equals('msg123')); @@ -222,7 +236,7 @@ data: "delta":"Hello"} '''; final event = decoder.decodeSSE(sseMessage); - + expect(event, isA()); final textEvent = event as TextMessageContentEvent; expect(textEvent.messageId, equals('msg123')); @@ -237,7 +251,7 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} '''; final event = decoder.decodeSSE(sseMessage); - + expect(event, isA()); final runEvent = event as RunFinishedEvent; expect(runEvent.threadId, equals('t1')); @@ -246,21 +260,21 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} test('throws DecodingError for SSE without data field', () { final sseMessage = 'id: 123\nevent: message\n\n'; - + expect( () => decoder.decodeSSE(sseMessage), throwsA(isA() - .having((e) => e.message, 'message', contains('No data found'))), + .having((e) => e.message, 'message', contains('No data found'))), ); }); test('throws DecodingError for SSE keep-alive comment', () { final sseMessage = 'data: :\n\n'; - + expect( () => decoder.decodeSSE(sseMessage), throwsA(isA() - .having((e) => e.message, 'message', contains('keep-alive'))), + .having((e) => e.message, 'message', contains('keep-alive'))), ); }); }); @@ -269,9 +283,9 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} test('decodes UTF-8 encoded JSON', () { final json = '{"type":"CUSTOM","name":"test","value":42}'; final binary = Uint8List.fromList(utf8.encode(json)); - + final event = decoder.decodeBinary(binary); - + expect(event, isA()); final customEvent = event as CustomEvent; expect(customEvent.name, equals('test')); @@ -281,22 +295,23 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} test('decodes UTF-8 encoded SSE message', () { final sseMessage = 'data: {"type":"RAW","event":{"foo":"bar"}}\n\n'; final binary = Uint8List.fromList(utf8.encode(sseMessage)); - + final event = decoder.decodeBinary(binary); - + expect(event, isA()); final rawEvent = event as RawEvent; expect(rawEvent.event, equals({'foo': 'bar'})); }); test('throws DecodingError for invalid UTF-8', () { - // Invalid UTF-8 sequence + // Invalid UTF-8 sequence (likely protobuf bytes on a proto-negotiated + // channel — the error message now directs callers to use SSE transport). final binary = Uint8List.fromList([0xFF, 0xFE, 0xFD]); - + expect( () => decoder.decodeBinary(binary), throwsA(isA() - .having((e) => e.message, 'message', contains('Invalid UTF-8'))), + .having((e) => e.message, 'message', contains('UTF-8'))), ); }); }); @@ -309,39 +324,53 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} test('throws ValidationError for empty messageId', () { final event = TextMessageStartEvent(messageId: ''); - + expect( () => decoder.validate(event), throwsA(isA() - .having((e) => e.field, 'field', equals('messageId')) - .having((e) => e.message, 'message', contains('cannot be empty'))), + .having((e) => e.field, 'field', equals('messageId')) + .having( + (e) => e.message, 'message', contains('cannot be empty'))), ); }); - test('throws ValidationError for empty delta in content event', () { + test('validate() accepts empty delta in content event (canonical parity)', + () { + // Canonical TS/Python schemas allow empty `delta` + // (`TextMessageContentEventSchema.delta: z.string()`). The + // decoder pipeline must NOT reject it. The deprecated + // `Thinking*Content` events now also accept empty `delta`. final event = TextMessageContentEvent( messageId: 'msg123', delta: '', ); - - expect( - () => decoder.validate(event), - throwsA(isA() - .having((e) => e.field, 'field', equals('delta')) - .having((e) => e.message, 'message', contains('cannot be empty'))), - ); + + expect(decoder.validate(event), isTrue); }); + test( + 'accepts empty delta in deprecated thinking-text content event', + () { + // Relaxed to match the canonical `z.string()` contract — empty + // `delta` is now accepted. Consumers should migrate to + // [ReasoningMessageContentEvent] which uses the relaxed contract. + // ignore: deprecated_member_use_from_same_package + final event = ThinkingTextMessageContentEvent(delta: ''); + + expect(decoder.validate(event), isTrue); + }, + ); + test('throws ValidationError for empty tool call fields', () { final event = ToolCallStartEvent( toolCallId: '', toolCallName: 'search', ); - + expect( () => decoder.validate(event), throwsA(isA() - .having((e) => e.field, 'field', equals('toolCallId'))), + .having((e) => e.field, 'field', equals('toolCallId'))), ); }); @@ -350,21 +379,21 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} threadId: 'thread123', runId: '', ); - + expect( () => decoder.validate(event), throwsA(isA() - .having((e) => e.field, 'field', equals('runId'))), + .having((e) => e.field, 'field', equals('runId'))), ); }); test('validates events without specific validation rules', () { final event = ThinkingStartEvent(title: 'Planning'); expect(decoder.validate(event), isTrue); - + final event2 = StateSnapshotEvent(snapshot: {}); expect(decoder.validate(event2), isTrue); - + final event3 = CustomEvent(name: 'test', value: null); expect(decoder.validate(event3), isTrue); }); @@ -373,7 +402,7 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} group('error handling', () { test('preserves stack trace on decode errors', () { final invalidJson = 'not json'; - + try { decoder.decode(invalidJson); fail('Should have thrown'); @@ -385,7 +414,7 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} test('includes source in error for debugging', () { final json = '{"type":"UNKNOWN_EVENT"}'; - + try { decoder.decode(json); fail('Should have thrown'); @@ -400,7 +429,7 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} test('truncates long source in error toString', () { final longJson = '{"data":"${'x' * 300}"}'; - + try { decoder.decode(longJson); fail('Should have thrown'); @@ -412,5 +441,52 @@ data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"} } }); }); + + test( + 'decodeSSE and fromRawSseStream produce identical events for the same ' + 'complete-frame input (Opus2 S8 parity)', () async { + // Locks in behavioral equivalence between the LineSplitter-based + // decodeSSE path and the hand-rolled _scanLines path in fromRawSseStream + // for well-formed complete frames. Divergence on edge cases (e.g. lone-CR + // terminators) is NOT expected to be caught here — this tests the common + // path only. + const frames = [ + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\n\n', + 'data: {"type":"TEXT_MESSAGE_START","messageId":"m1","role":"assistant"}\n\n', + 'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m1","delta":"hello"}\n\n', + 'data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\n\n', + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n', + ]; + + // decodeSSE path: decode each SSE frame string directly. + final decodeSSEEvents = frames.map(decoder.decodeSSE).toList(); + + // fromRawSseStream path: push the same frames as a single-chunk stream. + final streamAdapter = EventStreamAdapter(); + final rawStream = + Stream.fromIterable(frames); + final fromRawEvents = + await streamAdapter.fromRawSseStream(rawStream).toList(); + + expect( + fromRawEvents.length, + equals(decodeSSEEvents.length), + reason: 'both paths must decode the same number of events', + ); + for (var i = 0; i < decodeSSEEvents.length; i++) { + expect( + fromRawEvents[i].runtimeType, + equals(decodeSSEEvents[i].runtimeType), + reason: + 'event[$i]: fromRawSseStream yields ${fromRawEvents[i].runtimeType}, ' + 'decodeSSE yields ${decodeSSEEvents[i].runtimeType}', + ); + expect( + fromRawEvents[i].eventType, + equals(decodeSSEEvents[i].eventType), + reason: 'event[$i] eventType mismatch', + ); + } + }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/encoder/encoder_test.dart b/sdks/community/dart/test/encoder/encoder_test.dart index ad66dd6cbc..6daf42f70b 100644 --- a/sdks/community/dart/test/encoder/encoder_test.dart +++ b/sdks/community/dart/test/encoder/encoder_test.dart @@ -21,7 +21,9 @@ void main() { expect(encoder.getContentType(), equals('text/event-stream')); }); - test('creates encoder with protobuf support when accept header includes it', () { + test( + 'creates encoder with protobuf support when accept header includes it', + () { final encoder = EventEncoder( accept: 'application/vnd.ag-ui.event+proto, text/event-stream', ); @@ -29,7 +31,8 @@ void main() { expect(encoder.getContentType(), equals(aguiMediaType)); }); - test('creates encoder without protobuf when accept header excludes it', () { + test('creates encoder without protobuf when accept header excludes it', + () { final encoder = EventEncoder(accept: 'text/event-stream'); expect(encoder.acceptsProtobuf, isFalse); expect(encoder.getContentType(), equals('text/event-stream')); @@ -44,14 +47,14 @@ void main() { ); final encoded = encoder.encodeSSE(event); - + expect(encoded, startsWith('data: ')); expect(encoded, endsWith('\n\n')); - + // Extract and parse JSON final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - + expect(json['type'], equals('TEXT_MESSAGE_START')); expect(json['messageId'], equals('msg123')); expect(json['role'], equals('assistant')); @@ -66,7 +69,7 @@ void main() { final encoded = encoder.encodeSSE(event); final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - + expect(json['type'], equals('TEXT_MESSAGE_CONTENT')); expect(json['messageId'], equals('msg123')); expect(json['delta'], equals('Hello, world!')); @@ -82,7 +85,7 @@ void main() { final encoded = encoder.encodeSSE(event); final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - + expect(json['type'], equals('TOOL_CALL_START')); expect(json['toolCallId'], equals('tool456')); expect(json['toolCallName'], equals('search')); @@ -98,7 +101,7 @@ void main() { final encoded = encoder.encodeSSE(event); final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - + expect(json['type'], equals('RUN_STARTED')); expect(json['threadId'], equals('thread789')); expect(json['runId'], equals('run012')); @@ -112,7 +115,7 @@ void main() { final encoded = encoder.encodeSSE(event); final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - + expect(json['type'], equals('STATE_SNAPSHOT')); expect(json['snapshot'], equals({'counter': 42, 'name': 'test'})); }); @@ -134,7 +137,7 @@ void main() { final encoded = encoder.encodeSSE(event); final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - + expect(json['type'], equals('MESSAGES_SNAPSHOT')); expect(json['messages'], isA()); expect(json['messages'].length, equals(2)); @@ -149,7 +152,7 @@ void main() { final encoded = encoder.encodeSSE(event); final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - + expect(json['type'], equals('TEXT_MESSAGE_CHUNK')); expect(json['messageId'], equals('msg123')); expect(json.containsKey('role'), isFalse); @@ -166,7 +169,7 @@ void main() { final encoded = encoder.encodeSSE(event); final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - + expect(json['timestamp'], equals(timestamp)); }); }); @@ -179,7 +182,7 @@ void main() { final encoded = encoder.encode(event); final encodedSSE = encoder.encodeSSE(event); - + expect(encoded, equals(encodedSSE)); }); }); @@ -193,7 +196,7 @@ void main() { final binary = encoder.encodeBinary(event); final decoded = utf8.decode(binary); - + expect(decoded, startsWith('data: ')); expect(decoded, endsWith('\n\n')); expect(decoded, contains('"type":"TEXT_MESSAGE_START"')); @@ -210,7 +213,7 @@ void main() { final binary = encoder.encodeBinary(event); final decoded = utf8.decode(binary); - + // Should fall back to SSE until protobuf is implemented expect(decoded, startsWith('data: ')); expect(decoded, contains('"type":"TEXT_MESSAGE_START"')); @@ -223,7 +226,7 @@ void main() { messageId: 'msg123', toolCallId: 'tool456', content: 'Search results: ...', - role: 'tool', + role: ToolCallResultRole.tool, ); final encoded = encoder.encodeSSE(originalEvent); @@ -278,7 +281,7 @@ void main() { final encoded = encoder.encodeSSE(event); final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - + expect(json['delta'], equals('Line 1\nLine 2\nLine 3')); }); @@ -291,8 +294,9 @@ void main() { final encoded = encoder.encodeSSE(event); final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - - expect(json['delta'], equals('Special chars: "quotes", \\backslash\\, \ttab')); + + expect(json['delta'], + equals('Special chars: "quotes", \\backslash\\, \ttab')); }); test('handles unicode characters', () { @@ -304,9 +308,9 @@ void main() { final encoded = encoder.encodeSSE(event); final jsonStr = encoded.substring(6, encoded.length - 2); final json = jsonDecode(jsonStr) as Map; - + expect(json['delta'], equals('Unicode: 你好 🌟 €')); }); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/encoder/errors_test.dart b/sdks/community/dart/test/encoder/errors_test.dart index 5a181a38b4..fa4242b68a 100644 --- a/sdks/community/dart/test/encoder/errors_test.dart +++ b/sdks/community/dart/test/encoder/errors_test.dart @@ -109,7 +109,8 @@ void main() { expect(str, contains('Source (truncated):')); expect(str, contains('x' * 200)); expect(str, contains('...')); - expect(str.contains('x' * 250), isFalse); // Full string should not be present + expect(str.contains('x' * 250), + isFalse); // Full string should not be present }); test('toString handles short source without truncation', () { @@ -172,7 +173,11 @@ void main() { }); test('toString shows source type instead of value', () { - final complexObject = {'nested': {'data': [1, 2, 3]}}; + final complexObject = { + 'nested': { + 'data': [1, 2, 3] + } + }; final error = EncodeError( message: 'Complex object error', source: complexObject, @@ -294,12 +299,15 @@ void main() { final str = error.toString(); expect(str, contains('ValidationError: Null value error')); expect(str, contains('Field: optional_field')); - expect(str.contains('Value:'), isFalse); // Should not include value line when null + expect(str.contains('Value:'), + isFalse); // Should not include value line when null }); test('handles complex value types', () { final complexValue = { - 'nested': {'array': [1, 2, 3]}, + 'nested': { + 'array': [1, 2, 3] + }, 'boolean': true, }; final error = ValidationError( @@ -308,7 +316,8 @@ void main() { ); final str = error.toString(); - expect(str, contains('Value: {nested: {array: [1, 2, 3]}, boolean: true}')); + expect( + str, contains('Value: {nested: {array: [1, 2, 3]}, boolean: true}')); }); }); @@ -339,4 +348,4 @@ void main() { expect(error.message, equals('validation msg')); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/encoder/stream_adapter_test.dart b/sdks/community/dart/test/encoder/stream_adapter_test.dart index 394ee3d5eb..d961f1de99 100644 --- a/sdks/community/dart/test/encoder/stream_adapter_test.dart +++ b/sdks/community/dart/test/encoder/stream_adapter_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:ag_ui/src/client/errors.dart'; import 'package:ag_ui/src/encoder/stream_adapter.dart'; import 'package:ag_ui/src/events/events.dart'; import 'package:ag_ui/src/sse/sse_message.dart'; @@ -17,24 +18,26 @@ void main() { test('converts SSE messages to typed events', () async { final sseController = StreamController(); final eventStream = adapter.fromSseStream(sseController.stream); - + final events = []; final subscription = eventStream.listen(events.add); - + // Add SSE messages sseController.add(SseMessage( - data: '{"type":"TEXT_MESSAGE_START","messageId":"msg1","role":"assistant"}', + data: + '{"type":"TEXT_MESSAGE_START","messageId":"msg1","role":"assistant"}', )); sseController.add(SseMessage( - data: '{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg1","delta":"Hello"}', + data: + '{"type":"TEXT_MESSAGE_CONTENT","messageId":"msg1","delta":"Hello"}', )); sseController.add(SseMessage( data: '{"type":"TEXT_MESSAGE_END","messageId":"msg1"}', )); - + await sseController.close(); await subscription.cancel(); - + expect(events.length, equals(3)); expect(events[0], isA()); expect(events[1], isA()); @@ -44,22 +47,23 @@ void main() { test('ignores non-data SSE messages', () async { final sseController = StreamController(); final eventStream = adapter.fromSseStream(sseController.stream); - + final events = []; final subscription = eventStream.listen(events.add); - + // Add various SSE message types sseController.add(const SseMessage(id: '123')); // No data sseController.add(const SseMessage(event: 'custom')); // No data - sseController.add(const SseMessage(retry: Duration(milliseconds: 1000))); // No data + sseController.add( + const SseMessage(retry: Duration(milliseconds: 1000))); // No data sseController.add(SseMessage( data: '{"type":"TEXT_MESSAGE_START","messageId":"msg1"}', )); sseController.add(SseMessage(data: '')); // Empty data - + await sseController.close(); await subscription.cancel(); - + expect(events.length, equals(1)); expect(events[0], isA()); }); @@ -70,14 +74,14 @@ void main() { sseController.stream, skipInvalidEvents: false, ); - + final events = []; final errors = []; final subscription = eventStream.listen( events.add, onError: errors.add, ); - + // Add valid and invalid messages sseController.add(SseMessage( data: '{"type":"TEXT_MESSAGE_START","messageId":"msg1"}', @@ -88,10 +92,10 @@ void main() { sseController.add(SseMessage( data: '{"type":"TEXT_MESSAGE_END","messageId":"msg1"}', )); - + await sseController.close(); await subscription.cancel(); - + expect(events.length, equals(2)); expect(errors.length, equals(1)); }); @@ -104,10 +108,10 @@ void main() { skipInvalidEvents: true, onError: (error, stack) => collectedErrors.add(error), ); - + final events = []; final subscription = eventStream.listen(events.add); - + // Add valid and invalid messages sseController.add(SseMessage( data: '{"type":"TEXT_MESSAGE_START","messageId":"msg1"}', @@ -121,10 +125,10 @@ void main() { sseController.add(SseMessage( data: '{"type":"TEXT_MESSAGE_END","messageId":"msg1"}', )); - + await sseController.close(); await subscription.cancel(); - + expect(events.length, equals(2)); expect(collectedErrors.length, equals(2)); }); @@ -134,17 +138,19 @@ void main() { test('handles complete SSE messages', () async { final rawController = StreamController(); final eventStream = adapter.fromRawSseStream(rawController.stream); - + final events = []; final subscription = eventStream.listen(events.add); - + // Add complete SSE messages - rawController.add('data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\n\n'); - rawController.add('data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'); - + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\n\n'); + rawController.add( + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n'); + await rawController.close(); await subscription.cancel(); - + expect(events.length, equals(2)); expect(events[0], isA()); expect(events[1], isA()); @@ -153,18 +159,18 @@ void main() { test('handles partial messages across chunks', () async { final rawController = StreamController(); final eventStream = adapter.fromRawSseStream(rawController.stream); - + final events = []; final subscription = eventStream.listen(events.add); - + // Split message across chunks rawController.add('data: {"type":"TEXT_MES'); rawController.add('SAGE_START","messageI'); rawController.add('d":"msg1"}\n\n'); - + await rawController.close(); await subscription.cancel(); - + expect(events.length, equals(1)); expect(events[0], isA()); final event = events[0] as TextMessageStartEvent; @@ -174,18 +180,18 @@ void main() { test('handles multi-line data fields', () async { final rawController = StreamController(); final eventStream = adapter.fromRawSseStream(rawController.stream); - + final events = []; final subscription = eventStream.listen(events.add); - + // Multi-line data rawController.add('data: {"type":"TEXT_MESSAGE_CONTENT",\n'); rawController.add('data: "messageId":"msg1",\n'); rawController.add('data: "delta":"Hello"}\n\n'); - + await rawController.close(); await subscription.cancel(); - + expect(events.length, equals(1)); expect(events[0], isA()); final event = events[0] as TextMessageContentEvent; @@ -195,19 +201,20 @@ void main() { test('ignores non-data lines', () async { final rawController = StreamController(); final eventStream = adapter.fromRawSseStream(rawController.stream); - + final events = []; final subscription = eventStream.listen(events.add); - + rawController.add('id: 123\n'); rawController.add('event: custom\n'); rawController.add(': comment\n'); - rawController.add('data: {"type":"CUSTOM","name":"test","value":42}\n\n'); + rawController + .add('data: {"type":"CUSTOM","name":"test","value":42}\n\n'); rawController.add('retry: 1000\n'); - + await rawController.close(); await subscription.cancel(); - + expect(events.length, equals(1)); expect(events[0], isA()); }); @@ -215,21 +222,380 @@ void main() { test('processes remaining buffered data on close', () async { final rawController = StreamController(); final eventStream = adapter.fromRawSseStream(rawController.stream); - + final events = []; final subscription = eventStream.listen(events.add); - + // Add data without final newlines - rawController.add('data: {"type":"STATE_SNAPSHOT","snapshot":{"count":42}}'); - + rawController + .add('data: {"type":"STATE_SNAPSHOT","snapshot":{"count":42}}'); + await rawController.close(); await subscription.cancel(); - + expect(events.length, equals(1)); expect(events[0], isA()); final event = events[0] as StateSnapshotEvent; expect(event.snapshot['count'], equals(42)); }); + + test('handles CRLF split across chunks without double-dispatch', + () async { + // Regression for Opus2 I3: when lastWasLoneCrAtStart=true and the new + // chunk starts with '\n', that '\n' is the second half of a chunk-spanning + // CRLF pair and must NOT produce an extra empty line (which would cause a + // spurious flush of an in-progress data block). + // + // Chunk 1: "data: foo\r\r" + // - First \r terminates "data: foo" (lone-CR, sets lastWasLoneCr=true) + // - Second \r terminates "" (empty line, dispatches "foo", keeps lastWasLoneCr=true) + // Chunk 2: "\ndata: bar\n\n" + // - Leading \n is the CRLF complement of a PRIOR chunk boundary + // (skipped by the edge-case fix so it doesn't dispatch an extra event) + // - "data: bar" + "\n\n" dispatches "bar" + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\r\r', + ); + rawController.add( + '\ndata: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\n\n', + ); + + await rawController.close(); + await subscription.cancel(); + + // Must produce exactly 2 events, not 3 (the spurious empty-flush + // from the lone \n would have caused a double-dispatch before the fix). + expect(events.length, equals(2), + reason: + 'leading \\n in chunk 2 must not produce an extra dispatch'); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test( + 'lone-CR: lastWasLoneCr persists through zero-length intermediate chunk', + () async { + // Regression for II5: when a lone-CR terminator is delivered in one + // chunk and the next chunk is empty (zero-length), lastWasLoneCr must + // survive across the empty chunk so the subsequent real chunk does not + // stall waiting for a deferred \r resolution. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Chunk 1: event + lone-CR terminator pair (CR = end of data line, CR = empty line → flush) + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\r\r', + ); + // Chunk 2: zero-length — must not reset lastWasLoneCr state + rawController.add(''); + // Chunk 3: second event using lone-CR style + rawController.add( + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\r\r', + ); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(2)); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test( + 'lone-CR: three back-to-back events each delivered in their own chunk', + () async { + // Regression for I4/II5: three consecutive lone-CR-terminated events + // delivered one per chunk. Each chunk ends with \r\r (data line CR + + // empty-line CR). The lastWasLoneCr flag must persist correctly so + // each event is dispatched exactly once. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + for (final runId in ['r1', 'r2', 'r3']) { + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"$runId"}\r\r', + ); + } + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(3)); + expect((events[0] as RunStartedEvent).runId, equals('r1')); + expect((events[1] as RunStartedEvent).runId, equals('r2')); + expect((events[2] as RunStartedEvent).runId, equals('r3')); + }); + + test('mixed lone-CR + CRLF terminators in adjacent events', () async { + // Regression for I4: chunk1 uses lone-CR style, chunk2 uses CRLF. + // The transition must not double-dispatch or lose an event. + // chunk1: "data: foo\r" — lone-CR terminates the line; trailing \r + // is deferred (not yet a lone-CR producer confirmation) + // chunk2: "\r\ndata: bar\n\n" — the leading \r is interpreted as the + // continuation of the prior deferred \r, making it a lone-CR + // (empty line → flush foo), then \n is handled as a new + // terminator for the CRLF-style event. + // Actually the simpler test: lone-CR event in chunk1, CRLF event in chunk2. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Chunk 1: lone-CR event (data line + empty line via lone-CR) + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\r\r', + ); + // Chunk 2: CRLF-terminated event + rawController.add( + 'data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1"}\r\n\r\n', + ); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(2)); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test('downstream cancellation propagates to upstream subscription', + () async { + // Regression for the leaked-subscription bug noted in the #1018 + // review: pre-fix, `rawStream.listen(...)` was fire-and-forget — + // the returned stream's `controller.onCancel` did not cancel the + // upstream subscription. A consumer that stops listening early + // left the upstream draining indefinitely. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Push one complete event, then assert the upstream is alive. + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\n\n', + ); + await Future.delayed(Duration.zero); + expect(events.length, equals(1)); + expect(rawController.hasListener, isTrue); + + // Cancel the downstream subscription; upstream listener should + // be released. + await subscription.cancel(); + // A microtask hop lets the cancel propagate through the + // controller before we sample `hasListener`. + await Future.delayed(Duration.zero); + expect(rawController.hasListener, isFalse, + reason: 'fromRawSseStream must cancel its upstream subscription ' + 'when the downstream stream is cancelled'); + + await rawController.close(); + }); + + test( + 'CRLF split where second chunk is exactly "\\n" (deferral edge case)', + () async { + // Regression for Opus2 I7: when chunk 1 ends with a bare \r (deferred + // — could be the \r of a CRLF pair), and chunk 2 is exactly "\n", the + // \r+\n must be treated as a single CRLF terminator and produce exactly + // ONE empty line (one flush), not two. + // + // Without the deferral fix, chunk1's \r would emit a line AND chunk2's + // \n would emit another empty line, causing double-dispatch. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Chunk 1: data line terminated by \r (deferred — may be CRLF start) + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\r', + ); + // Chunk 2: exactly "\n" — the CRLF complement; must NOT produce a + // second empty line + rawController.add('\n'); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(1), + reason: + '\\r\\n split across chunks must produce exactly one flush'); + expect(events[0], isA()); + }); + + test( + 'two distinct JSON decode errors in one chunk both reach the consumer', + () async { + // Regression for Opus2 I1: within a single chunk, the per-frame reset + // of errorRoutedInChunk (reset before EACH empty-line flush) ensures + // that a second JSON decode error is never suppressed by the first. + // Both errors must reach the downstream consumer. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final errors = []; + final subscription = eventStream.listen( + (_) {}, + onError: errors.add, + ); + + // Single chunk with two complete SSE messages, both with invalid JSON. + rawController.add('data: not-json-1\n\ndata: not-json-2\n\n'); + + await Future.delayed(Duration.zero); + await subscription.cancel(); + await rawController.close(); + + expect(errors.length, equals(2), + reason: 'both decode errors must reach the consumer; ' + 'errorRoutedInChunk must be reset before each new frame'); + }); + + test( + 'processChunk size-cap resets dataBuffer so next valid event ' + 'is not contaminated (I1 regression)', () async { + // Regression for Opus2 I1: when a chunk-level size cap fires, + // the in-progress dataBuffer must be cleared and inDataBlock reset + // before throwing. Without the fix, chunk 1's data (already appended + // to dataBuffer via a complete `data:` line) contaminates chunk 4's + // decode: the leftover partial data triggers a spurious extra error + // when the blank-line boundary arrives in chunk 3. + // + // Sequence: + // Chunk 1: `data: \n` → appended to dataBuffer (complete line) + // Chunk 2: huge blob → processChunk cap fires, 1 error routed + // Chunk 3: `\n` → blank-line boundary (ends oversized msg) + // Chunk 4: valid complete event → must decode cleanly (0 extra errors) + // + // Without fix: chunk 3's blank-line flush sees leftover dataBuffer from + // chunk 1, tries to decode it → routes a 2nd spurious error. + // With fix: dataBuffer cleared on cap; chunk 3 flush is a no-op. + const smallCap = 60; // big enough for valid events, not for the blob + final smallAdapter = EventStreamAdapter(maxDataCodeUnits: smallCap); + final rawController = StreamController(); + final eventStream = smallAdapter.fromRawSseStream(rawController.stream); + + final events = []; + final errors = []; + final subscription = eventStream.listen( + events.add, + onError: errors.add, + ); + + // Chunk 1: complete data: line (with \n) so content reaches dataBuffer. + rawController.add('data: {"partial":true}\n'); + + // Chunk 2: oversized — exceeds smallCap, fires processChunk cap. + rawController.add('x' * (smallCap + 1)); + + // Chunk 3: blank line — boundary that "closes" the oversized message. + rawController.add('\n'); + + // Chunk 4: clean new SSE event that must decode without error. + rawController.add( + 'data: {"type":"RUN_FINISHED","threadId":"t","runId":"r"}\n\n'); + + await Future.delayed(Duration.zero); + await subscription.cancel(); + await rawController.close(); + + expect(errors.length, equals(1), + reason: 'only the oversized chunk should produce an error; ' + 'the leftover dataBuffer from chunk 1 must NOT cause a 2nd error ' + 'when chunk 3\'s blank line fires flushDataBlock'); + expect(events.length, equals(1), + reason: 'RUN_FINISHED from chunk 4 must decode cleanly'); + expect(events[0], isA()); + }); + + test( + '_scanLines: lone-CR at chunk end followed by CRLF at chunk start ' + '(mixed-terminator producer transition)', () async { + // Regression for S3: producer emits the data line with a lone-CR + // terminator and the event boundary with CRLF, split across two chunks. + // + // chunk1: "data: \r" + // → the trailing \r is deferred (could be the \r of a CRLF pair). + // chunk2: "\r\n" + // → chunk2[0] = \r (NOT \n) → deferred \r resolves as lone-CR, + // emitting line "data: ". The new \r is immediately + // deferred. + // → chunk2[1] = \n → deferred \r + \n = CRLF → produces empty + // line → event dispatch. + // + // Expected: exactly one event, with no double-dispatch from + // the chunk-boundary \r being misread as part of the \r\n pair. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\r', + ); + rawController.add('\r\n'); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(1), + reason: 'lone-CR data-line + CRLF boundary split across chunks ' + 'must produce exactly one event'); + expect(events[0], isA()); + }); + + test( + '_scanLines: CRLF data-line in chunk1, lone-CR event-boundary in ' + 'chunk2 (mixed-terminator producer transition)', () async { + // Regression for S3: producer uses CRLF for the data line and a + // lone-CR for the blank-line event boundary, split across chunks. + // + // chunk1: "data: \r\n" + // → CRLF terminates the data line; "data: " is appended + // to the data buffer. + // chunk2: "\r" + // → trailing \r deferred (could be start of CRLF). + // stream close: + // → deferred \r flushed as lone-CR → produces empty line + // → event dispatch. + // + // Expected: exactly one event, confirming that a deferred lone-CR + // left at stream close still triggers the event boundary flush. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + rawController.add( + 'data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\r\n', + ); + rawController.add('\r'); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(1), + reason: 'CRLF data-line + lone-CR boundary flushed at stream ' + 'close must produce exactly one event'); + expect(events[0], isA()); + }); }); group('filterByType', () { @@ -238,22 +604,23 @@ void main() { final filtered = EventStreamAdapter.filterByType( controller.stream, ); - + final events = []; final subscription = filtered.listen(events.add); - + controller.add(TextMessageStartEvent(messageId: 'msg1')); - controller.add(TextMessageContentEvent(messageId: 'msg1', delta: 'Hello')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'Hello')); controller.add(TextMessageStartEvent(messageId: 'msg2')); controller.add(ToolCallStartEvent( toolCallId: 'tool1', toolCallName: 'search', )); controller.add(TextMessageEndEvent(messageId: 'msg1')); - + await controller.close(); await subscription.cancel(); - + expect(events.length, equals(2)); expect(events[0].messageId, equals('msg1')); expect(events[1].messageId, equals('msg2')); @@ -263,20 +630,23 @@ void main() { group('groupRelatedEvents', () { test('groups text message events by messageId', () async { final controller = StreamController(); - final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); - + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + final groups = >[]; final subscription = grouped.listen(groups.add); - + // Complete message sequence controller.add(TextMessageStartEvent(messageId: 'msg1')); - controller.add(TextMessageContentEvent(messageId: 'msg1', delta: 'Hello')); - controller.add(TextMessageContentEvent(messageId: 'msg1', delta: ' world')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'Hello')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: ' world')); controller.add(TextMessageEndEvent(messageId: 'msg1')); - + await controller.close(); await subscription.cancel(); - + expect(groups.length, equals(1)); expect(groups[0].length, equals(4)); expect(groups[0][0], isA()); @@ -287,11 +657,12 @@ void main() { test('groups tool call events by toolCallId', () async { final controller = StreamController(); - final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); - + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + final groups = >[]; final subscription = grouped.listen(groups.add); - + // Complete tool call sequence controller.add(ToolCallStartEvent( toolCallId: 'tool1', @@ -306,10 +677,10 @@ void main() { delta: '"test"}', )); controller.add(ToolCallEndEvent(toolCallId: 'tool1')); - + await controller.close(); await subscription.cancel(); - + expect(groups.length, equals(1)); expect(groups[0].length, equals(4)); expect(groups[0][0], isA()); @@ -320,11 +691,12 @@ void main() { test('handles interleaved message groups', () async { final controller = StreamController(); - final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); - + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + final groups = >[]; final subscription = grouped.listen(groups.add); - + // Interleaved messages controller.add(TextMessageStartEvent(messageId: 'msg1')); controller.add(TextMessageStartEvent(messageId: 'msg2')); @@ -332,33 +704,36 @@ void main() { controller.add(TextMessageContentEvent(messageId: 'msg2', delta: 'B')); controller.add(TextMessageEndEvent(messageId: 'msg1')); controller.add(TextMessageEndEvent(messageId: 'msg2')); - + await controller.close(); await subscription.cancel(); - + expect(groups.length, equals(2)); // First completed group (msg1) expect(groups[0].length, equals(3)); - expect((groups[0][0] as TextMessageStartEvent).messageId, equals('msg1')); + expect( + (groups[0][0] as TextMessageStartEvent).messageId, equals('msg1')); // Second completed group (msg2) expect(groups[1].length, equals(3)); - expect((groups[1][0] as TextMessageStartEvent).messageId, equals('msg2')); + expect( + (groups[1][0] as TextMessageStartEvent).messageId, equals('msg2')); }); test('emits single events not part of groups', () async { final controller = StreamController(); - final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); - + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + final groups = >[]; final subscription = grouped.listen(groups.add); - + controller.add(RunStartedEvent(threadId: 't1', runId: 'r1')); controller.add(StateSnapshotEvent(snapshot: {'count': 0})); controller.add(CustomEvent(name: 'test', value: 42)); - + await controller.close(); await subscription.cancel(); - + expect(groups.length, equals(3)); expect(groups[0].length, equals(1)); expect(groups[0][0], isA()); @@ -370,28 +745,291 @@ void main() { test('emits incomplete groups on stream close', () async { final controller = StreamController(); - final grouped = EventStreamAdapter.groupRelatedEvents(controller.stream); - + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + final groups = >[]; final completer = Completer(); final subscription = grouped.listen( groups.add, onDone: completer.complete, ); - + // Incomplete message (no END event) controller.add(TextMessageStartEvent(messageId: 'msg1')); - controller.add(TextMessageContentEvent(messageId: 'msg1', delta: 'Hello')); - + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'Hello')); + await controller.close(); - await completer.future; // Wait for stream to complete + await completer.future; // Wait for stream to complete await subscription.cancel(); - + expect(groups.length, equals(1)); expect(groups[0].length, equals(2)); expect(groups[0][0], isA()); expect(groups[0][1], isA()); }); + + test('groups ReasoningMessage* events by messageId', () async { + // Regression for Opus1 I1: ReasoningMessage* events must be grouped + // like TextMessage* events, not fall to the default single-event branch. + final controller = StreamController(); + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add(ReasoningMessageStartEvent(messageId: 'rsn1')); + controller.add(ReasoningMessageContentEvent( + messageId: 'rsn1', + delta: 'Thinking...', + )); + controller.add(ReasoningMessageEndEvent(messageId: 'rsn1')); + + await controller.close(); + await subscription.cancel(); + + expect(groups.length, equals(1)); + expect(groups[0].length, equals(3)); + expect(groups[0][0], isA()); + expect(groups[0][1], isA()); + expect(groups[0][2], isA()); + }); + + test('routes chunk into open group when Start/End cycle is active', + () async { + // Regression: *Chunk events must be routed into an active group rather + // than emitted as standalone single-element groups via the default branch. + final controller = StreamController(); + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + // TextMessageChunkEvent arriving while a Start/End cycle is open + controller.add(TextMessageStartEvent(messageId: 'msg1')); + controller + .add(TextMessageChunkEvent(messageId: 'msg1', delta: 'chunk')); + controller.add(TextMessageEndEvent(messageId: 'msg1')); + + await controller.close(); + await subscription.cancel(); + + // All three events must land in a single group, not 2 groups + expect(groups.length, equals(1)); + expect(groups[0].length, equals(3)); + expect(groups[0][1], isA()); + }); + + test('emits standalone chunk when no matching open group exists', + () async { + // A *Chunk with no active group (e.g. server sends only chunks, no + // Start/End) must still be emitted, just as a single-element group. + final controller = StreamController(); + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller + .add(TextMessageChunkEvent(messageId: 'msg1', delta: 'standalone')); + + await controller.close(); + await subscription.cancel(); + + expect(groups.length, equals(1)); + expect(groups[0].length, equals(1)); + expect(groups[0][0], isA()); + }); + + // Regression for I-J: Tool and Reasoning chunk families were not covered. + test('routes ToolCallChunkEvent into open tool group', () async { + final controller = StreamController(); + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add(ToolCallStartEvent( + toolCallId: 'tc1', + toolCallName: 'search', + parentMessageId: 'msg1', + )); + controller.add(ToolCallChunkEvent(toolCallId: 'tc1', delta: '{"q"')); + controller.add(ToolCallEndEvent(toolCallId: 'tc1')); + + await controller.close(); + await subscription.cancel(); + + // All three must land in a single group, not 2 groups + expect(groups.length, equals(1)); + expect(groups[0].length, equals(3)); + expect(groups[0][1], isA()); + }); + + test('emits standalone ToolCallChunkEvent when no open group exists', + () async { + final controller = StreamController(); + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add(ToolCallChunkEvent(toolCallId: 'tc1', delta: '{}')); + + await controller.close(); + await subscription.cancel(); + + expect(groups.length, equals(1)); + expect(groups[0].length, equals(1)); + expect(groups[0][0], isA()); + }); + + test('routes ReasoningMessageChunkEvent into open reasoning group', + () async { + final controller = StreamController(); + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add(ReasoningMessageStartEvent(messageId: 'rm1')); + controller.add( + ReasoningMessageChunkEvent(messageId: 'rm1', delta: 'thinking')); + controller.add(ReasoningMessageEndEvent(messageId: 'rm1')); + + await controller.close(); + await subscription.cancel(); + + // All three must land in a single group, not 2 groups + expect(groups.length, equals(1)); + expect(groups[0].length, equals(3)); + expect(groups[0][1], isA()); + }); + + test( + 'emits standalone ReasoningMessageChunkEvent when no open group exists', + () async { + final controller = StreamController(); + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + controller.add( + ReasoningMessageChunkEvent(messageId: 'rm1', delta: 'standalone')); + + await controller.close(); + await subscription.cancel(); + + expect(groups.length, equals(1)); + expect(groups[0].length, equals(1)); + expect(groups[0][0], isA()); + }); + + test('orphan *_End events are emitted as standalone groups (I3 fix)', + () async { + // Regression for Opus2 I3: a *_End event with no matching *_Start + // (e.g. after a reconnect that missed the opening event) was silently + // dropped. It must now be emitted as a standalone single-element group, + // consistent with how orphan *_Chunk events are handled. + final controller = StreamController(); + final grouped = + EventStreamAdapter.groupRelatedEvents(controller.stream); + + final groups = >[]; + final subscription = grouped.listen(groups.add); + + // Orphan End events — no preceding Start + controller.add(TextMessageEndEvent(messageId: 'no-start-text')); + controller.add(ToolCallEndEvent(toolCallId: 'no-start-tool')); + controller + .add(ReasoningMessageEndEvent(messageId: 'no-start-reasoning')); + + await controller.close(); + await subscription.cancel(); + + expect(groups.length, equals(3), + reason: 'each orphan *_End must emit as a standalone group'); + expect(groups[0].length, equals(1)); + expect(groups[0][0], isA()); + expect(groups[1].length, equals(1)); + expect(groups[1][0], isA()); + expect(groups[2].length, equals(1)); + expect(groups[2][0], isA()); + }); + test( + 'duplicate *_Start discards prior accumulated events (last-Start-wins)', + () async { + // Regression for Opus2 S4: the dartdoc at groupRelatedEvents promises + // that a duplicate *_Start discards the prior open group's events + // silently and starts fresh. This contract previously lacked a + // regression guard. + final controller = StreamController(); + final groups = >[]; + final subscription = + EventStreamAdapter.groupRelatedEvents(controller.stream) + .listen(groups.add); + + controller.add(TextMessageStartEvent(messageId: 'm1')); + controller + .add(TextMessageContentEvent(messageId: 'm1', delta: 'first')); + // Duplicate Start with same id — silently discards the prior group + // (no emission) and starts fresh. + controller.add(TextMessageStartEvent(messageId: 'm1')); + controller + .add(TextMessageContentEvent(messageId: 'm1', delta: 'second')); + controller.add(TextMessageEndEvent(messageId: 'm1')); + + await controller.close(); + await subscription.cancel(); + + // Only the second group is emitted (completed by its End event). + // The prior group's events are discarded without being emitted. + expect(groups, hasLength(1), + reason: 'only the second (post-duplicate-Start) group is emitted'); + expect( + groups[0].whereType().single.delta, + 'second', + ); + }); + + test('maxOpenGroups cap evicts oldest open group when exceeded', + () async { + // Regression for Opus2 S4: the maxOpenGroups cap eviction path + // previously lacked a regression guard. + final controller = StreamController(); + final groups = >[]; + final subscription = EventStreamAdapter.groupRelatedEvents( + controller.stream, + maxOpenGroups: 2, + ).listen(groups.add); + + controller.add(TextMessageStartEvent(messageId: 'm1')); + controller.add(TextMessageStartEvent(messageId: 'm2')); + // Third Start exceeds cap — evicts m1 (oldest insertion-order entry). + controller.add(TextMessageStartEvent(messageId: 'm3')); + + await controller.close(); + await subscription.cancel(); + + // m1 is evicted immediately when m3 arrives; m2 and m3 are flushed + // on stream close. Total: 3 groups emitted. + expect(groups, hasLength(3), + reason: 'evicted m1 + stream-close flush of m2 and m3'); + // The evicted group is the first emitted. + expect( + groups[0].whereType().single.messageId, + 'm1', + ); + }); }); group('accumulateTextMessages', () { @@ -400,20 +1038,22 @@ void main() { final accumulated = EventStreamAdapter.accumulateTextMessages( controller.stream, ); - + final messages = []; final subscription = accumulated.listen(messages.add); - + // Complete message controller.add(TextMessageStartEvent(messageId: 'msg1')); - controller.add(TextMessageContentEvent(messageId: 'msg1', delta: 'Hello')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'Hello')); controller.add(TextMessageContentEvent(messageId: 'msg1', delta: ', ')); - controller.add(TextMessageContentEvent(messageId: 'msg1', delta: 'world!')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'world!')); controller.add(TextMessageEndEvent(messageId: 'msg1')); - + await controller.close(); await subscription.cancel(); - + expect(messages.length, equals(1)); expect(messages[0], equals('Hello, world!')); }); @@ -423,22 +1063,25 @@ void main() { final accumulated = EventStreamAdapter.accumulateTextMessages( controller.stream, ); - + final messages = []; final subscription = accumulated.listen(messages.add); - + // Interleaved messages controller.add(TextMessageStartEvent(messageId: 'msg1')); controller.add(TextMessageStartEvent(messageId: 'msg2')); - controller.add(TextMessageContentEvent(messageId: 'msg1', delta: 'First')); - controller.add(TextMessageContentEvent(messageId: 'msg2', delta: 'Second')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'First')); + controller + .add(TextMessageContentEvent(messageId: 'msg2', delta: 'Second')); controller.add(TextMessageEndEvent(messageId: 'msg1')); - controller.add(TextMessageContentEvent(messageId: 'msg2', delta: ' message')); + controller + .add(TextMessageContentEvent(messageId: 'msg2', delta: ' message')); controller.add(TextMessageEndEvent(messageId: 'msg2')); - + await controller.close(); await subscription.cancel(); - + expect(messages.length, equals(2)); expect(messages[0], equals('First')); expect(messages[1], equals('Second message')); @@ -449,10 +1092,10 @@ void main() { final accumulated = EventStreamAdapter.accumulateTextMessages( controller.stream, ); - + final messages = []; final subscription = accumulated.listen(messages.add); - + // Chunk events (complete content in single event) controller.add(TextMessageChunkEvent( messageId: 'msg1', @@ -462,10 +1105,10 @@ void main() { messageId: 'msg2', delta: 'Complete message 2', )); - + await controller.close(); await subscription.cancel(); - + expect(messages.length, equals(2)); expect(messages[0], equals('Complete message 1')); expect(messages[1], equals('Complete message 2')); @@ -476,46 +1119,217 @@ void main() { final accumulated = EventStreamAdapter.accumulateTextMessages( controller.stream, ); - + final messages = []; final subscription = accumulated.listen(messages.add); - + controller.add(RunStartedEvent(threadId: 't1', runId: 'r1')); controller.add(TextMessageStartEvent(messageId: 'msg1')); controller.add(ToolCallStartEvent( toolCallId: 'tool1', toolCallName: 'search', )); - controller.add(TextMessageContentEvent(messageId: 'msg1', delta: 'Test')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'Test')); controller.add(StateSnapshotEvent(snapshot: {})); controller.add(TextMessageEndEvent(messageId: 'msg1')); - + await controller.close(); await subscription.cancel(); - + expect(messages.length, equals(1)); expect(messages[0], equals('Test')); }); - test('handles empty content', () async { + test('Start→End with no content emits nothing (S11 fix)', () async { + // Regression for Opus2 S11: empty Start→End cycles previously emitted + // an empty string. Now they are skipped — consistent with the onDone + // flush which already drops empty buffers. final controller = StreamController(); final accumulated = EventStreamAdapter.accumulateTextMessages( controller.stream, ); - + final messages = []; final subscription = accumulated.listen(messages.add); - - // Message with no content events + controller.add(TextMessageStartEvent(messageId: 'msg1')); controller.add(TextMessageEndEvent(messageId: 'msg1')); - + await controller.close(); await subscription.cancel(); - + + expect(messages.length, equals(0), + reason: 'empty Start→End cycle must not emit an empty string'); + }); + + test('flushes partial content on stream close without TextMessageEnd', + () async { + // Regression: When the upstream closes abnormally (no TextMessageEnd), + // accumulated content must be flushed rather than silently discarded. + // Mirrors groupRelatedEvents which emits incomplete groups on close. + final controller = StreamController(); + final accumulated = EventStreamAdapter.accumulateTextMessages( + controller.stream, + ); + + final messages = []; + final completer = Completer(); + final subscription = accumulated.listen( + messages.add, + onDone: completer.complete, + ); + + controller.add(TextMessageStartEvent(messageId: 'msg1')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'partial')); + // No TextMessageEndEvent — simulates abnormal stream close + await controller.close(); + await completer.future; + await subscription.cancel(); + expect(messages.length, equals(1)); - expect(messages[0], equals('')); + expect(messages[0], equals('partial')); + }); + + test( + 'accumulateTextMessages duplicate Start drops prior buffered content', + () async { + // Regression for Opus2 S4: a duplicate TextMessageStart (same + // messageId while a buffer is open) should discard the prior buffer + // and start fresh — matching the groupRelatedEvents last-Start-wins + // policy at the content-accumulation layer. + final controller = StreamController(); + final accumulated = + EventStreamAdapter.accumulateTextMessages(controller.stream); + + final messages = []; + final completer = Completer(); + final subscription = accumulated.listen( + messages.add, + onDone: completer.complete, + ); + + controller.add(TextMessageStartEvent(messageId: 'msg1')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'first')); + // Duplicate Start — prior buffered content should be dropped. + controller.add(TextMessageStartEvent(messageId: 'msg1')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'second')); + controller.add(TextMessageEndEvent(messageId: 'msg1')); + + await controller.close(); + await completer.future; + await subscription.cancel(); + + // Only the second message body should be emitted. + expect(messages, hasLength(1)); + expect(messages[0], equals('second')); }); + + test( + 'accumulateTextMessages buffers chunk-before-Start and folds it ' + 'into the Start+Content+End sequence without duplicate emission', + () async { + // Verifies the fix for the pre-Start chunk hazard: a Chunk that + // arrives before its Start is now buffered (not emitted immediately), + // then drained into the active buffer when Start arrives. The final + // emission is a single string containing both the pre-Start chunk + // and any subsequent Content, preventing the duplicate-emission bug + // that the original TODO at stream_adapter.dart:1026-1035 described. + final controller = StreamController(); + final accumulated = + EventStreamAdapter.accumulateTextMessages(controller.stream); + + final messages = []; + final completer = Completer(); + final subscription = accumulated.listen( + messages.add, + onDone: completer.complete, + ); + + // Chunk arrives before Start — must be buffered, not emitted yet. + controller.add( + TextMessageChunkEvent(messageId: 'msg1', delta: 'pre-start')); + controller.add(TextMessageStartEvent(messageId: 'msg1')); + controller + .add(TextMessageContentEvent(messageId: 'msg1', delta: 'body')); + controller.add(TextMessageEndEvent(messageId: 'msg1')); + + await controller.close(); + await completer.future; + await subscription.cancel(); + + // Fixed behavior: pre-Start chunk is drained into the active buffer + // when Start arrives, so a single emission contains the full text. + expect(messages, hasLength(1)); + expect(messages[0], equals('pre-startbody')); + }); + }); + }); + + _reentrancyContractTests(); +} + +// I-5 re-entrancy contract tests live at the top level so they can use +// private imports. These pin the StateError vs DecodingError distinction. +// fromRawSseStream uses sync: true internally; these tests verify externally +// observable error-type routing and per-invocation isolation. +void _reentrancyContractTests() { + group('fromRawSseStream error-type contract (I-5)', () { + test( + 'wire decode errors surface as DecodingError, not StateError (I-5)', + () async { + // I-5: Pins the distinction — StateError is the re-entrancy + // programmer-error guard; ordinary wire errors become DecodingError. + // If this expectation ever fails, the two error types have been merged + // and the re-entrancy guard is no longer diagnosable. + final adapter = EventStreamAdapter(); + final errors = []; + final sub = adapter + .fromRawSseStream( + Stream.fromIterable(['data: invalid json\n\n']), + ) + .listen( + (_) {}, + onError: errors.add, + cancelOnError: false, + ); + + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(errors, hasLength(1)); + expect(errors[0], isA(), + reason: 'wire error must be DecodingError, not StateError'); + expect(errors[0], isNot(isA()), + reason: 'StateError is reserved for programmer-error re-entrancy'); + }); + + test( + 'fromRawSseStream per-invocation isolation: sequential calls are ' + 'independent (I-5)', () async { + // I-5: Per-invocation locals in fromRawSseStream guarantee that two + // sequential calls on the same adapter cannot share parser state + // (buffer, dataBuffer, inDataBlock, lastWasLoneCr). + final adapter = EventStreamAdapter(); + + final events1 = await adapter.fromRawSseStream( + Stream.fromIterable( + ['data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1"}\n\n']), + ).toList(); + + final events2 = await adapter.fromRawSseStream( + Stream.fromIterable([ + 'data: {"type":"RUN_FINISHED","threadId":"t2","runId":"r2"}\n\n', + ]), + ).toList(); + + expect(events1, hasLength(1)); + expect(events1.single, isA()); + expect(events2, hasLength(1)); + expect(events2.single, isA()); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/events/event_test.dart b/sdks/community/dart/test/events/event_test.dart index 49f397c576..087e0671a4 100644 --- a/sdks/community/dart/test/events/event_test.dart +++ b/sdks/community/dart/test/events/event_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:test/test.dart'; import 'package:ag_ui/ag_ui.dart'; @@ -23,25 +25,55 @@ void main() { expect(decoded.timestamp, event.timestamp); }); - test('TextMessageContentEvent validation', () { - // Valid event with non-empty delta + test('TextMessageContentEvent accepts empty delta (canonical parity)', + () { + // Canonical TS/Python schemas allow empty `delta` + // (`TextMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str` with no `min_length`). Servers may + // legitimately emit a deliberate empty content chunk. final validEvent = TextMessageContentEvent( messageId: 'msg_001', delta: 'Hello world', ); expect(validEvent.delta, 'Hello world'); - // Invalid event with empty delta should throw - final invalidJson = { + final empty = TextMessageContentEvent.fromJson({ 'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'msg_001', 'delta': '', - }; + }); + expect(empty.delta, isEmpty); + }); - expect( - () => TextMessageContentEvent.fromJson(invalidJson), - throwsA(isA()), - ); + test('TextMessage* events accept snake_case (Python server)', () { + final start = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'message_id': 'msg_001', + 'role': 'assistant', + }); + expect(start.messageId, 'msg_001'); + + final content = TextMessageContentEvent.fromJson({ + 'type': 'TEXT_MESSAGE_CONTENT', + 'message_id': 'msg_001', + 'delta': 'hello', + }); + expect(content.messageId, 'msg_001'); + expect(content.delta, 'hello'); + + final end = TextMessageEndEvent.fromJson({ + 'type': 'TEXT_MESSAGE_END', + 'message_id': 'msg_001', + }); + expect(end.messageId, 'msg_001'); + + final chunk = TextMessageChunkEvent.fromJson({ + 'type': 'TEXT_MESSAGE_CHUNK', + 'message_id': 'msg_001', + 'delta': 'partial', + }); + expect(chunk.messageId, 'msg_001'); + expect(chunk.delta, 'partial'); }); test('TextMessageChunkEvent optional fields', () { @@ -63,6 +95,101 @@ void main() { expect(minimalJson.containsKey('role'), false); expect(minimalJson.containsKey('delta'), false); }); + + test('TextMessageRole.fromString throws on unknown values', () { + // Aligned with `ReasoningMessageRole.fromString` — unknown wire + // values throw at the enum so direct callers see a visible + // failure mode. Wire decoding still succeeds via the factory's + // absorb (see the `falls back to assistant` test below). + expect( + () => TextMessageRole.fromString('bogus'), + throwsA(isA()), + ); + }); + + test( + 'TextMessageStartEvent falls back to assistant for an unknown ' + 'role (forward-compat, no stream tear-down)', () { + final decoded = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_001', + 'role': 'bogus', + }); + expect(decoded.role, TextMessageRole.assistant); + expect(decoded.messageId, 'msg_001'); + }); + + test( + 'TextMessageChunkEvent falls back to null for an unknown role ' + '(forward-compat: nullable field, not required like TextMessageStartEvent)', + () { + final decoded = TextMessageChunkEvent.fromJson({ + 'type': 'TEXT_MESSAGE_CHUNK', + 'messageId': 'msg_001', + 'role': 'bogus', + 'delta': 'partial', + }); + // role is nullable/optional on TextMessageChunkEvent — an unknown wire + // value should produce null so callers can distinguish "absent" from + // "unrecognized." Contrast: TextMessageStartEvent has a required role, + // so the assistant fallback is appropriate there. + expect(decoded.role, isNull); + expect(decoded.messageId, 'msg_001'); + expect(decoded.delta, 'partial'); + }); + + test('TextMessageStartEvent preserves name across round-trip', () { + // Regression guard for #1018: pre-PR `name` was silently dropped + // on decode. Now decode/re-encode preserves the field, and + // omitting it round-trips as absent (no `'name': null`). + final withName = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_001', + 'role': 'assistant', + 'name': 'tool_response', + }); + expect(withName.name, 'tool_response'); + expect(withName.toJson()['name'], 'tool_response'); + + final withoutName = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_002', + 'role': 'assistant', + }); + expect(withoutName.name, isNull); + expect(withoutName.toJson().containsKey('name'), false); + }); + + test('TextMessageChunkEvent preserves name across round-trip', () { + // Same parity fix as TextMessageStartEvent. `name` on chunk is + // optional; presence/absence must round-trip cleanly. + final withName = TextMessageChunkEvent.fromJson({ + 'type': 'TEXT_MESSAGE_CHUNK', + 'messageId': 'msg_001', + 'name': 'tool_response', + 'delta': 'hello', + }); + expect(withName.name, 'tool_response'); + expect(withName.toJson()['name'], 'tool_response'); + + final withoutName = TextMessageChunkEvent.fromJson({ + 'type': 'TEXT_MESSAGE_CHUNK', + 'messageId': 'msg_002', + 'delta': 'hello', + }); + expect(withoutName.name, isNull); + expect(withoutName.toJson().containsKey('name'), false); + }); + + test('TextMessageStartEvent.copyWith(name: null) clears name', () { + // Sentinel-pattern verification — `name` uses `_unsetCopyWith`. + final event = TextMessageStartEvent( + messageId: 'msg_001', + name: 'foo', + ); + expect(event.copyWith(name: null).name, isNull); + expect(event.copyWith().name, 'foo'); + }); }); group('ToolCallEvents', () { @@ -85,19 +212,140 @@ void main() { expect(decoded.parentMessageId, event.parentMessageId); }); + test('ToolCall* events accept snake_case (Python server)', () { + final start = ToolCallStartEvent.fromJson({ + 'type': 'TOOL_CALL_START', + 'tool_call_id': 'call_001', + 'tool_call_name': 'get_weather', + 'parent_message_id': 'msg_001', + }); + expect(start.toolCallId, 'call_001'); + expect(start.toolCallName, 'get_weather'); + expect(start.parentMessageId, 'msg_001'); + + final args = ToolCallArgsEvent.fromJson({ + 'type': 'TOOL_CALL_ARGS', + 'tool_call_id': 'call_001', + 'delta': '{"q":"x"}', + }); + expect(args.toolCallId, 'call_001'); + + final end = ToolCallEndEvent.fromJson({ + 'type': 'TOOL_CALL_END', + 'tool_call_id': 'call_001', + }); + expect(end.toolCallId, 'call_001'); + + final chunk = ToolCallChunkEvent.fromJson({ + 'type': 'TOOL_CALL_CHUNK', + 'tool_call_id': 'call_001', + 'tool_call_name': 'get_weather', + 'parent_message_id': 'msg_001', + 'delta': '{', + }); + expect(chunk.toolCallId, 'call_001'); + expect(chunk.toolCallName, 'get_weather'); + expect(chunk.parentMessageId, 'msg_001'); + + final result = ToolCallResultEvent.fromJson({ + 'type': 'TOOL_CALL_RESULT', + 'message_id': 'msg_001', + 'tool_call_id': 'call_001', + 'content': '72F sunny', + 'role': 'tool', + }); + expect(result.messageId, 'msg_001'); + expect(result.toolCallId, 'call_001'); + }); + test('ToolCallResultEvent role field', () { final event = ToolCallResultEvent( messageId: 'msg_001', toolCallId: 'call_001', content: 'Weather: Sunny, 72°F', - role: 'tool', + role: ToolCallResultRole.tool, ); final json = event.toJson(); expect(json['role'], 'tool'); final decoded = ToolCallResultEvent.fromJson(json); - expect(decoded.role, 'tool'); + expect(decoded.role, ToolCallResultRole.tool); + }); + + test('ToolCallResultEvent absorbs unknown wire role', () { + // Forward-compat: an unknown role on the wire falls back to + // `tool` so the stream stays alive. Mirrors `TextMessageRole` / + // `ReasoningMessageRole` semantics — see + // `dart-enum-parsing-safety.md`. + final decoded = ToolCallResultEvent.fromJson({ + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'msg_001', + 'toolCallId': 'call_001', + 'content': 'ok', + 'role': 'developer', + }); + expect(decoded.role, ToolCallResultRole.tool); + }); + + test('ToolCallResultEvent.copyWith(role: null) clears the role', () { + final event = ToolCallResultEvent( + messageId: 'msg_001', + toolCallId: 'call_001', + content: 'ok', + role: ToolCallResultRole.tool, + ); + expect(event.copyWith(role: null).role, isNull); + expect(event.copyWith().role, ToolCallResultRole.tool); + }); + + test('ToolCallStartEvent.copyWith(parentMessageId: null) clears it', () { + // Sentinel-pattern verification for `parentMessageId`. + final event = ToolCallStartEvent( + toolCallId: 'call_001', + toolCallName: 'get_weather', + parentMessageId: 'msg_001', + ); + expect(event.copyWith(parentMessageId: null).parentMessageId, isNull); + expect(event.copyWith().parentMessageId, 'msg_001'); + }); + + test('ToolCallArgsEvent accepts empty delta (canonical parity)', () { + // Canonical TS/Python schemas allow empty `delta` + // (`ToolCallArgsEventSchema.delta: z.string()` / pydantic + // `delta: str`). Direct factory and decoder pipeline both + // accept it. + final ev = ToolCallArgsEvent.fromJson({ + 'type': 'TOOL_CALL_ARGS', + 'toolCallId': 'call_001', + 'delta': '', + }); + expect(ev.delta, isEmpty); + }); + + test('ToolCallChunkEvent allows all-optional payload', () { + // Pins the deliberate `case ToolCallChunkEvent(): break;` in + // `EventDecoder.validate` (decoder.dart). An entirely empty chunk + // is a valid wire shape; it round-trips and survives the decoder + // boundary. Mirrors the equivalent assertion for + // `ReasoningMessageChunkEvent`. + final empty = ToolCallChunkEvent(); + final emptyJson = empty.toJson(); + expect(emptyJson['type'], 'TOOL_CALL_CHUNK'); + expect(emptyJson.containsKey('toolCallId'), false); + expect(emptyJson.containsKey('toolCallName'), false); + expect(emptyJson.containsKey('parentMessageId'), false); + expect(emptyJson.containsKey('delta'), false); + + final decoded = ToolCallChunkEvent.fromJson(emptyJson); + expect(decoded.toolCallId, isNull); + expect(decoded.toolCallName, isNull); + expect(decoded.parentMessageId, isNull); + expect(decoded.delta, isNull); + + const decoder = EventDecoder(); + final viaDecoder = decoder.decodeJson({'type': 'TOOL_CALL_CHUNK'}); + expect(viaDecoder, isA()); }); }); @@ -162,6 +410,160 @@ void main() { expect(decoded.messages[1], isA()); expect(decoded.messages[2], isA()); }); + + test('MessagesSnapshotEvent round-trips activity and reasoning messages', + () { + final messages = [ + UserMessage(id: 'u1', content: 'Index this directory.'), + ActivityMessage( + id: 'act1', + activityType: 'task.run', + activityContent: const {'progress': 0.0, 'items': []}, + ), + ReasoningMessage( + id: 'rsn1', + content: 'Considering file types', + encryptedValue: 'cGF5bG9hZA==', + ), + ]; + + final event = MessagesSnapshotEvent(messages: messages); + final json = event.toJson(); + + final decoded = MessagesSnapshotEvent.fromJson(json); + expect(decoded.messages.length, 3); + expect(decoded.messages[1], isA()); + expect(decoded.messages[2], isA()); + + final activity = decoded.messages[1] as ActivityMessage; + expect(activity.activityType, 'task.run'); + expect(activity.activityContent['progress'], 0.0); + + final reasoning = decoded.messages[2] as ReasoningMessage; + expect(reasoning.content, 'Considering file types'); + expect(reasoning.encryptedValue, 'cGF5bG9hZA=='); + }); + }); + + test( + 'MessagesSnapshotEvent.fromJson scrubs rawEvent when any message ' + 'carries cipher data (S1 regression)', () { + // Parallel to RunStartedEvent C1 regression. Verifies that the auto-scrub + // in fromJson fires when the wire JSON contains both a rawEvent key AND a + // cipher-bearing inner message. + final wireJson = { + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [ + {'id': 'm1', 'role': 'user', 'content': 'hi'}, + { + 'id': 'm2', + 'role': 'reasoning', + 'content': 'thinking', + 'encryptedValue': 'c2VjcmV0', + }, + ], + 'rawEvent': {'original': 'wire-map'}, + }; + final event = MessagesSnapshotEvent.fromJson(wireJson); + expect( + event.rawEvent, + isNull, + reason: + 'rawEvent must be scrubbed when any message carries cipher data', + ); + expect(event.messages.length, 2); + }); + + test( + 'MessagesSnapshotEvent.fromJson scrubs rawEvent when ActivityMessage ' + 'carries wire-level encryptedValue (I1 regression)', () { + // I1: ActivityMessage.fromJson silently strips encryptedValue from the + // structured field, so the structured-field hasCipher predicate alone + // returns false for an ActivityMessage with a wire-level cipher. The + // fix extends the predicate to also check the raw wire messages list. + final wireJson = { + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [ + { + 'id': 'a1', + 'role': 'activity', + 'activityType': 'task.run', + 'content': {}, + 'encryptedValue': 'should-not-leak', + }, + ], + 'rawEvent': {'_passthrough': 'arbitrary'}, + }; + final event = MessagesSnapshotEvent.fromJson(wireJson); + expect( + event.rawEvent, + isNull, + reason: + 'rawEvent must be scrubbed when ActivityMessage carries ' + 'wire-level encryptedValue', + ); + final emitted = event.toJson(); + expect( + jsonEncode(emitted), + isNot(contains('should-not-leak')), + reason: 'cipher must not leak through rawEvent passthrough', + ); + }); + + test( + 'MessagesSnapshotEvent.fromJson preserves rawEvent when no cipher ' + 'data is present (S1 regression)', () { + final wireJson = { + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [ + {'id': 'm1', 'role': 'user', 'content': 'hi'}, + ], + 'rawEvent': {'seq': 1}, + }; + final event = MessagesSnapshotEvent.fromJson(wireJson); + expect( + event.rawEvent, + {'seq': 1}, + reason: 'rawEvent must be preserved when no cipher data is present', + ); + }); + + test( + 'MessagesSnapshotEvent.copyWith forces rawEvent null when messages ' + 'gain cipher data (I-3, release-mode safe)', () { + // I-3: the assert in copyWith fires only in debug mode. This test + // verifies the actual force-to-null branch, not the assert, so it + // catches a regression even in release builds. + final base = MessagesSnapshotEvent( + messages: [UserMessage(id: '1', content: 'hi')], + rawEvent: {'preserved': true}, + ); + expect(base.rawEvent, isNotNull); + + MessagesSnapshotEvent updated; + try { + updated = base.copyWith( + messages: [ + ReasoningMessage(id: '2', content: 'r', encryptedValue: 'cipher'), + ], + rawEvent: {'attacker': 'leak'}, + ); + } on AssertionError { + // Debug mode: assert fires before the force-to-null branch. + // Fall through to construct via copyWith without rawEvent arg so + // the branch itself is tested. + updated = base.copyWith( + messages: [ + ReasoningMessage(id: '2', content: 'r', encryptedValue: 'cipher'), + ], + ); + } + expect( + updated.rawEvent, + isNull, + reason: + 'cipher-scrub must apply in all build modes, not just debug/test', + ); }); group('LifecycleEvents', () { @@ -190,7 +592,10 @@ void main() { }); test('RunFinishedEvent with result', () { - final result = {'status': 'success', 'data': [1, 2, 3]}; + final result = { + 'status': 'success', + 'data': [1, 2, 3] + }; final event = RunFinishedEvent( threadId: 'thread_001', runId: 'run_001', @@ -204,6 +609,67 @@ void main() { expect(decoded.result, result); }); + test('RunFinishedEvent.copyWith(result: null) clears the result', () { + // The sentinel pattern lets a caller intentionally clear `result`, + // matching the factory contract (which already accepts an absent + // / null `result`). + final original = RunFinishedEvent( + threadId: 't', + runId: 'r', + result: {'status': 'success'}, + ); + final keep = original.copyWith(); + expect(keep.result, equals({'status': 'success'})); + + final cleared = original.copyWith(result: null); + expect(cleared.result, isNull); + expect(cleared.threadId, equals('t')); + expect(cleared.runId, equals('r')); + }); + + test( + 'RunFinishedEvent absent result key decodes identically to explicit null', + () { + final absentJson = { + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r' + }; + final nullJson = { + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r', + 'result': null + }; + expect(RunFinishedEvent.fromJson(absentJson).result, isNull); + expect(RunFinishedEvent.fromJson(nullJson).result, isNull); + expect( + RunFinishedEvent.fromJson(absentJson) + .toJson() + .containsKey('result'), + isFalse); + expect( + RunFinishedEvent.fromJson(nullJson).toJson().containsKey('result'), + isFalse); + }); + + test('RunFinishedEvent round-trip with null result drops the key', () { + // Pins the contract that null result is NOT emitted on the wire, and + // that a null-result event survives a toJson → fromJson round-trip. + final original = RunFinishedEvent( + threadId: 't1', + runId: 'r1', + result: null, + ); + final encoded = original.toJson(); + expect(encoded.containsKey('result'), isFalse, + reason: 'null result must not appear on wire'); + final decoded = RunFinishedEvent.fromJson(encoded); + expect(decoded.result, isNull); + expect(decoded.threadId, original.threadId); + expect(decoded.runId, original.runId); + }); + test('RunErrorEvent with error code', () { final event = RunErrorEvent( message: 'Something went wrong', @@ -238,270 +704,481 @@ void main() { final stepEnd = StepFinishedEvent.fromJson(stepEndCamel); expect(stepEnd.stepName, 'processing'); }); - }); - - group('Event Factory', () { - test('should create correct event type based on type field', () { - final eventJsons = [ - {'type': 'TEXT_MESSAGE_START', 'messageId': 'msg_001'}, - {'type': 'TOOL_CALL_START', 'toolCallId': 'call_001', 'toolCallName': 'test'}, - {'type': 'STATE_SNAPSHOT', 'snapshot': {}}, - {'type': 'RUN_STARTED', 'threadId': 'thread_001', 'runId': 'run_001'}, - {'type': 'THINKING_START'}, - {'type': 'CUSTOM', 'name': 'my_event', 'value': 'data'}, - ]; - final events = eventJsons.map((json) => BaseEvent.fromJson(json)).toList(); + test('RunStartedEvent preserves parentRunId and input across round-trip', + () { + // Regression guard for #1018: pre-PR `parentRunId` and `input` + // were silently dropped on decode. Both fields now round-trip, + // including via the camelCase and snake_case wire spellings for + // `parentRunId`. `input` itself has no snake_case variant for the + // event-level key (single-word). + final inputJson = { + 'threadId': 'tid', + 'runId': 'rid', + 'messages': >[], + 'tools': >[], + 'context': >[], + }; + final camelJson = { + 'type': 'RUN_STARTED', + 'threadId': 'tid', + 'runId': 'rid', + 'parentRunId': 'parent_rid', + 'input': inputJson, + }; + final fromCamel = RunStartedEvent.fromJson(camelJson); + expect(fromCamel.parentRunId, 'parent_rid'); + expect(fromCamel.input, isNotNull); + expect(fromCamel.input!.threadId, 'tid'); + expect(fromCamel.input!.runId, 'rid'); + + final reEmitted = fromCamel.toJson(); + expect(reEmitted['parentRunId'], 'parent_rid'); + expect(reEmitted['input'], isA>()); + expect(reEmitted['input']['threadId'], 'tid'); + + // snake_case parity for parentRunId + final snakeJson = { + 'type': 'RUN_STARTED', + 'thread_id': 'tid2', + 'run_id': 'rid2', + 'parent_run_id': 'parent_rid2', + }; + final fromSnake = RunStartedEvent.fromJson(snakeJson); + expect(fromSnake.parentRunId, 'parent_rid2'); + expect(fromSnake.input, isNull); - expect(events[0], isA()); - expect(events[1], isA()); - expect(events[2], isA()); - expect(events[3], isA()); - expect(events[4], isA()); - expect(events[5], isA()); + // omitted parent / input → both null and omitted from toJson + final minimal = RunStartedEvent.fromJson({ + 'type': 'RUN_STARTED', + 'threadId': 'tid3', + 'runId': 'rid3', + }); + expect(minimal.parentRunId, isNull); + expect(minimal.input, isNull); + expect(minimal.toJson().containsKey('parentRunId'), false); + expect(minimal.toJson().containsKey('input'), false); }); - test('should throw on invalid event type', () { - final json = { - 'type': 'INVALID_EVENT_TYPE', - 'data': 'some data', + test( + 'RunStartedEvent.input.parentRunId round-trips ' + '(camelCase and snake_case)', () { + // Parity follow-up: `RunStartedEvent.parentRunId` already + // round-trips at the event level; this pins the embedded + // `RunAgentInput.parentRunId` field, which canonical TS/Python + // schemas also expose (`RunAgentInputSchema.parentRunId` / + // `RunAgentInput.parent_run_id`). Pre-fix, the embedded field + // was silently dropped at decode even when the event-level one + // survived. + final camelInputJson = { + 'threadId': 'tid', + 'runId': 'rid', + 'parentRunId': 'input-parent-rid', + 'messages': >[], + 'tools': >[], + 'context': >[], }; - + final camelEvent = RunStartedEvent.fromJson({ + 'type': 'RUN_STARTED', + 'threadId': 'tid', + 'runId': 'rid', + 'input': camelInputJson, + }); + expect(camelEvent.input!.parentRunId, 'input-parent-rid'); + final reEmitted = camelEvent.toJson(); expect( - () => BaseEvent.fromJson(json), - throwsArgumentError, + (reEmitted['input'] as Map)['parentRunId'], + 'input-parent-rid', ); - }); - }); - group('ReasoningEvents', () { - test('round-trips ReasoningStartEvent', () { - final event = ReasoningStartEvent(messageId: 'reas_1'); - final decoded = ReasoningStartEvent.fromJson(event.toJson()); - expect(decoded.messageId, 'reas_1'); + // snake_case alias on the embedded input also decodes. + final snakeInputJson = { + 'thread_id': 'tid', + 'run_id': 'rid', + 'parent_run_id': 'input-parent-snake', + 'messages': >[], + 'tools': >[], + 'context': >[], + }; + final snakeEvent = RunStartedEvent.fromJson({ + 'type': 'RUN_STARTED', + 'threadId': 'tid', + 'runId': 'rid', + 'input': snakeInputJson, + }); + expect(snakeEvent.input!.parentRunId, 'input-parent-snake'); }); - test('round-trips ReasoningMessageContentEvent', () { - final event = ReasoningMessageContentEvent( - messageId: 'reas_1', - delta: 'thinking step', - ); - final decoded = ReasoningMessageContentEvent.fromJson(event.toJson()); - expect(decoded.delta, 'thinking step'); - }); + test( + 'optionalIntField accepts JS/TS-shaped float timestamps ' + '(regression: cross-runtime decode)', () { + // JS/TS producers serialize all numbers through a single Number + // type, so a server emitting `Date.now() / 1000` arrives as + // `double`. The previous `optionalField` rejected `double` + // even when integer-valued. `optionalIntField` accepts any + // `num` and coerces via `.toInt()`. See + // `dart-enum-parsing-safety.md` (cross-runtime decode notes). + final fromDouble = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_001', + 'role': 'assistant', + 'timestamp': 1.7e9, // a float — used to fail decode + }); + expect(fromDouble.timestamp, equals(1700000000)); + + final fromInt = TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_002', + 'role': 'assistant', + 'timestamp': 1234567890, + }); + expect(fromInt.timestamp, equals(1234567890)); - test('ReasoningMessageContentEvent rejects empty delta', () { + // Wrong type still rejects (string is not a num). expect( - () => ReasoningMessageContentEvent.fromJson({ - 'type': 'REASONING_MESSAGE_CONTENT', - 'messageId': 'reas_1', - 'delta': '', + () => TextMessageStartEvent.fromJson({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_003', + 'role': 'assistant', + 'timestamp': 'not-a-number', }), throwsA(isA()), ); }); - test('round-trips ReasoningEncryptedValueEvent', () { - final event = ReasoningEncryptedValueEvent( - subtype: ReasoningEncryptedValueSubtype.message, - entityId: 'reas_1', - encryptedValue: 'ENC', + test('RunStartedEvent.copyWith(parentRunId: null) clears parentRunId', + () { + // Sentinel-pattern verification: per `_Unset` dartdoc, passing + // `null` to a sentinel-using `copyWith` parameter MUST clear the + // field, distinct from "argument omitted" which keeps it. + final event = RunStartedEvent( + threadId: 'tid', + runId: 'rid', + parentRunId: 'pid', ); - final json = event.toJson(); - expect(json['subtype'], 'message'); - final decoded = ReasoningEncryptedValueEvent.fromJson(json); - expect(decoded.subtype, ReasoningEncryptedValueSubtype.message); + expect(event.copyWith(parentRunId: null).parentRunId, isNull); + // Argument omitted → parentRunId preserved + expect(event.copyWith().parentRunId, 'pid'); }); - test('round-trips ReasoningEndEvent', () { - final event = ReasoningEndEvent(messageId: 'reas_1'); - final decoded = ReasoningEndEvent.fromJson(event.toJson()); - expect(decoded.messageId, 'reas_1'); - }); - - test('round-trips ReasoningMessageStartEvent', () { - final event = ReasoningMessageStartEvent(messageId: 'reas_1'); - final json = event.toJson(); - expect(json['role'], 'reasoning'); - final decoded = ReasoningMessageStartEvent.fromJson(json); - expect(decoded.messageId, 'reas_1'); - }); - - test('round-trips ReasoningMessageEndEvent', () { - final event = ReasoningMessageEndEvent(messageId: 'reas_1'); - final decoded = ReasoningMessageEndEvent.fromJson(event.toJson()); - expect(decoded.messageId, 'reas_1'); - }); - - test('round-trips ReasoningMessageChunkEvent with fields', () { - final event = ReasoningMessageChunkEvent( - messageId: 'reas_1', - delta: 'chunk', + test('RunStartedEvent.copyWith(input: null) clears input', () { + final input = RunAgentInput( + threadId: 'tid', + runId: 'rid', + messages: const [], + tools: const [], + context: const [], ); - final decoded = ReasoningMessageChunkEvent.fromJson(event.toJson()); - expect(decoded.messageId, 'reas_1'); - expect(decoded.delta, 'chunk'); - }); - - test('round-trips ReasoningMessageChunkEvent with null fields', () { - final event = ReasoningMessageChunkEvent(); - final json = event.toJson(); - expect(json.containsKey('messageId'), isFalse); - expect(json.containsKey('delta'), isFalse); - final decoded = ReasoningMessageChunkEvent.fromJson(json); - expect(decoded.messageId, isNull); - expect(decoded.delta, isNull); + final event = RunStartedEvent( + threadId: 'tid', + runId: 'rid', + input: input, + ); + expect(event.copyWith(input: null).input, isNull); + // Argument omitted → input preserved + expect(event.copyWith().input, isNotNull); }); - test('ReasoningEncryptedValueSubtype.fromString rejects unknown value', - () { + test( + 'RunStartedEvent.fromJson scrubs rawEvent when input.messages ' + 'carry cipher data (C1 regression)', () { + // Regression: RunStartedEvent.fromJson previously forwarded the + // verbatim wire map into rawEvent even when input.messages contained + // encryptedValue payloads, undoing the cipher scrubbing that the + // ReasoningMessage factory applied to the structured field. + final wireJson = { + 'type': 'RUN_STARTED', + 'threadId': 'thread-1', + 'runId': 'run-1', + 'input': { + 'threadId': 'thread-1', + 'runId': 'run-1', + 'messages': [ + { + 'id': 'msg-1', + 'role': 'assistant', + 'content': 'hi', + }, + { + 'id': 'msg-2', + 'role': 'reasoning', + 'content': 'thinking', + 'encryptedValue': 'c2VjcmV0', + }, + ], + 'tools': [], + 'context': [], + }, + 'rawEvent': {'original': 'wire-map'}, + }; + final event = RunStartedEvent.fromJson(wireJson); + // rawEvent MUST be null — the wire map carries encryptedValue in + // input.messages[1] and must not leak through rawEvent. expect( - () => ReasoningEncryptedValueSubtype.fromString('bogus'), - throwsA(isA()), + event.rawEvent, + isNull, + reason: + 'rawEvent must be scrubbed when input.messages carry cipher data', ); + expect(event.input!.messages.length, 2); }); - test('BaseEvent.fromJson routes all REASONING_* events', () { + test( + 'RunStartedEvent.fromJson scrubs rawEvent when input.messages ' + 'contain ActivityMessage with wire-level encryptedValue (I1 regression)', + () { + // I1: ActivityMessage.fromJson silently strips wire-level + // encryptedValue from the structured field, so the structured-field + // hasCipher predicate alone returns false. The fix extends the + // predicate to check the raw inputJson['messages'] directly. + final wireJson = { + 'type': 'RUN_STARTED', + 'threadId': 'thread-1', + 'runId': 'run-1', + 'input': { + 'threadId': 'thread-1', + 'runId': 'run-1', + 'messages': [ + { + 'id': 'a1', + 'role': 'activity', + 'activityType': 'task.run', + 'content': {}, + 'encryptedValue': 'should-not-leak', + }, + ], + 'tools': [], + 'context': [], + }, + 'rawEvent': {'_passthrough': 'arbitrary'}, + }; + final event = RunStartedEvent.fromJson(wireJson); expect( - BaseEvent.fromJson({'type': 'REASONING_START', 'messageId': 'r'}), - isA(), + event.rawEvent, + isNull, + reason: + 'rawEvent must be scrubbed when ActivityMessage in input.messages ' + 'carries wire-level encryptedValue', ); + final emitted = event.toJson(); expect( - BaseEvent.fromJson({'type': 'REASONING_END', 'messageId': 'r'}), - isA(), + jsonEncode(emitted), + isNot(contains('should-not-leak')), + reason: 'cipher must not leak through rawEvent passthrough', ); + }); + + test( + 'RunStartedEvent.fromJson preserves rawEvent when no cipher data ' + 'is present', () { + final wireJson = { + 'type': 'RUN_STARTED', + 'threadId': 'thread-1', + 'runId': 'run-1', + 'input': { + 'threadId': 'thread-1', + 'runId': 'run-1', + 'messages': [ + { + 'id': 'msg-1', + 'role': 'user', + 'content': 'hello', + }, + ], + 'tools': [], + 'context': [], + }, + 'rawEvent': {'seq': 1}, + }; + final event = RunStartedEvent.fromJson(wireJson); expect( - BaseEvent.fromJson( - {'type': 'REASONING_MESSAGE_START', 'messageId': 'r'}), - isA(), + event.rawEvent, + {'seq': 1}, + reason: 'rawEvent must be preserved when no cipher data is present', ); - expect( - BaseEvent.fromJson({ - 'type': 'REASONING_MESSAGE_CONTENT', - 'messageId': 'r', - 'delta': 'd', - }), - isA(), + }); + + test( + 'RunStartedEvent.copyWith scrubs rawEvent when new input carries ' + 'cipher data (C1 regression)', () { + final cipherInput = RunAgentInput( + threadId: 'tid', + runId: 'rid', + messages: [ + ReasoningMessage( + id: 'r1', + content: 'thinking', + encryptedValue: 'c2VjcmV0', + ), + ], + tools: const [], + context: const [], ); - expect( - BaseEvent.fromJson( - {'type': 'REASONING_MESSAGE_END', 'messageId': 'r'}), - isA(), + final event = RunStartedEvent( + threadId: 'tid', + runId: 'rid', + rawEvent: {'original': 'map'}, ); + final updated = event.copyWith(input: cipherInput); expect( - BaseEvent.fromJson({'type': 'REASONING_MESSAGE_CHUNK'}), - isA(), + updated.rawEvent, + isNull, + reason: + 'copyWith must scrub rawEvent when updated input carries cipher data', ); + }); + + test('RunStartedEvent.fromJson rethrow does not leak input payload', () { expect( - BaseEvent.fromJson({ - 'type': 'REASONING_ENCRYPTED_VALUE', - 'subtype': 'tool-call', - 'entityId': 'e', - 'encryptedValue': 'v', + () => RunStartedEvent.fromJson({ + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': 'r', + 'input': { + 'runId': 'r', + 'threadId': 't', + 'messages': [{'id': 123, 'role': 'user', 'content': 'hi', 'encryptedValue': 'cipher'}], + 'tools': [], + 'context': [], + 'forwardedProps': {}, + 'state': {}, + }, }), - isA(), + throwsA(isA().having((e) => e.json, 'json', isNull)), ); }); + }); - test('copyWith overrides fields on each reasoning event', () { - final start = ReasoningStartEvent(messageId: 'a', timestamp: 1) - .copyWith(messageId: 'b', timestamp: 2); - expect(start.messageId, 'b'); - expect(start.timestamp, 2); - - final end = ReasoningEndEvent(messageId: 'a').copyWith(messageId: 'b'); - expect(end.messageId, 'b'); - - final msgStart = ReasoningMessageStartEvent(messageId: 'a') - .copyWith(messageId: 'b'); - expect(msgStart.messageId, 'b'); + group('Event Factory', () { + test('should create correct event type based on type field', () { + final eventJsons = [ + {'type': 'TEXT_MESSAGE_START', 'messageId': 'msg_001'}, + { + 'type': 'TOOL_CALL_START', + 'toolCallId': 'call_001', + 'toolCallName': 'test' + }, + {'type': 'STATE_SNAPSHOT', 'snapshot': {}}, + {'type': 'RUN_STARTED', 'threadId': 'thread_001', 'runId': 'run_001'}, + {'type': 'THINKING_START'}, + {'type': 'CUSTOM', 'name': 'my_event', 'value': 'data'}, + ]; - final msgContent = ReasoningMessageContentEvent( - messageId: 'a', - delta: 'x', - ).copyWith(messageId: 'b', delta: 'y'); - expect(msgContent.messageId, 'b'); - expect(msgContent.delta, 'y'); + final events = + eventJsons.map((json) => BaseEvent.fromJson(json)).toList(); - final msgEnd = ReasoningMessageEndEvent(messageId: 'a') - .copyWith(messageId: 'b'); - expect(msgEnd.messageId, 'b'); + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + expect(events[3], isA()); + expect(events[4], isA()); + expect(events[5], isA()); + }); - final chunk = ReasoningMessageChunkEvent(messageId: 'a', delta: 'x') - .copyWith(messageId: 'b', delta: 'y'); - expect(chunk.messageId, 'b'); - expect(chunk.delta, 'y'); + test('should throw AGUIValidationError on invalid event type', () { + // The factory wraps `EventType.fromString`'s raw `ArgumentError` + // as `AGUIValidationError` so direct callers see the same error + // surface as every other validation failure. Through the public + // `EventDecoder` pipeline this surfaces as `DecodingError` — + // see `event_decoding_integration_test.dart` ("validates + // required fields strictly", invalid event type case). + final json = { + 'type': 'INVALID_EVENT_TYPE', + 'data': 'some data', + }; - final enc = ReasoningEncryptedValueEvent( - subtype: ReasoningEncryptedValueSubtype.toolCall, - entityId: 'a', - encryptedValue: 'x', - ).copyWith( - subtype: ReasoningEncryptedValueSubtype.message, - entityId: 'b', - encryptedValue: 'y', + expect( + () => BaseEvent.fromJson(json), + throwsA(isA()), ); - expect(enc.subtype, ReasoningEncryptedValueSubtype.message); - expect(enc.entityId, 'b'); - expect(enc.encryptedValue, 'y'); }); - test('copyWith preserves fields when no overrides given', () { - final original = ReasoningMessageContentEvent( - messageId: 'a', - delta: 'x', - timestamp: 1, - ); - final copy = original.copyWith(); - expect(copy.messageId, 'a'); - expect(copy.delta, 'x'); - expect(copy.timestamp, 1); - }); - }); - - group('ActivityDeltaEvent', () { - test('round-trips patch array', () { - final event = ActivityDeltaEvent( - messageId: 'act_1', - activityType: 'upload', - patch: [ - {'op': 'replace', 'path': '/progress', 'value': 0.75}, - ], - ); - final decoded = ActivityDeltaEvent.fromJson(event.toJson()); - expect(decoded.patch, hasLength(1)); - expect(decoded.activityType, 'upload'); - }); - - test('BaseEvent.fromJson routes ACTIVITY_DELTA', () { - final event = BaseEvent.fromJson({ - 'type': 'ACTIVITY_DELTA', - 'messageId': 'act_1', - 'activityType': 'upload', - 'patch': [], - }); - expect(event, isA()); - }); - - test('copyWith overrides fields', () { - final original = ActivityDeltaEvent( - messageId: 'a', - activityType: 'upload', - patch: [], - timestamp: 1, - ); - final copy = original.copyWith( - messageId: 'b', - activityType: 'download', - patch: [ - {'op': 'add', 'path': '/x', 'value': 1}, - ], - timestamp: 2, - ); - expect(copy.messageId, 'b'); - expect(copy.activityType, 'download'); - expect(copy.patch, hasLength(1)); - expect(copy.timestamp, 2); + test('every event toJson preserves the type discriminator after spread', + () { + // Pins the invariant that `BaseEvent.toJson` emits `'type': + // eventType.value` AND that no subclass `toJson` ever shadows it + // via `...super.toJson()` spread. A future subclass that + // accidentally adds a `'type'` key would silently overwrite the + // discriminator and the analyzer wouldn't catch it — this test + // would fail concretely. See `dart-sealed-classes-json-serialization.md` + // ("`toJson()` that uses spread `...super.toJson()` will overwrite + // the base's discriminator key"). + final samples = [ + TextMessageStartEvent(messageId: 'm'), + TextMessageContentEvent(messageId: 'm', delta: 'd'), + TextMessageEndEvent(messageId: 'm'), + TextMessageChunkEvent(), + // ignore: deprecated_member_use_from_same_package + ThinkingTextMessageStartEvent(), + // ignore: deprecated_member_use_from_same_package + ThinkingTextMessageContentEvent(delta: 'd'), + // ignore: deprecated_member_use_from_same_package + ThinkingTextMessageEndEvent(), + ToolCallStartEvent(toolCallId: 'c', toolCallName: 'n'), + ToolCallArgsEvent(toolCallId: 'c', delta: 'd'), + ToolCallEndEvent(toolCallId: 'c'), + ToolCallChunkEvent(), + ToolCallResultEvent( + messageId: 'm', + toolCallId: 'c', + content: 'ok', + ), + ThinkingStartEvent(), + ThinkingEndEvent(), + StateSnapshotEvent(snapshot: {}), + StateDeltaEvent(delta: const []), + MessagesSnapshotEvent(messages: const []), + ActivitySnapshotEvent( + messageId: 'm', + activityType: 't', + content: null, + ), + ActivityDeltaEvent( + messageId: 'm', + activityType: 't', + patch: const [], + ), + RawEvent(event: const {'k': 'v'}), + CustomEvent(name: 'n', value: 'v'), + RunStartedEvent(threadId: 'tid', runId: 'rid'), + RunFinishedEvent(threadId: 'tid', runId: 'rid'), + RunErrorEvent(message: 'oops'), + StepStartedEvent(stepName: 's'), + StepFinishedEvent(stepName: 's'), + ReasoningStartEvent(messageId: 'm'), + ReasoningMessageStartEvent(messageId: 'm'), + ReasoningMessageContentEvent(messageId: 'm', delta: 'd'), + ReasoningMessageEndEvent(messageId: 'm'), + ReasoningMessageChunkEvent(), + ReasoningEndEvent(messageId: 'm'), + ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.message, + entityId: 'e', + encryptedValue: 'v', + ), + ]; + + for (final e in samples) { + expect( + e.toJson()['type'], + equals(e.eventType.value), + reason: + 'discriminator must survive ...super.toJson() spread for ${e.runtimeType}', + ); + } + + // Sanity: the sample list covers every non-deprecated EventType. + // `thinkingContent` is intentionally excluded: it is the only + // Dart-only legacy event type (no protocol-level wire value), so it + // gets its own dedicated round-trip test ('deprecated + // ThinkingContentEvent still round-trips') rather than sharing this + // sample list. Keeping the deprecation surface narrow makes the 1.0.0 + // removal sweep a single-file edit. + final coveredTypes = samples.map((e) => e.eventType).toSet(); + // ignore: deprecated_member_use_from_same_package + final expectedTypes = EventType.values.toSet() + ..remove(EventType.thinkingContent); + expect(coveredTypes, equals(expectedTypes)); }); }); @@ -517,16 +1194,47 @@ void main() { expect(decoded.title, 'Processing request'); }); - test('ThinkingTextMessageContentEvent delta validation', () { - final invalidJson = { + test('ThinkingTextMessageContentEvent accepts empty delta', () { + // Relaxed to match canonical `z.string()` contract — empty `delta` + // is now accepted. Migrate to [ReasoningMessageContentEvent]. + final json = { 'type': 'THINKING_TEXT_MESSAGE_CONTENT', 'delta': '', }; - expect( - () => ThinkingTextMessageContentEvent.fromJson(invalidJson), - throwsA(isA()), - ); + // ignore: deprecated_member_use_from_same_package + final event = ThinkingTextMessageContentEvent.fromJson(json); + expect(event.delta, isEmpty); + }); + + test('deprecated ThinkingContentEvent still round-trips', () { + // Locks in the backward-compat contract on the deprecation: + // decoding/encoding must keep working until the planned removal. + // ignore: deprecated_member_use_from_same_package + final original = ThinkingContentEvent(delta: 'still works'); + final json = original.toJson(); + expect(json['type'], 'THINKING_CONTENT'); + expect(json['delta'], 'still works'); + + // ignore: deprecated_member_use_from_same_package + final decoded = ThinkingContentEvent.fromJson(json); + expect(decoded.delta, 'still works'); + }); + + test('EventDecoder still decodes deprecated THINKING_CONTENT', () { + // Backs the CHANGELOG promise that the deprecated path remains + // decodable end-to-end through the public decoder boundary. + const decoder = EventDecoder(); + + final event = decoder.decodeJson({ + 'type': 'THINKING_CONTENT', + 'delta': 'legacy payload', + }); + + // ignore: deprecated_member_use_from_same_package + expect(event, isA()); + // ignore: deprecated_member_use_from_same_package + expect((event as ThinkingContentEvent).delta, 'legacy payload'); }); }); @@ -551,6 +1259,49 @@ void main() { expect(decoded.source, 'external_api'); }); + test('rawEvent / raw_event dual-key — Python snake_case is preserved', + () { + // Python emits `raw_event`; TS emits `rawEvent`. Both must decode + // into `BaseEvent.rawEvent` so a Dart proxy can re-emit it + // (camelCase) on the next hop. Regression for the silent-drop bug + // that pre-existed across every event factory. + final upstreamPayload = {'origin': 'python-server', 'seq': 7}; + + // 1. Python-style snake_case input on RunStartedEvent. + final pythonJson = { + 'type': 'RUN_STARTED', + 'thread_id': 'thread_001', + 'run_id': 'run_001', + 'raw_event': upstreamPayload, + }; + final fromSnake = RunStartedEvent.fromJson(pythonJson); + expect(fromSnake.rawEvent, upstreamPayload); + // Output is canonical camelCase. + expect(fromSnake.toJson()['rawEvent'], upstreamPayload); + + // 2. camelCase wins when both keys are present. + final bothKeys = { + 'type': 'RUN_STARTED', + 'thread_id': 'thread_001', + 'run_id': 'run_001', + 'rawEvent': {'winner': 'camel'}, + 'raw_event': {'winner': 'snake'}, + }; + final fromBoth = RunStartedEvent.fromJson(bothKeys); + expect(fromBoth.rawEvent, {'winner': 'camel'}); + + // 3. camelCase explicit-null wins (containsKey precedence). + final nullCamel = { + 'type': 'RUN_STARTED', + 'thread_id': 'thread_001', + 'run_id': 'run_001', + 'rawEvent': null, + 'raw_event': {'winner': 'snake'}, + }; + final fromNullCamel = RunStartedEvent.fromJson(nullCamel); + expect(fromNullCamel.rawEvent, isNull); + }); + test('CustomEvent with complex value', () { final customValue = { 'action': 'update_ui', @@ -570,164 +1321,696 @@ void main() { expect(decoded.name, 'ui_config_change'); expect(decoded.value, customValue); }); + + test('RawEvent.copyWith(event: null) clears the payload', () { + // The sentinel pattern (mirroring `ActivitySnapshotEvent.content`) + // distinguishes "argument omitted" from "argument explicitly + // null", so an explicit null actually clears the field. + final original = RawEvent( + event: {'foo': 'bar'}, + source: 'agent', + ); + final keep = original.copyWith(); + expect(keep.event, equals({'foo': 'bar'})); + + final cleared = original.copyWith(event: null); + expect(cleared.event, isNull); + expect(cleared.source, equals('agent')); + }); + + test('RawEvent.copyWith(source: null) clears source', () { + // Sentinel parity for the second nullable field (was `?? this.source` + // before the sentinel sweep). Without the sentinel, an explicit + // `null` was indistinguishable from "argument omitted". + final original = RawEvent( + event: const {'foo': 'bar'}, + source: 'agent', + ); + final keep = original.copyWith(); + expect(keep.source, equals('agent')); + + final cleared = original.copyWith(source: null); + expect(cleared.source, isNull); + // Other fields preserved. + expect(cleared.event, equals(const {'foo': 'bar'})); + }); + + test('CustomEvent.copyWith(value: null) clears the payload', () { + final original = CustomEvent(name: 'evt', value: 42); + final keep = original.copyWith(); + expect(keep.value, equals(42)); + + final cleared = original.copyWith(value: null); + expect(cleared.value, isNull); + expect(cleared.name, equals('evt')); + }); }); group('ActivityEvents', () { - test('ActivitySnapshotEvent serialization with spec fields', () { - final content = {'skill': 'rag', 'tool_name': 'search'}; + test('ActivitySnapshotEvent serialization round-trip', () { + final content = { + 'title': 'Processing', + 'progress': 0.5, + 'steps': ['fetch', 'parse'], + }; + final event = ActivitySnapshotEvent( - messageId: 'rag:abc123', - activityType: 'skill_tool_call', + messageId: 'msg_001', + activityType: 'task.run', content: content, + replace: false, ); final json = event.toJson(); expect(json['type'], 'ACTIVITY_SNAPSHOT'); - expect(json['messageId'], 'rag:abc123'); - expect(json['activityType'], 'skill_tool_call'); + expect(json['messageId'], 'msg_001'); + expect(json['activityType'], 'task.run'); expect(json['content'], content); - expect(json['replace'], true); + expect(json['replace'], false); final decoded = ActivitySnapshotEvent.fromJson(json); - expect(decoded.messageId, 'rag:abc123'); - expect(decoded.activityType, 'skill_tool_call'); + expect(decoded.messageId, 'msg_001'); + expect(decoded.activityType, 'task.run'); expect(decoded.content, content); + expect(decoded.replace, false); + }); + + test('ActivitySnapshotEvent defaults replace to true', () { + final json = { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'msg_001', + 'activityType': 'task.run', + 'content': {'foo': 'bar'}, + }; + + final decoded = ActivitySnapshotEvent.fromJson(json); expect(decoded.replace, true); }); - test('ActivitySnapshotEvent replace can be set to false', () { - const event = ActivitySnapshotEvent( - messageId: 'msg-1', - activityType: 'test', - content: {}, + test( + 'ActivitySnapshotEvent.toJson omits replace when true (default), ' + 'emits replace when false', () { + // Canonical TS/Python wire behavior: `replace` is omitted when it + // equals the default `true`; emitted only when `false`. `fromJson` + // defaults to `true` when absent, so round-trip semantics hold. + final defaultEvent = ActivitySnapshotEvent( + messageId: 'm', + activityType: 't', + content: null, + ); + expect(defaultEvent.replace, isTrue); + expect(defaultEvent.toJson().containsKey('replace'), isFalse, + reason: 'replace=true (default) must be omitted from wire output'); + + final replaceEvent = ActivitySnapshotEvent( + messageId: 'm', + activityType: 't', + content: null, replace: false, ); - expect(event.replace, false); + expect(replaceEvent.toJson()['replace'], isFalse, + reason: 'replace=false (non-default) must be emitted'); + }); + + test('ActivitySnapshotEvent treats explicit-null replace as default-true', + () { + // `optionalField` returns null for both an absent key and + // an explicit-null value; the `?? true` coercion at the factory + // pins the documented behavior. This test locks the contract so + // a future change to `optionalField` semantics doesn't + // silently drift. + final decoded = ActivitySnapshotEvent.fromJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'msg_001', + 'activityType': 'task.run', + 'content': null, + 'replace': null, + }); + expect(decoded.replace, isTrue); + }); + + test('ActivitySnapshotEvent accepts snake_case (Python server)', () { + final pythonJson = { + 'type': 'ACTIVITY_SNAPSHOT', + 'message_id': 'msg_002', + 'activity_type': 'task.run', + 'content': 'hello', + 'replace': true, + }; + + final decoded = ActivitySnapshotEvent.fromJson(pythonJson); + expect(decoded.messageId, 'msg_002'); + expect(decoded.activityType, 'task.run'); + expect(decoded.content, 'hello'); + expect(decoded.replace, true); + }); + + test('ActivityDeltaEvent serialization round-trip', () { + final patch = [ + {'op': 'replace', 'path': '/progress', 'value': 0.75}, + {'op': 'add', 'path': '/steps/-', 'value': 'finalize'}, + ]; + + final event = ActivityDeltaEvent( + messageId: 'msg_001', + activityType: 'task.run', + patch: patch, + ); final json = event.toJson(); - expect(json['replace'], false); + expect(json['type'], 'ACTIVITY_DELTA'); + expect(json['messageId'], 'msg_001'); + expect(json['activityType'], 'task.run'); + expect(json['patch'], patch); - final decoded = ActivitySnapshotEvent.fromJson(json); - expect(decoded.replace, false); + final decoded = ActivityDeltaEvent.fromJson(json); + expect(decoded.messageId, 'msg_001'); + expect(decoded.activityType, 'task.run'); + expect(decoded.patch, patch); }); - test('ActivitySnapshotEvent fromJson with missing optional replace', () { - final json = { - 'type': 'ACTIVITY_SNAPSHOT', - 'messageId': 'msg-1', - 'activityType': 'test', - 'content': {'key': 'value'}, + test('ActivityDeltaEvent accepts snake_case (Python server)', () { + final pythonJson = { + 'type': 'ACTIVITY_DELTA', + 'message_id': 'msg_003', + 'activity_type': 'task.run', + 'patch': [ + {'op': 'replace', 'path': '/x', 'value': 1}, + ], }; - final event = ActivitySnapshotEvent.fromJson(json); - expect(event.messageId, 'msg-1'); - expect(event.activityType, 'test'); - expect(event.content, {'key': 'value'}); - expect(event.replace, true); + final decoded = ActivityDeltaEvent.fromJson(pythonJson); + expect(decoded.messageId, 'msg_003'); + expect(decoded.activityType, 'task.run'); + expect(decoded.patch.length, 1); }); - test('ActivitySnapshotEvent fromJson throws on missing messageId', () { - final json = { + test('Activity events dispatch via BaseEvent.fromJson', () { + final snapshot = BaseEvent.fromJson({ 'type': 'ACTIVITY_SNAPSHOT', - 'activityType': 'test', - 'content': {'key': 'value'}, - }; + 'messageId': 'm', + 'activityType': 't', + 'content': null, + }); + expect(snapshot, isA()); + expect((snapshot as ActivitySnapshotEvent).content, isNull); + + final delta = BaseEvent.fromJson({ + 'type': 'ACTIVITY_DELTA', + 'messageId': 'm', + 'activityType': 't', + 'patch': [], + }); + expect(delta, isA()); + }); + test('ActivitySnapshotEvent rejects missing content key', () { + // Mirrors the `StateSnapshotEvent` / `RawEvent` contract: the + // payload field may be any JSON shape (including `null`) but the + // KEY must be present. Distinguishing missing-key from + // explicit-null is the whole point of this check. expect( - () => ActivitySnapshotEvent.fromJson(json), + () => ActivitySnapshotEvent.fromJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'msg_001', + 'activityType': 'task.run', + }), throwsA(isA()), ); }); - test('ActivitySnapshotEvent fromJson throws on missing activityType', () { - final json = { + test('ActivitySnapshotEvent accepts explicit-null content', () { + // The companion to "rejects missing content key": an explicit + // `null` is a valid wire payload (Python's `content: Any` + // permits None) and must round-trip without error. + final decoded = ActivitySnapshotEvent.fromJson({ 'type': 'ACTIVITY_SNAPSHOT', - 'messageId': 'msg-1', - 'content': {'key': 'value'}, - }; + 'messageId': 'msg_001', + 'activityType': 'task.run', + 'content': null, + }); + expect(decoded.content, isNull); + }); + + test('ActivitySnapshotEvent.copyWith(content: null) clears content', () { + // The factory contract permits explicit-null `content`, and so + // must `copyWith` — distinguishing "argument omitted" from + // "argument explicitly set to null" via the + // `_unsetCopyWith` sentinel. + final original = ActivitySnapshotEvent( + messageId: 'msg_001', + activityType: 'task.run', + content: {'progress': 0.25}, + ); + // Omitted content keeps the existing value. + final keep = original.copyWith(); + expect(keep.content, equals({'progress': 0.25})); + + // Explicit-null clears the content. + final cleared = original.copyWith(content: null); + expect(cleared.content, isNull); + }); + test('ActivitySnapshotEvent rejects missing messageId', () { expect( - () => ActivitySnapshotEvent.fromJson(json), + () => ActivitySnapshotEvent.fromJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'activityType': 'task.run', + 'content': null, + }), throwsA(isA()), ); }); - test('ActivitySnapshotEvent fromJson throws on missing content', () { - final json = { - 'type': 'ACTIVITY_SNAPSHOT', - 'messageId': 'msg-1', - 'activityType': 'test', - }; + test('ActivityDeltaEvent rejects missing messageId', () { + expect( + () => ActivityDeltaEvent.fromJson({ + 'type': 'ACTIVITY_DELTA', + 'activityType': 'task.run', + 'patch': [], + }), + throwsA(isA()), + ); + }); + test('ActivityDeltaEvent rejects missing activityType', () { expect( - () => ActivitySnapshotEvent.fromJson(json), + () => ActivityDeltaEvent.fromJson({ + 'type': 'ACTIVITY_DELTA', + 'messageId': 'msg_001', + 'patch': [], + }), throwsA(isA()), ); }); - test('ActivitySnapshotEvent copyWith preserves unchanged fields', () { - const event = ActivitySnapshotEvent( - messageId: 'msg-1', - activityType: 'test', - content: {'a': 1}, - replace: false, - timestamp: 1000, + test('ActivityDeltaEvent rejects missing patch', () { + expect( + () => ActivityDeltaEvent.fromJson({ + 'type': 'ACTIVITY_DELTA', + 'messageId': 'msg_001', + 'activityType': 'task.run', + }), + throwsA(isA()), + ); + }); + + test('ActivitySnapshotEvent copyWith preserves untouched fields', () { + final original = ActivitySnapshotEvent( + messageId: 'msg_001', + activityType: 'task.run', + content: 'original', + ); + + final updated = original.copyWith(content: 'new'); + expect(updated.messageId, original.messageId); + expect(updated.activityType, original.activityType); + expect(updated.content, 'new'); + expect(updated.replace, original.replace); + }); + }); + + group('ReasoningEvents', () { + test('ReasoningStartEvent serialization round-trip', () { + final event = ReasoningStartEvent(messageId: 'msg_r1'); + + final json = event.toJson(); + expect(json['type'], 'REASONING_START'); + expect(json['messageId'], 'msg_r1'); + + final decoded = ReasoningStartEvent.fromJson(json); + expect(decoded.messageId, 'msg_r1'); + }); + + test('ReasoningStartEvent accepts snake_case', () { + final decoded = ReasoningStartEvent.fromJson({ + 'type': 'REASONING_START', + 'message_id': 'msg_r1', + }); + expect(decoded.messageId, 'msg_r1'); + }); + + test('ReasoningMessageStartEvent accepts snake_case', () { + final decoded = ReasoningMessageStartEvent.fromJson({ + 'type': 'REASONING_MESSAGE_START', + 'message_id': 'msg_r2', + 'role': 'reasoning', + }); + expect(decoded.messageId, 'msg_r2'); + expect(decoded.role, ReasoningMessageRole.reasoning); + }); + + test('ReasoningMessageContentEvent accepts snake_case', () { + final decoded = ReasoningMessageContentEvent.fromJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'message_id': 'msg_r3', + 'delta': 'thinking step', + }); + expect(decoded.messageId, 'msg_r3'); + expect(decoded.delta, 'thinking step'); + }); + + test('ReasoningMessageEndEvent accepts snake_case', () { + final decoded = ReasoningMessageEndEvent.fromJson({ + 'type': 'REASONING_MESSAGE_END', + 'message_id': 'msg_r4', + }); + expect(decoded.messageId, 'msg_r4'); + }); + + test('ReasoningEndEvent accepts snake_case', () { + final decoded = ReasoningEndEvent.fromJson({ + 'type': 'REASONING_END', + 'message_id': 'msg_r6', + }); + expect(decoded.messageId, 'msg_r6'); + }); + + test('ReasoningMessageStartEvent default role is reasoning', () { + final event = ReasoningMessageStartEvent(messageId: 'msg_r2'); + expect(event.role, ReasoningMessageRole.reasoning); + + final json = event.toJson(); + expect(json['type'], 'REASONING_MESSAGE_START'); + expect(json['role'], 'reasoning'); + + final decoded = ReasoningMessageStartEvent.fromJson(json); + expect(decoded.role, ReasoningMessageRole.reasoning); + expect(decoded.messageId, 'msg_r2'); + }); + + test('ReasoningMessageContentEvent serialization round-trip', () { + final event = ReasoningMessageContentEvent( + messageId: 'msg_r3', + delta: 'thinking step', ); - final withMessageId = event.copyWith(messageId: 'msg-2'); - expect(withMessageId.messageId, 'msg-2'); - expect(withMessageId.activityType, 'test'); - expect(withMessageId.content, {'a': 1}); - expect(withMessageId.replace, false); - expect(withMessageId.timestamp, 1000); + final json = event.toJson(); + expect(json['type'], 'REASONING_MESSAGE_CONTENT'); + expect(json['delta'], 'thinking step'); - final withActivityType = event.copyWith(activityType: 'updated'); - expect(withActivityType.messageId, 'msg-1'); - expect(withActivityType.activityType, 'updated'); + final decoded = ReasoningMessageContentEvent.fromJson(json); + expect(decoded.messageId, 'msg_r3'); + expect(decoded.delta, 'thinking step'); + }); - final withContent = event.copyWith(content: {'b': 2}); - expect(withContent.content, {'b': 2}); - expect(withContent.messageId, 'msg-1'); + test('ReasoningMessageEndEvent serialization round-trip', () { + final event = ReasoningMessageEndEvent(messageId: 'msg_r4'); - final withReplace = event.copyWith(replace: true); - expect(withReplace.replace, true); - expect(withReplace.messageId, 'msg-1'); + final json = event.toJson(); + expect(json['type'], 'REASONING_MESSAGE_END'); - final withTimestamp = event.copyWith(timestamp: 2000); - expect(withTimestamp.timestamp, 2000); - expect(withTimestamp.messageId, 'msg-1'); + final decoded = ReasoningMessageEndEvent.fromJson(json); + expect(decoded.messageId, 'msg_r4'); }); - test('ActivitySnapshotEvent timestamp survives serialization', () { - const event = ActivitySnapshotEvent( - messageId: 'msg-1', - activityType: 'test', - content: {'key': 'value'}, - timestamp: 1710000000, + test('ReasoningMessageChunkEvent allows all-optional payload', () { + final empty = ReasoningMessageChunkEvent(); + final emptyJson = empty.toJson(); + expect(emptyJson['type'], 'REASONING_MESSAGE_CHUNK'); + expect(emptyJson.containsKey('messageId'), false); + expect(emptyJson.containsKey('delta'), false); + + final decoded = ReasoningMessageChunkEvent.fromJson(emptyJson); + expect(decoded.messageId, isNull); + expect(decoded.delta, isNull); + + final populated = ReasoningMessageChunkEvent( + messageId: 'msg_r5', + delta: 'partial', ); + final pjson = populated.toJson(); + expect(pjson['messageId'], 'msg_r5'); + expect(pjson['delta'], 'partial'); + }); + + test('ReasoningMessageChunkEvent.copyWith(delta: null) clears delta', () { + // Sentinel-pattern verification for both `messageId` and `delta`. + final event = ReasoningMessageChunkEvent( + messageId: 'msg_r5', + delta: 'partial', + ); + expect(event.copyWith(delta: null).delta, isNull); + expect(event.copyWith(messageId: null).messageId, isNull); + // Argument omitted preserves both + final cloned = event.copyWith(); + expect(cloned.messageId, 'msg_r5'); + expect(cloned.delta, 'partial'); + }); + + test('ReasoningEndEvent serialization round-trip', () { + final event = ReasoningEndEvent(messageId: 'msg_r6'); final json = event.toJson(); - expect(json['timestamp'], 1710000000); + expect(json['type'], 'REASONING_END'); - final decoded = ActivitySnapshotEvent.fromJson(json); - expect(decoded.timestamp, 1710000000); + final decoded = ReasoningEndEvent.fromJson(json); + expect(decoded.messageId, 'msg_r6'); }); - test('ActivitySnapshotEvent via BaseEvent.fromJson factory', () { - final json = { - 'type': 'ACTIVITY_SNAPSHOT', - 'messageId': 'rag:abc123', - 'activityType': 'skill_tool_call', - 'content': {'skill': 'rag'}, - 'replace': true, + test('ReasoningEncryptedValueEvent supports both subtypes', () { + final tool = ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.toolCall, + entityId: 'tc_1', + encryptedValue: 'cipher-1', + ); + final toolJson = tool.toJson(); + expect(toolJson['type'], 'REASONING_ENCRYPTED_VALUE'); + expect(toolJson['subtype'], 'tool-call'); + expect(toolJson['entityId'], 'tc_1'); + expect(toolJson['encryptedValue'], 'cipher-1'); + + final decodedTool = ReasoningEncryptedValueEvent.fromJson(toolJson); + expect(decodedTool.subtype, ReasoningEncryptedValueSubtype.toolCall); + expect(decodedTool.entityId, 'tc_1'); + expect(decodedTool.encryptedValue, 'cipher-1'); + + final msg = ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.message, + entityId: 'm_1', + encryptedValue: 'cipher-2', + ); + expect(msg.toJson()['subtype'], 'message'); + }); + + test('ReasoningEncryptedValueEvent accepts snake_case', () { + final decoded = ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'tool-call', + 'entity_id': 'tc_2', + 'encrypted_value': 'cipher-3', + }); + expect(decoded.subtype, ReasoningEncryptedValueSubtype.toolCall); + expect(decoded.entityId, 'tc_2'); + expect(decoded.encryptedValue, 'cipher-3'); + }); + + test('ReasoningEncryptedValueSubtype.fromString throws on unknown values', + () { + // Unlike other enum fromString helpers (which throw ArgumentError), + // ReasoningEncryptedValueSubtype.fromString throws AGUIValidationError + // so the cipher-data path can surface a typed, structured error. + expect( + () => ReasoningEncryptedValueSubtype.fromString('bogus'), + throwsA(isA()), + ); + }); + + test('ReasoningMessageRole.fromString throws on unknown values', () { + expect( + () => ReasoningMessageRole.fromString('bogus'), + throwsA(isA()), + ); + }); + + test( + 'ReasoningMessageStartEvent falls back to `reasoning` for an ' + 'unknown role (forward-compat, no stream tear-down)', () { + // `ReasoningMessageRole` is currently a single-variant enum + // mirroring the canonical `Literal["reasoning"]` in the Python + // and TypeScript SDKs (see the dartdoc on `ReasoningMessageRole` + // in `lib/src/events/events.dart`). The forward-compat machinery + // — `fromString` throw + factory absorb + fallback — therefore + // exercises a path that cannot legitimately fire today, but + // pins the contract for the day a future spec adds a second + // role value. Do not delete this as tautological. + final decoded = ReasoningMessageStartEvent.fromJson({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'msg_r2', + 'role': 'bogus', + }); + expect(decoded.role, ReasoningMessageRole.reasoning); + expect(decoded.messageId, 'msg_r2'); + }); + + test( + 'ReasoningMessageStartEvent rejects missing role (parity with TS/Python)', + () { + // The canonical TypeScript and Python schemas both mark `role` as + // required on REASONING_MESSAGE_START. A producer bug that drops + // the field must surface as a protocol violation here, not be + // silently coerced to `reasoning` (which would let malformed + // payloads pass undetected and diverge from the reference SDKs). + expect( + () => ReasoningMessageStartEvent.fromJson({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'msg_r2', + }), + throwsA(isA()), + ); + }); + + test('ReasoningMessageChunkEvent accepts snake_case', () { + final decoded = ReasoningMessageChunkEvent.fromJson({ + 'type': 'REASONING_MESSAGE_CHUNK', + 'message_id': 'msg_r5', + 'delta': 'partial', + }); + + expect(decoded.messageId, 'msg_r5'); + expect(decoded.delta, 'partial'); + }); + + test('ReasoningMessageContentEvent rejects missing delta', () { + expect( + () => ReasoningMessageContentEvent.fromJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'msg_r3', + }), + throwsA(isA()), + ); + }); + + test( + 'ReasoningMessageContentEvent accepts empty delta (canonical parity)', + () { + // Canonical TS/Python schemas allow empty `delta` + // (`ReasoningMessageContentEventSchema.delta: z.string()` / + // pydantic `delta: str`). The Dart SDK matches. + final ev = ReasoningMessageContentEvent.fromJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'msg_r3', + 'delta': '', + }); + expect(ev.delta, isEmpty); + }); + + test('ReasoningEncryptedValueEvent rejects missing subtype', () { + expect( + () => ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'entityId': 'tc_1', + 'encryptedValue': 'cipher-1', + }), + throwsA(isA()), + ); + }); + + test('ReasoningEncryptedValueEvent rejects missing entityId', () { + expect( + () => ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'encryptedValue': 'cipher', + }), + throwsA(isA()), + ); + }); + + test('ReasoningEncryptedValueEvent rejects missing encryptedValue', () { + expect( + () => ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'msg_1', + }), + throwsA(isA()), + ); + }); + + test( + 'ReasoningEncryptedValueEvent accepts empty entityId / ' + 'encryptedValue (canonical-schema parity)', () { + // Canonical schemas: TS `events.ts` declares `entityId: z.string()` + // and `encryptedValue: z.string()`; Python `events.py` declares + // `entity_id: str` and `encrypted_value: str`. Neither imposes a + // minimum length. Dart must not be stricter than the protocol — + // a payload accepted by TS/Python must decode in Dart. + final emptyEntity = ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': '', + 'encryptedValue': 'cipher', + }); + expect(emptyEntity.entityId, ''); + expect(emptyEntity.encryptedValue, 'cipher'); + + final emptyCipher = ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'rsn_01', + 'encryptedValue': '', + }); + expect(emptyCipher.entityId, 'rsn_01'); + expect(emptyCipher.encryptedValue, ''); + }); + + test('ReasoningEncryptedValueEvent rejects unknown subtype', () { + // Pins the dartdoc contract: an unknown `subtype` must surface + // to direct factory callers as `AGUIValidationError` (not as + // the raw `ArgumentError` that the enum itself throws). The + // matching wire→DecodingError contract is locked in by the + // integration test in + // event_decoding_integration_test.dart. + expect( + () => ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'bogus', + 'entityId': 'rsn_01', + 'encryptedValue': 'cipher', + }), + throwsA(isA()), + ); + }); + + test('ReasoningEncryptedValueEvent.fromJson scrubs rawEvent', () { + final decoded = ReasoningEncryptedValueEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'r-1', + 'encryptedValue': 'cipher', + 'rawEvent': {'leak': true}, + }); + expect(decoded.rawEvent, isNull); + }); + + test('Reasoning events dispatch via BaseEvent.fromJson', () { + final cases = , Type>{ + {'type': 'REASONING_START', 'messageId': 'm'}: ReasoningStartEvent, + { + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'm', + 'role': 'reasoning', + }: ReasoningMessageStartEvent, + {'type': 'REASONING_MESSAGE_CONTENT', 'messageId': 'm', 'delta': 'd'}: + ReasoningMessageContentEvent, + {'type': 'REASONING_MESSAGE_END', 'messageId': 'm'}: + ReasoningMessageEndEvent, + {'type': 'REASONING_MESSAGE_CHUNK'}: ReasoningMessageChunkEvent, + {'type': 'REASONING_END', 'messageId': 'm'}: ReasoningEndEvent, + { + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'e', + 'encryptedValue': 'v', + }: ReasoningEncryptedValueEvent, }; - final event = BaseEvent.fromJson(json); - expect(event, isA()); - final activity = event as ActivitySnapshotEvent; - expect(activity.messageId, 'rag:abc123'); - expect(activity.activityType, 'skill_tool_call'); + cases.forEach((json, type) { + final event = BaseEvent.fromJson(json); + expect(event.runtimeType, type, reason: 'for $json'); + }); }); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/events/event_type_test.dart b/sdks/community/dart/test/events/event_type_test.dart index 4a881aeb01..e5e515a48c 100644 --- a/sdks/community/dart/test/events/event_type_test.dart +++ b/sdks/community/dart/test/events/event_type_test.dart @@ -5,23 +5,33 @@ void main() { group('EventType', () { test('each enum has correct string value', () { expect(EventType.textMessageStart.value, equals('TEXT_MESSAGE_START')); - expect(EventType.textMessageContent.value, equals('TEXT_MESSAGE_CONTENT')); + expect( + EventType.textMessageContent.value, equals('TEXT_MESSAGE_CONTENT')); expect(EventType.textMessageEnd.value, equals('TEXT_MESSAGE_END')); expect(EventType.textMessageChunk.value, equals('TEXT_MESSAGE_CHUNK')); - expect(EventType.thinkingTextMessageStart.value, equals('THINKING_TEXT_MESSAGE_START')); - expect(EventType.thinkingTextMessageContent.value, equals('THINKING_TEXT_MESSAGE_CONTENT')); - expect(EventType.thinkingTextMessageEnd.value, equals('THINKING_TEXT_MESSAGE_END')); + // ignore: deprecated_member_use_from_same_package + expect(EventType.thinkingTextMessageStart.value, + equals('THINKING_TEXT_MESSAGE_START')); + // ignore: deprecated_member_use_from_same_package + expect(EventType.thinkingTextMessageContent.value, + equals('THINKING_TEXT_MESSAGE_CONTENT')); + // ignore: deprecated_member_use_from_same_package + expect(EventType.thinkingTextMessageEnd.value, + equals('THINKING_TEXT_MESSAGE_END')); expect(EventType.toolCallStart.value, equals('TOOL_CALL_START')); expect(EventType.toolCallArgs.value, equals('TOOL_CALL_ARGS')); expect(EventType.toolCallEnd.value, equals('TOOL_CALL_END')); expect(EventType.toolCallChunk.value, equals('TOOL_CALL_CHUNK')); expect(EventType.toolCallResult.value, equals('TOOL_CALL_RESULT')); expect(EventType.thinkingStart.value, equals('THINKING_START')); + // ignore: deprecated_member_use_from_same_package expect(EventType.thinkingContent.value, equals('THINKING_CONTENT')); expect(EventType.thinkingEnd.value, equals('THINKING_END')); expect(EventType.stateSnapshot.value, equals('STATE_SNAPSHOT')); expect(EventType.stateDelta.value, equals('STATE_DELTA')); expect(EventType.messagesSnapshot.value, equals('MESSAGES_SNAPSHOT')); + expect(EventType.activitySnapshot.value, equals('ACTIVITY_SNAPSHOT')); + expect(EventType.activityDelta.value, equals('ACTIVITY_DELTA')); expect(EventType.raw.value, equals('RAW')); expect(EventType.custom.value, equals('CUSTOM')); expect(EventType.runStarted.value, equals('RUN_STARTED')); @@ -29,34 +39,98 @@ void main() { expect(EventType.runError.value, equals('RUN_ERROR')); expect(EventType.stepStarted.value, equals('STEP_STARTED')); expect(EventType.stepFinished.value, equals('STEP_FINISHED')); + expect(EventType.reasoningStart.value, equals('REASONING_START')); + expect( + EventType.reasoningMessageStart.value, + equals('REASONING_MESSAGE_START'), + ); + expect( + EventType.reasoningMessageContent.value, + equals('REASONING_MESSAGE_CONTENT'), + ); + expect( + EventType.reasoningMessageEnd.value, + equals('REASONING_MESSAGE_END'), + ); + expect( + EventType.reasoningMessageChunk.value, + equals('REASONING_MESSAGE_CHUNK'), + ); + expect(EventType.reasoningEnd.value, equals('REASONING_END')); + expect( + EventType.reasoningEncryptedValue.value, + equals('REASONING_ENCRYPTED_VALUE'), + ); }); test('fromString converts string to correct enum', () { - expect(EventType.fromString('TEXT_MESSAGE_START'), equals(EventType.textMessageStart)); - expect(EventType.fromString('TEXT_MESSAGE_CONTENT'), equals(EventType.textMessageContent)); - expect(EventType.fromString('TEXT_MESSAGE_END'), equals(EventType.textMessageEnd)); - expect(EventType.fromString('TEXT_MESSAGE_CHUNK'), equals(EventType.textMessageChunk)); - expect(EventType.fromString('THINKING_TEXT_MESSAGE_START'), equals(EventType.thinkingTextMessageStart)); - expect(EventType.fromString('THINKING_TEXT_MESSAGE_CONTENT'), equals(EventType.thinkingTextMessageContent)); - expect(EventType.fromString('THINKING_TEXT_MESSAGE_END'), equals(EventType.thinkingTextMessageEnd)); - expect(EventType.fromString('TOOL_CALL_START'), equals(EventType.toolCallStart)); - expect(EventType.fromString('TOOL_CALL_ARGS'), equals(EventType.toolCallArgs)); - expect(EventType.fromString('TOOL_CALL_END'), equals(EventType.toolCallEnd)); - expect(EventType.fromString('TOOL_CALL_CHUNK'), equals(EventType.toolCallChunk)); - expect(EventType.fromString('TOOL_CALL_RESULT'), equals(EventType.toolCallResult)); - expect(EventType.fromString('THINKING_START'), equals(EventType.thinkingStart)); - expect(EventType.fromString('THINKING_CONTENT'), equals(EventType.thinkingContent)); - expect(EventType.fromString('THINKING_END'), equals(EventType.thinkingEnd)); - expect(EventType.fromString('STATE_SNAPSHOT'), equals(EventType.stateSnapshot)); + expect(EventType.fromString('TEXT_MESSAGE_START'), + equals(EventType.textMessageStart)); + expect(EventType.fromString('TEXT_MESSAGE_CONTENT'), + equals(EventType.textMessageContent)); + expect(EventType.fromString('TEXT_MESSAGE_END'), + equals(EventType.textMessageEnd)); + expect(EventType.fromString('TEXT_MESSAGE_CHUNK'), + equals(EventType.textMessageChunk)); + // ignore: deprecated_member_use_from_same_package + expect(EventType.fromString('THINKING_TEXT_MESSAGE_START'), + equals(EventType.thinkingTextMessageStart)); + // ignore: deprecated_member_use_from_same_package + expect(EventType.fromString('THINKING_TEXT_MESSAGE_CONTENT'), + equals(EventType.thinkingTextMessageContent)); + // ignore: deprecated_member_use_from_same_package + expect(EventType.fromString('THINKING_TEXT_MESSAGE_END'), + equals(EventType.thinkingTextMessageEnd)); + expect(EventType.fromString('TOOL_CALL_START'), + equals(EventType.toolCallStart)); + expect(EventType.fromString('TOOL_CALL_ARGS'), + equals(EventType.toolCallArgs)); + expect( + EventType.fromString('TOOL_CALL_END'), equals(EventType.toolCallEnd)); + expect(EventType.fromString('TOOL_CALL_CHUNK'), + equals(EventType.toolCallChunk)); + expect(EventType.fromString('TOOL_CALL_RESULT'), + equals(EventType.toolCallResult)); + expect(EventType.fromString('THINKING_START'), + equals(EventType.thinkingStart)); + // ignore: deprecated_member_use_from_same_package + expect(EventType.fromString('THINKING_CONTENT'), + equals(EventType.thinkingContent)); + expect( + EventType.fromString('THINKING_END'), equals(EventType.thinkingEnd)); + expect(EventType.fromString('STATE_SNAPSHOT'), + equals(EventType.stateSnapshot)); expect(EventType.fromString('STATE_DELTA'), equals(EventType.stateDelta)); - expect(EventType.fromString('MESSAGES_SNAPSHOT'), equals(EventType.messagesSnapshot)); + expect(EventType.fromString('MESSAGES_SNAPSHOT'), + equals(EventType.messagesSnapshot)); + expect(EventType.fromString('ACTIVITY_SNAPSHOT'), + equals(EventType.activitySnapshot)); + expect(EventType.fromString('ACTIVITY_DELTA'), + equals(EventType.activityDelta)); expect(EventType.fromString('RAW'), equals(EventType.raw)); expect(EventType.fromString('CUSTOM'), equals(EventType.custom)); expect(EventType.fromString('RUN_STARTED'), equals(EventType.runStarted)); - expect(EventType.fromString('RUN_FINISHED'), equals(EventType.runFinished)); + expect( + EventType.fromString('RUN_FINISHED'), equals(EventType.runFinished)); expect(EventType.fromString('RUN_ERROR'), equals(EventType.runError)); - expect(EventType.fromString('STEP_STARTED'), equals(EventType.stepStarted)); - expect(EventType.fromString('STEP_FINISHED'), equals(EventType.stepFinished)); + expect( + EventType.fromString('STEP_STARTED'), equals(EventType.stepStarted)); + expect(EventType.fromString('STEP_FINISHED'), + equals(EventType.stepFinished)); + expect(EventType.fromString('REASONING_START'), + equals(EventType.reasoningStart)); + expect(EventType.fromString('REASONING_MESSAGE_START'), + equals(EventType.reasoningMessageStart)); + expect(EventType.fromString('REASONING_MESSAGE_CONTENT'), + equals(EventType.reasoningMessageContent)); + expect(EventType.fromString('REASONING_MESSAGE_END'), + equals(EventType.reasoningMessageEnd)); + expect(EventType.fromString('REASONING_MESSAGE_CHUNK'), + equals(EventType.reasoningMessageChunk)); + expect(EventType.fromString('REASONING_END'), + equals(EventType.reasoningEnd)); + expect(EventType.fromString('REASONING_ENCRYPTED_VALUE'), + equals(EventType.reasoningEncryptedValue)); }); test('fromString throws ArgumentError for invalid value', () { @@ -99,6 +173,10 @@ void main() { expect(EventType.values, contains(EventType.runStarted)); expect(EventType.values, contains(EventType.runFinished)); expect(EventType.values, contains(EventType.stateSnapshot)); + expect(EventType.values, contains(EventType.activitySnapshot)); + expect(EventType.values, contains(EventType.activityDelta)); + expect(EventType.values, contains(EventType.reasoningStart)); + expect(EventType.values, contains(EventType.reasoningEncryptedValue)); }); test('enum values are unique', () { @@ -146,7 +224,10 @@ void main() { test('enum supports index property', () { expect(EventType.textMessageStart.index, equals(0)); - expect(EventType.stepFinished.index, equals(EventType.values.length - 1)); + expect( + EventType.reasoningEncryptedValue.index, + equals(EventType.values.length - 1), + ); }); test('enum name property returns correct name', () { @@ -190,10 +271,14 @@ void main() { test('thinking events are grouped correctly', () { final thinkingEvents = [ EventType.thinkingStart, + // ignore: deprecated_member_use_from_same_package EventType.thinkingContent, EventType.thinkingEnd, + // ignore: deprecated_member_use_from_same_package EventType.thinkingTextMessageStart, + // ignore: deprecated_member_use_from_same_package EventType.thinkingTextMessageContent, + // ignore: deprecated_member_use_from_same_package EventType.thinkingTextMessageEnd, ]; @@ -202,6 +287,33 @@ void main() { } }); + test('activity events are grouped correctly', () { + final activityEvents = [ + EventType.activitySnapshot, + EventType.activityDelta, + ]; + + for (final event in activityEvents) { + expect(event.value, contains('ACTIVITY')); + } + }); + + test('reasoning events are grouped correctly', () { + final reasoningEvents = [ + EventType.reasoningStart, + EventType.reasoningMessageStart, + EventType.reasoningMessageContent, + EventType.reasoningMessageEnd, + EventType.reasoningMessageChunk, + EventType.reasoningEnd, + EventType.reasoningEncryptedValue, + ]; + + for (final event in reasoningEvents) { + expect(event.value, contains('REASONING')); + } + }); + test('tool call events are grouped correctly', () { final toolEvents = [ EventType.toolCallStart, @@ -249,4 +361,4 @@ void main() { }); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/fixtures/events.json b/sdks/community/dart/test/fixtures/events.json index 700c30d0b2..1e6de91dce 100644 --- a/sdks/community/dart/test/fixtures/events.json +++ b/sdks/community/dart/test/fixtures/events.json @@ -172,6 +172,21 @@ "role": "tool", "content": "72°F and sunny", "toolCallId": "call_01" + }, + { + "id": "msg_09", + "role": "user", + "content": [ + { "type": "text", "text": "Describe this image" }, + { + "type": "image", + "source": { + "type": "url", + "value": "https://example.com/image.png", + "mimeType": "image/png" + } + } + ] } ] }, @@ -195,6 +210,49 @@ "runId": "run_04" } ], + "messages_snapshot_activity_reasoning": [ + { + "type": "RUN_STARTED", + "threadId": "thread_04b", + "runId": "run_04b" + }, + { + "type": "MESSAGES_SNAPSHOT", + "messages": [ + { + "id": "msg_a1", + "role": "user", + "content": "Help me index this directory." + }, + { + "id": "act_a1", + "role": "activity", + "activityType": "task.run", + "content": { + "title": "Indexing files", + "progress": 0.5 + } + }, + { + "id": "rsn_a1", + "role": "reasoning", + "content": "Considering the file types to skip.", + "encryptedValue": "ZW5jcnlwdGVkLXJlYXNvbmluZw==" + }, + { + "id": "msg_a2", + "role": "assistant", + "content": "Indexing started.", + "encryptedValue": "ZW5jcnlwdGVkLWFzc2lzdGFudA==" + } + ] + }, + { + "type": "RUN_FINISHED", + "threadId": "thread_04b", + "runId": "run_04b" + } + ], "multiple_runs": [ { "type": "RUN_STARTED", @@ -437,5 +495,112 @@ "threadId": "thread_11", "runId": "run_12" } + ], + "activity_events": [ + { + "type": "RUN_STARTED", + "threadId": "thread_12", + "runId": "run_13" + }, + { + "type": "ACTIVITY_SNAPSHOT", + "messageId": "act_01", + "activityType": "task.run", + "content": { + "title": "Indexing files", + "progress": 0.0, + "items": [] + }, + "replace": true + }, + { + "type": "ACTIVITY_DELTA", + "messageId": "act_01", + "activityType": "task.run", + "patch": [ + {"op": "replace", "path": "/progress", "value": 0.5}, + {"op": "add", "path": "/items/-", "value": "/foo.dart"} + ] + }, + { + "type": "ACTIVITY_DELTA", + "messageId": "act_01", + "activityType": "task.run", + "patch": [ + {"op": "replace", "path": "/progress", "value": 1.0} + ] + }, + { + "type": "RUN_FINISHED", + "threadId": "thread_12", + "runId": "run_13" + } + ], + "reasoning_events": [ + { + "type": "RUN_STARTED", + "threadId": "thread_13", + "runId": "run_14" + }, + { + "type": "REASONING_START", + "messageId": "rsn_01" + }, + { + "type": "REASONING_MESSAGE_START", + "messageId": "rsn_01", + "role": "reasoning" + }, + { + "type": "REASONING_MESSAGE_CONTENT", + "messageId": "rsn_01", + "delta": "Analyzing the request..." + }, + { + "type": "REASONING_MESSAGE_CHUNK", + "messageId": "rsn_01", + "delta": " considering options." + }, + { + "type": "REASONING_MESSAGE_END", + "messageId": "rsn_01" + }, + { + "type": "REASONING_ENCRYPTED_VALUE", + "subtype": "message", + "entityId": "rsn_01", + "encryptedValue": "ZW5jcnlwdGVkLXBheWxvYWQ=" + }, + { + "type": "REASONING_END", + "messageId": "rsn_01" + }, + { + "type": "RUN_FINISHED", + "threadId": "thread_13", + "runId": "run_14" + } + ], + "thinking_text_message_legacy": [ + { + "type": "RUN_STARTED", + "threadId": "thread_14", + "runId": "run_15" + }, + { + "type": "THINKING_TEXT_MESSAGE_START" + }, + { + "type": "THINKING_TEXT_MESSAGE_CONTENT", + "delta": "Let me think..." + }, + { + "type": "THINKING_TEXT_MESSAGE_END" + }, + { + "type": "RUN_FINISHED", + "threadId": "thread_14", + "runId": "run_15" + } ] } \ No newline at end of file diff --git a/sdks/community/dart/test/integration/dojo_new_events_decode_test.dart b/sdks/community/dart/test/integration/dojo_new_events_decode_test.dart new file mode 100644 index 0000000000..d60067952a --- /dev/null +++ b/sdks/community/dart/test/integration/dojo_new_events_decode_test.dart @@ -0,0 +1,762 @@ +/// Full `BaseEvent.fromJson` / `EventStreamAdapter.adaptJsonToEvents` coverage +/// for the 9 new event types added on branch `fix-missing-event-types`. +/// +/// This file does NOT require the live dojo — it is pure decoder logic using +/// synthesized JSON payloads so we catch decoder regressions independently of +/// server support. +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:ag_ui/src/client/errors.dart'; +import 'package:ag_ui/src/encoder/decoder.dart'; +import 'package:ag_ui/src/encoder/stream_adapter.dart'; +import 'package:ag_ui/src/events/events.dart'; +import 'package:ag_ui/src/sse/sse_message.dart'; +import 'package:test/test.dart'; + +// --------------------------------------------------------------------------- +// Helper: decode → toJson → decode and assert both instances agree on +// a set of spot-checked field values. Returns the re-decoded event. +// --------------------------------------------------------------------------- +T _roundTrip( + EventDecoder decoder, + Map json, + void Function(T first, T second) check, +) { + final first = decoder.decodeJson(json) as T; + final roundTripped = decoder.decodeJson(first.toJson()) as T; + check(first, roundTripped); + return roundTripped; +} + +void main() { + late EventDecoder decoder; + late EventStreamAdapter adapter; + + setUp(() { + decoder = const EventDecoder(); + adapter = EventStreamAdapter(); + }); + + // ========================================================================= + // 1. ACTIVITY_SNAPSHOT + // ========================================================================= + group('ACTIVITY_SNAPSHOT', () { + test('direct decode — camelCase payload', () { + final event = decoder.decodeJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'act-001', + 'activityType': 'task.run', + 'content': {'title': 'Hello', 'progress': 0.25}, + 'replace': false, + }); + expect(event, isA()); + final e = event as ActivitySnapshotEvent; + expect(e.messageId, equals('act-001')); + expect(e.activityType, equals('task.run')); + expect((e.content! as Map)['title'], equals('Hello')); + expect(e.replace, isFalse); + }); + + test('snake_case parity', () { + final event = decoder.decodeJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'message_id': 'act-002', + 'activity_type': 'task.run', + 'content': null, + }); + expect(event, isA()); + final e = event as ActivitySnapshotEvent; + expect(e.messageId, equals('act-002')); + expect(e.activityType, equals('task.run')); + // default replace is true when omitted + expect(e.replace, isTrue); + }); + + test('round-trip via toJson', () { + _roundTrip( + decoder, + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'act-003', + 'activityType': 'progress', + 'content': {'done': true}, + 'replace': false, + }, + (a, b) { + expect(b.messageId, equals(a.messageId)); + expect(b.activityType, equals(a.activityType)); + expect(b.replace, equals(a.replace)); + expect((b.content! as Map)['done'], isTrue); + }, + ); + }); + + test('replace defaults to true when absent and omitted from toJson', () { + // replace == true is the default: toJson should omit the field. + final event = decoder.decodeJson({ + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'act-004', + 'activityType': 'status', + 'content': 'ok', + }) as ActivitySnapshotEvent; + expect(event.replace, isTrue); + final json = event.toJson(); + expect( + json.containsKey('replace'), + isFalse, + reason: 'replace==true should be omitted from wire output', + ); + }); + }); + + // ========================================================================= + // 2. ACTIVITY_DELTA + // ========================================================================= + group('ACTIVITY_DELTA', () { + test('direct decode — camelCase payload', () { + final event = decoder.decodeJson({ + 'type': 'ACTIVITY_DELTA', + 'messageId': 'act-001', + 'activityType': 'task.run', + 'patch': [ + {'op': 'replace', 'path': '/progress', 'value': 0.5}, + ], + }); + expect(event, isA()); + final e = event as ActivityDeltaEvent; + expect(e.messageId, equals('act-001')); + expect(e.activityType, equals('task.run')); + expect(e.patch.length, equals(1)); + expect(e.patch[0]['op'], equals('replace')); + }); + + test('snake_case parity', () { + final event = decoder.decodeJson({ + 'type': 'ACTIVITY_DELTA', + 'message_id': 'act-002', + 'activity_type': 'task.run', + 'patch': [], + }); + expect(event, isA()); + final e = event as ActivityDeltaEvent; + expect(e.messageId, equals('act-002')); + expect(e.patch, isEmpty); + }); + + test('round-trip via toJson', () { + _roundTrip( + decoder, + { + 'type': 'ACTIVITY_DELTA', + 'messageId': 'act-003', + 'activityType': 'progress', + 'patch': [ + {'op': 'add', 'path': '/items/-', 'value': 42}, + {'op': 'remove', 'path': '/stale'}, + ], + }, + (a, b) { + expect(b.messageId, equals(a.messageId)); + expect(b.activityType, equals(a.activityType)); + expect(b.patch.length, equals(2)); + expect(b.patch[0]['op'], equals('add')); + expect(b.patch[1]['op'], equals('remove')); + }, + ); + }); + }); + + // ========================================================================= + // 3. REASONING_START + // ========================================================================= + group('REASONING_START', () { + test('direct decode — camelCase payload', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_START', + 'messageId': 'rsn-001', + }); + expect(event, isA()); + expect((event as ReasoningStartEvent).messageId, equals('rsn-001')); + }); + + test('snake_case parity', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_START', + 'message_id': 'rsn-002', + }); + expect(event, isA()); + expect((event as ReasoningStartEvent).messageId, equals('rsn-002')); + }); + + test('round-trip via toJson', () { + _roundTrip( + decoder, + {'type': 'REASONING_START', 'messageId': 'rsn-003'}, + (a, b) => expect(b.messageId, equals(a.messageId)), + ); + }); + }); + + // ========================================================================= + // 4. REASONING_MESSAGE_START + // ========================================================================= + group('REASONING_MESSAGE_START', () { + test('direct decode — camelCase payload', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'rsn-msg-001', + 'role': 'reasoning', + }); + expect(event, isA()); + final e = event as ReasoningMessageStartEvent; + expect(e.messageId, equals('rsn-msg-001')); + expect(e.role, equals(ReasoningMessageRole.reasoning)); + }); + + test('snake_case parity', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_START', + 'message_id': 'rsn-msg-002', + 'role': 'reasoning', + }); + expect(event, isA()); + expect( + (event as ReasoningMessageStartEvent).messageId, + equals('rsn-msg-002'), + ); + }); + + test('round-trip via toJson', () { + _roundTrip( + decoder, + { + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'rsn-msg-003', + 'role': 'reasoning', + }, + (a, b) { + expect(b.messageId, equals(a.messageId)); + expect(b.role, equals(a.role)); + }, + ); + }); + + test('unknown role falls back to reasoning (forward-compat)', () { + // Per the fromJson contract: an unknown role string defaults to + // ReasoningMessageRole.reasoning so a new server-side role value + // does not break the stream. + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'rsn-msg-004', + 'role': 'future-unknown-role', + }) as ReasoningMessageStartEvent; + expect(event.role, equals(ReasoningMessageRole.reasoning)); + }); + }); + + // ========================================================================= + // 5. REASONING_MESSAGE_CONTENT + // ========================================================================= + group('REASONING_MESSAGE_CONTENT', () { + test('direct decode — camelCase payload', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'rsn-msg-001', + 'delta': 'thinking hard...', + }); + expect(event, isA()); + final e = event as ReasoningMessageContentEvent; + expect(e.messageId, equals('rsn-msg-001')); + expect(e.delta, equals('thinking hard...')); + }); + + test('snake_case parity', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'message_id': 'rsn-msg-002', + 'delta': 'still thinking', + }); + expect(event, isA()); + expect( + (event as ReasoningMessageContentEvent).delta, + equals('still thinking'), + ); + }); + + test('empty delta is accepted (canonical parity)', () { + // TS/Python schemas allow empty string for delta. + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'rsn-msg-003', + 'delta': '', + }); + expect(event, isA()); + expect((event as ReasoningMessageContentEvent).delta, equals('')); + }); + + test('round-trip via toJson', () { + _roundTrip( + decoder, + { + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'rsn-msg-004', + 'delta': 'conclusion reached', + }, + (a, b) { + expect(b.messageId, equals(a.messageId)); + expect(b.delta, equals(a.delta)); + }, + ); + }); + }); + + // ========================================================================= + // 6. REASONING_MESSAGE_END + // ========================================================================= + group('REASONING_MESSAGE_END', () { + test('direct decode — camelCase payload', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_END', + 'messageId': 'rsn-msg-001', + }); + expect(event, isA()); + expect( + (event as ReasoningMessageEndEvent).messageId, + equals('rsn-msg-001'), + ); + }); + + test('snake_case parity', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_END', + 'message_id': 'rsn-msg-002', + }); + expect(event, isA()); + expect( + (event as ReasoningMessageEndEvent).messageId, + equals('rsn-msg-002'), + ); + }); + + test('round-trip via toJson', () { + _roundTrip( + decoder, + {'type': 'REASONING_MESSAGE_END', 'messageId': 'rsn-msg-003'}, + (a, b) => expect(b.messageId, equals(a.messageId)), + ); + }); + }); + + // ========================================================================= + // 7. REASONING_MESSAGE_CHUNK + // ========================================================================= + group('REASONING_MESSAGE_CHUNK', () { + test('direct decode — with optional fields', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_CHUNK', + 'messageId': 'rsn-msg-001', + 'delta': 'chunk content', + }); + expect(event, isA()); + final e = event as ReasoningMessageChunkEvent; + expect(e.messageId, equals('rsn-msg-001')); + expect(e.delta, equals('chunk content')); + }); + + test('snake_case parity', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_CHUNK', + 'message_id': 'rsn-msg-002', + 'delta': 'partial', + }); + expect(event, isA()); + expect( + (event as ReasoningMessageChunkEvent).messageId, + equals('rsn-msg-002'), + ); + }); + + test('all fields optional — bare type decodes successfully', () { + // All fields on ReasoningMessageChunkEvent are optional. + final event = decoder.decodeJson({'type': 'REASONING_MESSAGE_CHUNK'}); + expect(event, isA()); + final e = event as ReasoningMessageChunkEvent; + expect(e.messageId, isNull); + expect(e.delta, isNull); + }); + + test('round-trip via toJson — optional fields preserved when set', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_CHUNK', + 'messageId': 'rsn-msg-003', + 'delta': 'step', + }) as ReasoningMessageChunkEvent; + final json = event.toJson(); + expect(json['messageId'], equals('rsn-msg-003')); + expect(json['delta'], equals('step')); + + final reDecoded = + decoder.decodeJson(json) as ReasoningMessageChunkEvent; + expect(reDecoded.messageId, equals(event.messageId)); + expect(reDecoded.delta, equals(event.delta)); + }); + + test('round-trip — null fields absent from toJson output', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_CHUNK', + }) as ReasoningMessageChunkEvent; + final json = event.toJson(); + expect(json.containsKey('messageId'), isFalse); + expect(json.containsKey('delta'), isFalse); + }); + }); + + // ========================================================================= + // 8. REASONING_END + // ========================================================================= + group('REASONING_END', () { + test('direct decode — camelCase payload', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_END', + 'messageId': 'rsn-001', + }); + expect(event, isA()); + expect((event as ReasoningEndEvent).messageId, equals('rsn-001')); + }); + + test('snake_case parity', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_END', + 'message_id': 'rsn-002', + }); + expect(event, isA()); + expect((event as ReasoningEndEvent).messageId, equals('rsn-002')); + }); + + test('round-trip via toJson', () { + _roundTrip( + decoder, + {'type': 'REASONING_END', 'messageId': 'rsn-003'}, + (a, b) => expect(b.messageId, equals(a.messageId)), + ); + }); + }); + + // ========================================================================= + // 9. REASONING_ENCRYPTED_VALUE + // ========================================================================= + group('REASONING_ENCRYPTED_VALUE', () { + test('direct decode — tool-call subtype', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'tool-call', + 'entityId': 'tc-001', + 'encryptedValue': 'cipher-payload-abc', + }); + expect(event, isA()); + final e = event as ReasoningEncryptedValueEvent; + expect(e.subtype, equals(ReasoningEncryptedValueSubtype.toolCall)); + expect(e.entityId, equals('tc-001')); + expect(e.encryptedValue, equals('cipher-payload-abc')); + // rawEvent must be null — cipher-safety invariant. + expect(e.rawEvent, isNull); + }); + + test('direct decode — message subtype', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'msg-001', + 'encryptedValue': 'cipher-payload-xyz', + }); + expect(event, isA()); + final e = event as ReasoningEncryptedValueEvent; + expect(e.subtype, equals(ReasoningEncryptedValueSubtype.message)); + }); + + test('snake_case parity — entity_id / encrypted_value', () { + final event = decoder.decodeJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'tool-call', + 'entity_id': 'tc-002', + 'encrypted_value': 'cipher-snake', + }); + expect(event, isA()); + final e = event as ReasoningEncryptedValueEvent; + expect(e.entityId, equals('tc-002')); + expect(e.encryptedValue, equals('cipher-snake')); + }); + + test('round-trip via toJson', () { + _roundTrip( + decoder, + { + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'msg-rt', + 'encryptedValue': 'rt-cipher', + }, + (a, b) { + expect(b.subtype, equals(a.subtype)); + expect(b.entityId, equals(a.entityId)); + expect(b.encryptedValue, equals(a.encryptedValue)); + expect(b.rawEvent, isNull); + }, + ); + }); + + test('unknown subtype surfaces as DecodingError', () { + expect( + () => decoder.decodeJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'unknown-future-subtype', + 'entityId': 'e', + 'encryptedValue': 'v', + }), + throwsA(isA()), + ); + }); + }); + + // ========================================================================= + // Stream-adapter path: REASONING_MESSAGE_START / CONTENT / END triplet + // ========================================================================= + group('Stream-adapter path — REASONING_MESSAGE_* triplet', () { + test('adaptJsonToEvents — single event', () { + final events = adapter.adaptJsonToEvents({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'rsn-sa-001', + 'role': 'reasoning', + }); + expect(events.length, equals(1)); + expect(events.first, isA()); + }); + + test('adaptJsonToEvents — list of three events for same messageId', () { + final events = adapter.adaptJsonToEvents([ + { + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'rsn-sa-002', + 'role': 'reasoning', + }, + { + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'rsn-sa-002', + 'delta': 'step 1', + }, + { + 'type': 'REASONING_MESSAGE_END', + 'messageId': 'rsn-sa-002', + }, + ]); + + expect(events.length, equals(3)); + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + + expect( + (events[0] as ReasoningMessageStartEvent).messageId, + equals('rsn-sa-002'), + ); + expect( + (events[1] as ReasoningMessageContentEvent).delta, + equals('step 1'), + ); + expect( + (events[2] as ReasoningMessageEndEvent).messageId, + equals('rsn-sa-002'), + ); + }); + + test( + 'fromSseStream — REASONING_MESSAGE_* triplet flows through correctly', + () async { + final controller = StreamController(); + final stream = adapter.fromSseStream(controller.stream); + final events = []; + final sub = stream.listen(events.add); + + controller + ..add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_START', + 'messageId': 'rsn-sse-001', + }), + )) + ..add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'rsn-sse-001', + 'role': 'reasoning', + }), + )) + ..add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'rsn-sse-001', + 'delta': 'I am thinking...', + }), + )) + ..add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'rsn-sse-001', + 'delta': ' Done.', + }), + )) + ..add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_MESSAGE_END', + 'messageId': 'rsn-sse-001', + }), + )) + ..add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_END', + 'messageId': 'rsn-sse-001', + }), + )); + + await controller.close(); + await sub.cancel(); + + expect(events.length, equals(6)); + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + expect( + (events[2] as ReasoningMessageContentEvent).delta, + equals('I am thinking...'), + ); + expect(events[3], isA()); + expect( + (events[3] as ReasoningMessageContentEvent).delta, + equals(' Done.'), + ); + expect(events[4], isA()); + expect(events[5], isA()); + + // All phase-level events share the same messageId. + expect( + (events[0] as ReasoningStartEvent).messageId, + equals('rsn-sse-001'), + ); + expect( + (events[5] as ReasoningEndEvent).messageId, + equals('rsn-sse-001'), + ); + }, + ); + + test( + 'fromSseStream — REASONING_ENCRYPTED_VALUE with unknown subtype is ' + 'skipped under skipInvalidEvents', + () async { + final controller = StreamController(); + final stream = adapter.fromSseStream( + controller.stream, + skipInvalidEvents: true, + ); + final events = []; + final sub = stream.listen(events.add); + + controller + ..add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_START', + 'messageId': 'rsn-sse-002', + }), + )) + ..add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'unknown-future', + 'entityId': 'e', + 'encryptedValue': 'v', + }), + )) + ..add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_END', + 'messageId': 'rsn-sse-002', + }), + )); + + await controller.close(); + await sub.cancel(); + + // The malformed encrypted value is skipped; surrounding events flow. + expect(events.length, equals(2)); + expect(events[0], isA()); + expect(events[1], isA()); + }, + ); + + test('groupRelatedEvents groups REASONING_MESSAGE_* by messageId', () async { + final source = Stream.fromIterable([ + const ReasoningStartEvent(messageId: 'rsn-g-001'), + const ReasoningMessageStartEvent(messageId: 'rsn-g-001'), + const ReasoningMessageContentEvent( + messageId: 'rsn-g-001', + delta: 'thinking...', + ), + const ReasoningMessageEndEvent(messageId: 'rsn-g-001'), + const ReasoningEndEvent(messageId: 'rsn-g-001'), + ]); + + final groups = + await EventStreamAdapter.groupRelatedEvents(source).toList(); + + // REASONING_START and REASONING_END → singletons (phase-level). + // REASONING_MESSAGE_START/CONTENT/END → one grouped list. + expect(groups.length, equals(3)); + expect(groups[0].length, equals(1)); + expect(groups[0].first, isA()); + expect(groups[1].length, equals(3)); + expect(groups[1][0], isA()); + expect(groups[1][1], isA()); + expect(groups[1][2], isA()); + expect(groups[2].length, equals(1)); + expect(groups[2].first, isA()); + }); + }); + + // ========================================================================= + // Cross-type: ACTIVITY_SNAPSHOT + ACTIVITY_DELTA via adaptJsonToEvents list + // ========================================================================= + group('adaptJsonToEvents — ACTIVITY_* mixed list', () { + test('decodes activity snapshot and delta in sequence', () { + final events = adapter.adaptJsonToEvents([ + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'act-list-001', + 'activityType': 'task.run', + 'content': {'status': 'started'}, + }, + { + 'type': 'ACTIVITY_DELTA', + 'messageId': 'act-list-001', + 'activityType': 'task.run', + 'patch': [ + {'op': 'replace', 'path': '/status', 'value': 'done'}, + ], + }, + ]); + + expect(events.length, equals(2)); + expect(events[0], isA()); + expect(events[1], isA()); + + final snap = events[0] as ActivitySnapshotEvent; + expect(snap.messageId, equals('act-list-001')); + expect((snap.content! as Map)['status'], equals('started')); + + final delta = events[1] as ActivityDeltaEvent; + expect(delta.patch[0]['value'], equals('done')); + }); + }); +} diff --git a/sdks/community/dart/test/integration/dojo_resilience_test.dart b/sdks/community/dart/test/integration/dojo_resilience_test.dart new file mode 100644 index 0000000000..1f1615cade --- /dev/null +++ b/sdks/community/dart/test/integration/dojo_resilience_test.dart @@ -0,0 +1,622 @@ +/// Live integration test exercising error, cancellation, and validation paths +/// in the AG-UI Dart SDK client against the running dojo container. +/// +/// Assumes the dojo is reachable at `AGUI_DOJO_BASE_URL` (preferred) or +/// `AGUI_BASE_URL`, falling back to `http://127.0.0.1:18000`. +/// +/// Set `AGUI_SKIP_DOJO=1` to skip when the dojo is not running. +/// +/// Tests #4, #5, #6, #7 do NOT require the dojo and run unconditionally +/// (or use an in-process server for test #7). +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:ag_ui/ag_ui.dart'; +import 'package:test/test.dart'; + +// --------------------------------------------------------------------------- +// Shared helpers (same env-var precedence as dojo_smoke_test.dart) +// --------------------------------------------------------------------------- + +String _dojoBaseUrl() { + return Platform.environment['AGUI_DOJO_BASE_URL'] ?? + Platform.environment['AGUI_BASE_URL'] ?? + 'http://127.0.0.1:18000'; +} + +bool _skipDojo() { + return Platform.environment['AGUI_SKIP_DOJO'] == '1'; +} + +/// Lightweight reachability probe so the tests surface a clear skip reason +/// when the container is not running, instead of a generic socket error. +Future _dojoReachable(String baseUrl) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 2); + try { + final uri = Uri.parse('$baseUrl/openapi.json'); + final req = await client.getUrl(uri); + final resp = await req.close().timeout(const Duration(seconds: 3)); + await resp.drain(); + return resp.statusCode == 200; + } on Object { + return false; + } finally { + client.close(force: true); + } +} + +/// Build a minimal valid input for the agentic_chat endpoint. +SimpleRunAgentInput _chatInput({String? runId, String? threadId}) { + return SimpleRunAgentInput( + threadId: threadId ?? 'resilience-thread', + runId: + runId ?? 'resilience-run-${DateTime.now().millisecondsSinceEpoch}', + messages: [UserMessage(id: 'u1', content: 'hello resilience test')], + tools: const [], + context: const [], + state: const {}, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('AG-UI Dojo resilience', () { + final baseUrl = _dojoBaseUrl(); + late bool reachable; + + setUpAll(() async { + reachable = !_skipDojo() && await _dojoReachable(baseUrl); + if (!reachable) { + // ignore: avoid_print + print('[dojo_resilience_test] Dojo not reachable at $baseUrl — ' + 'dojo-dependent tests will be skipped. Start the container with:\n' + ' docker run --rm -p 18000:8000 ag-ui-protocol/ag-ui-server'); + } + }); + + // ----------------------------------------------------------------------- + // Test #1 — 404 on unknown endpoint (requires dojo) + // ----------------------------------------------------------------------- + test('404 on unknown endpoint surfaces TransportError(statusCode: 404)', + () async { + if (!reachable) { + markTestSkipped('dojo not reachable'); + return; + } + + final client = AgUiClient( + config: AgUiClientConfig(baseUrl: baseUrl), + ); + try { + final stream = client.runAgent( + 'agent/does_not_exist_xyz', + _chatInput(), + ); + + Object? caught; + try { + await for (final _ in stream) { + // consume any events (should not arrive) + } + } on TransportError catch (e) { + caught = e; + } + + expect(caught, isA(), + reason: 'unknown endpoint must surface as TransportError'); + expect( + (caught! as TransportError).statusCode, + equals(404), + reason: 'HTTP 404 must be preserved on the error', + ); + } finally { + await client.close(); + } + }); + + // ----------------------------------------------------------------------- + // Test #2 — 422 on malformed payload + // ----------------------------------------------------------------------- + // + // TODO: Reaching a server-side 422 through the public typed client API is + // not currently possible without bypassing the Dart type system. + // + // • The server returns 422 for missing `messages` field — but the Dart + // client's `SimpleRunAgentInput.toJson()` always emits `messages: []` + // even when `input.messages` is null, so that path is unreachable. + // • The server returns 422 for invalid `role` values — but all Dart + // `Message` subtypes hardcode their roles via sealed class constructors, + // making an invalid wire role impossible to construct through the API. + // • Sending `parameters: "not-a-schema"` for a tool returns 200 (the + // dojo's Pydantic model treats `parameters` as `Any`). + // + // A 422 test would require either a custom `http.Client` mock or direct + // HTTP access that bypasses `AgUiClient` — both are out of scope for this + // integration-against-the-real-client test file. This case is intentionally + // omitted rather than contorted. + + // ----------------------------------------------------------------------- + // Test #3 — Mid-stream cancellation (requires dojo) + // ----------------------------------------------------------------------- + // + // Behavioral notes (from reading _runAgentInternal + _sendWithCancellation): + // + // Once the HTTP response is received and SSE streaming begins, the + // `_sendWithCancellation` completer is already resolved. Calling + // cancelToken.cancel() after that point does NOT abort the already-streaming + // SSE response — the stream may complete naturally (not with CancellationError). + // Using client.cancelRun() additionally closes the SseClient, which is more + // effective at terminating the SSE stream. + test( + 'mid-stream cancelRun terminates stream; second run with same runId succeeds', + () async { + if (!reachable) { + markTestSkipped('dojo not reachable'); + return; + } + + final runId = + 'resilience-cancel-${DateTime.now().millisecondsSinceEpoch}'; + final client = AgUiClient( + config: AgUiClientConfig(baseUrl: baseUrl), + ); + + try { + final cancelToken = CancelToken(); + final input = SimpleRunAgentInput( + threadId: 'resilience-cancel-thread', + runId: runId, + messages: [ + UserMessage(id: 'u1', content: 'countdown please'), + ], + tools: const [], + context: const [], + state: const {}, + ); + + final stream = client.runAgenticChat(input, cancelToken: cancelToken); + + // Wait for the first event, then cancel. + bool receivedFirstEvent = false; + + try { + await for (final _ in stream.timeout(const Duration(seconds: 20))) { + if (!receivedFirstEvent) { + receivedFirstEvent = true; + // Cancel after receiving the first event. + cancelToken.cancel(); + await client.cancelRun(runId); + // Break out — after cancelRun the SseClient is closed and the + // stream will terminate (cleanly or with an error) on its own. + break; + } + } + } on CancellationError { + // Expected — cancel may surface as CancellationError. + } on Object { + // Other termination (SocketException, etc.) after close is fine. + } + + expect(receivedFirstEvent, isTrue, + reason: 'must receive at least one event before cancellation'); + + // After cancelRun + the async generator's finally block runs, + // the _requestTokens entry for this runId is cleaned up. Wait briefly + // to ensure the finally block has executed. + await Future.delayed(const Duration(milliseconds: 100)); + + // Confirm the token is gone by issuing a second run with the SAME + // runId on a fresh client. A 'unique-in-flight' ValidationError here + // would indicate the cleanup did not happen as expected. + final client2 = AgUiClient( + config: AgUiClientConfig(baseUrl: baseUrl), + ); + try { + final input2 = SimpleRunAgentInput( + threadId: 'resilience-cancel-thread-2', + runId: runId, + messages: [ + UserMessage(id: 'u1', content: 'reuse runId after cancel'), + ], + tools: const [], + context: const [], + state: const {}, + ); + final events2 = []; + await for (final event + in client2 + .runAgenticChat(input2) + .timeout(const Duration(seconds: 30))) { + events2.add(event); + } + // The run should complete without error. + expect(events2, isNotEmpty, + reason: 'second run with reused runId must succeed after cancel'); + } finally { + await client2.close(); + } + } finally { + await client.close(); + } + }); + + // ----------------------------------------------------------------------- + // Test #4 — Duplicate runId rejection (no dojo needed) + // ----------------------------------------------------------------------- + // + // Behavioral note: the duplicate-runId check lives inside _runAgentInternal, + // which is an async* function. The ValidationError surfaces via the stream + // (not as a synchronous throw from runAgent()), so we must subscribe to the + // stream to observe the error. The spec calls this "synchronous" because + // putIfAbsent runs in the first microtask of the generator, but from the + // caller's perspective it arrives via the stream, not via a thrown exception + // from the runAgent() call site. + // + // Implementation note: we use an in-process HttpServer that accepts but + // never writes, so stream1 stays genuinely in-flight long enough for + // stream2's subscription to see the duplicate runId. If we pointed at a + // port that immediately refuses, stream1 could complete (and deregister) + // before stream2 even starts — making the duplicate undetectable. + test( + 'duplicate runId emits ValidationError(constraint: unique-in-flight)', + () async { + // Bind a server that accepts but never responds — keeps stream1 in-flight. + final hangServer = await HttpServer.bind( + InternetAddress.loopbackIPv4, + 0, + ); + final hangPort = hangServer.port; + // Do not await this subscription — the server intentionally never + // writes, so the listen callback never completes. + final hangSub = hangServer.listen((req) async { + await req.drain(); + // Never write — keeps the connection open indefinitely. + }); + + final client = AgUiClient( + config: AgUiClientConfig( + baseUrl: 'http://127.0.0.1:$hangPort', + // Short timeout so stream1 doesn't keep the test alive too long + // if cleanup fails; long enough to stay in-flight during the test. + requestTimeout: const Duration(seconds: 5), + maxRetries: 0, + ), + ); + + // sub1 is declared outside the try block so the finally can cancel it. + StreamSubscription? sub1; + + try { + final sharedRunId = + 'dup-run-${DateTime.now().millisecondsSinceEpoch}'; + + final input1 = SimpleRunAgentInput( + threadId: 'dup-thread-1', + runId: sharedRunId, + messages: [UserMessage(id: 'u1', content: 'first')], + ); + final input2 = SimpleRunAgentInput( + threadId: 'dup-thread-2', + runId: sharedRunId, + messages: [UserMessage(id: 'u1', content: 'second')], + ); + + // Collect the stream2 result via a Completer so we can cancel the + // hanging server BEFORE awaiting the result (avoiding a 5s timeout + // from stream1 blocking the test). + final resultCompleter = Completer(); + + // Subscribe to stream1 — the generator registers the runId before + // awaiting the HTTP response (no await before putIfAbsent). + sub1 = client.runAgent('agentic_chat', input1).listen( + (_) {}, + onError: (_) {}, // expected: swallow stream1 errors + cancelOnError: true, + ); + + // Yield 200ms so stream1's async* body runs past _validateRunAgentInput + // and _requestTokens.putIfAbsent, and the HTTP connection is established, + // before stream2 subscribes. The hanging server's accept() callback must + // also have run (via the event loop) to confirm stream1 is truly in-flight. + await Future.delayed(const Duration(milliseconds: 200)); + + // Subscribe to stream2 — the hanging server keeps stream1 in-flight, + // so its runId is still in _requestTokens. Run stream2 in the + // background so we can proceed to cleanup. + client.runAgent('agentic_chat', input2).listen( + (_) { + if (!resultCompleter.isCompleted) { + resultCompleter.complete(null); // no event expected + } + }, + onError: (Object e) { + if (!resultCompleter.isCompleted) { + resultCompleter.complete(e); + } + }, + onDone: () { + if (!resultCompleter.isCompleted) { + resultCompleter.complete(null); + } + }, + cancelOnError: true, + ); + + // Wait for stream2 to produce its error (ValidationError expected). + // Stream2 should error immediately — no HTTP is made when duplicate + // is detected — so this completes in a single microtask. + final result = await resultCompleter.future + .timeout(const Duration(seconds: 2)); + + expect(result, isA(), + reason: + 'second stream with same runId must emit ValidationError'); + final ve = result! as ValidationError; + expect(ve.constraint, equals('unique-in-flight'), + reason: 'constraint must be unique-in-flight'); + expect(ve.field, equals('runId'), + reason: 'field must be runId'); + } finally { + // Force-close the server FIRST so stream1's pending HTTP connection + // fails (connection reset), which lets the generator exit its await + // quickly instead of waiting for the timeout. + await hangSub.cancel(); + await hangServer.close(force: true); + // close() cancels all in-flight requests (including stream1). The + // CancellationError from stream1 is handled by sub1's onError handler + // (which swallows it), preventing unhandled-exception reports. + await client.close(); + // Allow the event loop to drain stream1 callbacks before the test + // exits, so sub1's onError fires before sub1 is considered abandoned. + await Future.delayed(const Duration(milliseconds: 50)); + // Cancel sub1 only after the delay — by now its onError has handled + // any CancellationError. Using the variable ensures the linter knows + // sub1 is used (it also suppresses "cancel_subscriptions" warnings). + await sub1?.cancel(); + } + }); + + // ----------------------------------------------------------------------- + // Test #5 — Client-side input validation (no dojo needed) + // ----------------------------------------------------------------------- + + group('client-side input validation (no HTTP calls)', () { + late AgUiClient client; + + setUp(() { + // Point at a non-existent server so any accidental HTTP attempt is + // visible as a connection failure (should never reach). + client = AgUiClient( + config: AgUiClientConfig( + baseUrl: 'http://127.0.0.1:19999', + requestTimeout: const Duration(seconds: 2), + ), + ); + }); + + tearDown(() async => client.close()); + + // Helper: assert that a stream emits exactly one ValidationError with + // the given field and constraint, then terminates. + Future expectValidationError( + Stream stream, { + required String field, + required String constraint, + }) async { + Object? caught; + try { + await for (final _ in stream) { + fail('expected ValidationError, got an event'); + } + } on ValidationError catch (e) { + caught = e; + } + expect(caught, isA(), + reason: + 'expected ValidationError(field: $field, constraint: $constraint)'); + final ve = caught! as ValidationError; + expect(ve.field, equals(field), + reason: 'field mismatch: got "${ve.field}", want "$field"'); + expect(ve.constraint, equals(constraint), + reason: + 'constraint mismatch: got "${ve.constraint}", want "$constraint"'); + } + + test('empty endpoint string throws ValidationError synchronously', () { + // requireNonEmpty(endpoint) fires synchronously in runAgent() before + // the async generator starts — so this is a synchronous throw. + expect( + () => client.runAgent('', _chatInput()), + throwsA( + isA() + .having((e) => e.field, 'field', 'endpoint') + .having((e) => e.constraint, 'constraint', 'non-empty'), + ), + ); + }); + + test('empty message.id emits ValidationError(field: message.id)', + () async { + // Empty id on a UserMessage — the Dart type system requires a String + // but does not prohibit empty strings; the validator catches it. + final stream = client.runAgent( + 'agentic_chat', + SimpleRunAgentInput( + messages: [UserMessage(id: '', content: 'hi')], + ), + ); + await expectValidationError( + stream, + field: 'message.id', + constraint: 'non-empty', + ); + }); + + test( + 'duplicate message.id emits ValidationError' + '(field: message.id, constraint: unique-id)', + () async { + final stream = client.runAgent( + 'agentic_chat', + SimpleRunAgentInput( + messages: [ + UserMessage(id: 'same-id', content: 'first'), + UserMessage(id: 'same-id', content: 'second'), + ], + ), + ); + await expectValidationError( + stream, + field: 'message.id', + constraint: 'unique-id', + ); + }); + + test( + 'duplicate toolCall.id within AssistantMessage emits ValidationError', + () async { + final stream = client.runAgent( + 'agentic_chat', + SimpleRunAgentInput( + messages: [ + const AssistantMessage( + id: 'a1', + content: 'calling tools', + toolCalls: [ + ToolCall( + id: 'tc-dup', + function: FunctionCall(name: 'foo', arguments: '{}'), + ), + ToolCall( + id: 'tc-dup', + function: FunctionCall(name: 'bar', arguments: '{}'), + ), + ], + ), + ], + ), + ); + await expectValidationError( + stream, + field: 'toolCall.id', + constraint: 'unique-within-message', + ); + }); + + test('oversized runId (>100 chars) emits ValidationError', () async { + final longRunId = 'r' * 101; + final stream = client.runAgent( + 'agentic_chat', + SimpleRunAgentInput( + runId: longRunId, + messages: [UserMessage(id: 'u1', content: 'hi')], + ), + ); + await expectValidationError( + stream, + field: 'runId', + constraint: 'max-length-100', + ); + }); + + test('oversized threadId (>100 chars) emits ValidationError', () async { + final longThreadId = 't' * 101; + final stream = client.runAgent( + 'agentic_chat', + SimpleRunAgentInput( + threadId: longThreadId, + messages: [UserMessage(id: 'u1', content: 'hi')], + ), + ); + await expectValidationError( + stream, + field: 'threadId', + constraint: 'max-length-100', + ); + }); + }); + + // ----------------------------------------------------------------------- + // Test #6 — client.close() is idempotent (no dojo needed) + // ----------------------------------------------------------------------- + test('client.close() is idempotent — second call must not throw', + () async { + final client = AgUiClient( + config: AgUiClientConfig(baseUrl: 'http://127.0.0.1:19999'), + ); + await client.close(); + // Second close must not throw. If the underlying http.Client.close() + // throws on a double-close (e.g. IOClient "HTTP client already closed"), + // this test surfaces that as a real bug rather than swallowing it. + await client.close(); + }); + + // ----------------------------------------------------------------------- + // Test #7 — Timeout via in-process slow server (no dojo needed) + // ----------------------------------------------------------------------- + test('requestTimeout fires AGUITimeoutError against a silent server', + () async { + // Bind a real TCP server that accepts connections but never writes. + final server = await HttpServer.bind( + InternetAddress.loopbackIPv4, + 0, // OS-assigned port + ); + final port = server.port; + + // Accept connections but never write a response — simulates a hung + // server. We subscribe on the side; the subscription is not awaited + // because the stream never completes until force-closed. + final serverSub = server.listen((request) async { + // Drain the request body but do not send any response. + await request.drain(); + // The connection stays open until the server is force-closed. + }); + + final slowClient = AgUiClient( + config: AgUiClientConfig( + baseUrl: 'http://127.0.0.1:$port', + requestTimeout: const Duration(milliseconds: 200), + // Disable retries — we want exactly one timeout attempt. + maxRetries: 0, + ), + ); + + try { + Object? caught; + try { + await for (final _ in slowClient.runAgenticChat(_chatInput())) { + // no events expected + } + } on AGUITimeoutError catch (e) { + caught = e; + } + + expect(caught, isA(), + reason: 'silent server must trigger AGUITimeoutError'); + + final te = caught! as AGUITimeoutError; + expect( + te.timeout, + equals(const Duration(milliseconds: 200)), + reason: 'timeout field must match the configured requestTimeout', + ); + // operation is the full endpoint URL (set by _runAgentInternal's + // TimeoutException handler: `operation: endpoint`) + expect( + te.operation, + contains('127.0.0.1:$port'), + reason: 'operation must contain the server address', + ); + } finally { + await serverSub.cancel(); + await server.close(force: true); + await slowClient.close(); + } + }); + }); +} diff --git a/sdks/community/dart/test/integration/dojo_smoke_test.dart b/sdks/community/dart/test/integration/dojo_smoke_test.dart new file mode 100644 index 0000000000..fbcc030314 --- /dev/null +++ b/sdks/community/dart/test/integration/dojo_smoke_test.dart @@ -0,0 +1,691 @@ +/// Live integration test that exercises the Dart SDK against a running +/// ag-ui dojo (the Python `server-starter-all-features` container). +/// +/// Assumes the dojo is reachable at `AGUI_DOJO_BASE_URL` (preferred) or +/// `AGUI_BASE_URL`, falling back to `http://127.0.0.1:18000` — the port the +/// docker image `ag-ui-protocol/ag-ui-server` is typically mapped to when +/// running locally (`docker run -p 18000:8000 ag-ui-protocol/ag-ui-server`). +/// +/// Set `AGUI_SKIP_DOJO=1` to skip when the dojo is not running. +library; + +import 'dart:io'; + +import 'package:ag_ui/ag_ui.dart'; +import 'package:test/test.dart'; + +String _dojoBaseUrl() { + return Platform.environment['AGUI_DOJO_BASE_URL'] ?? + Platform.environment['AGUI_BASE_URL'] ?? + 'http://127.0.0.1:18000'; +} + +bool _skipDojo() { + return Platform.environment['AGUI_SKIP_DOJO'] == '1'; +} + +/// Lightweight reachability probe so the test surfaces a clear skip reason +/// when the container is not running, instead of a generic socket error +/// buried inside a `TransportError` stack. +Future _dojoReachable(String baseUrl) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 2); + try { + final uri = Uri.parse('$baseUrl/openapi.json'); + final req = await client.getUrl(uri); + final resp = await req.close().timeout(const Duration(seconds: 3)); + await resp.drain(); + return resp.statusCode == 200; + } on Object { + return false; + } finally { + client.close(force: true); + } +} + +/// Assert shared lifecycle invariants that must hold for every dojo endpoint: +/// - Exactly one RUN_STARTED (first event). +/// - Exactly one RUN_FINISHED (last event). +/// - No event follows RUN_FINISHED. +/// - No RUN_ERROR on the happy path. +/// - threadId / runId echo back correctly on both bookend events. +void _assertLifecycleInvariants( + List events, + SimpleRunAgentInput input, +) { + expect(events, isNotEmpty, reason: 'expected at least one event'); + + // Exactly one RUN_STARTED, exactly one RUN_FINISHED. + final starts = events.whereType().toList(growable: false); + final finishes = events.whereType().toList(growable: false); + expect(starts, hasLength(1), reason: 'exactly one RUN_STARTED'); + expect(finishes, hasLength(1), reason: 'exactly one RUN_FINISHED'); + + // First event is RUN_STARTED. + expect(events.first, isA(), + reason: 'first event must be RUN_STARTED'); + + // RUN_FINISHED is the last event — no events follow it. + expect(events.last, isA(), + reason: 'last event must be RUN_FINISHED'); + expect(events.indexOf(finishes.single), equals(events.length - 1), + reason: 'RUN_FINISHED must be at the last index'); + + // No RUN_ERROR on the happy path. + expect(events.whereType().toList(growable: false), isEmpty, + reason: 'no RUN_ERROR on the happy path'); + + // threadId / runId echo back on RUN_STARTED. + final started = starts.single; + expect(started.threadId, equals(input.threadId), + reason: 'RUN_STARTED.threadId must echo the request threadId'); + expect(started.runId, equals(input.runId), + reason: 'RUN_STARTED.runId must echo the request runId'); + + // threadId / runId echo back on RUN_FINISHED. + final finished = finishes.single; + expect(finished.threadId, equals(input.threadId), + reason: 'RUN_FINISHED.threadId must echo the request threadId'); + expect(finished.runId, equals(input.runId), + reason: 'RUN_FINISHED.runId must echo the request runId'); +} + +/// Assert that TOOL_CALL_* events (if any) form clean, non-interleaved groups +/// per toolCallId where each group is exactly START → ARGS* → END. +/// +/// This is called for endpoints that use streaming tool calls rather than +/// MESSAGES_SNAPSHOT to deliver tool call data. +/// +/// Probed endpoints that use this pattern: +/// - /predictive_state_updates: two sequential TOOL_CALL groups +/// - /human_in_the_loop: one TOOL_CALL group +void _assertToolCallGrouping(List events) { + final toolEvents = events + .where((e) => + e is ToolCallStartEvent || + e is ToolCallArgsEvent || + e is ToolCallEndEvent) + .toList(growable: false); + + if (toolEvents.isEmpty) { + return; // no tool calls in this stream — nothing to assert + } + + // Build groups keyed by toolCallId. + final groups = >{}; + for (final e in toolEvents) { + final String id; + if (e is ToolCallStartEvent) { + id = e.toolCallId; + } else if (e is ToolCallArgsEvent) { + id = e.toolCallId; + } else if (e is ToolCallEndEvent) { + id = e.toolCallId; + } else { + continue; + } + (groups[id] ??= []).add(e); + } + + expect(groups, isNotEmpty, reason: 'expected at least one TOOL_CALL group'); + + for (final entry in groups.entries) { + final id = entry.key; + final group = entry.value; + + expect(group.first, isA(), + reason: 'group $id must start with TOOL_CALL_START'); + expect(group.last, isA(), + reason: 'group $id must end with TOOL_CALL_END'); + // Every event between start and end must be TOOL_CALL_ARGS. + for (var i = 1; i < group.length - 1; i++) { + expect(group[i], isA(), + reason: 'group $id event at index $i must be TOOL_CALL_ARGS'); + } + } + + // Assert no cross-id interleaving: scan the raw tool-call event list and + // verify that once we've seen an END for an id, we never see a START/ARGS + // for that id again (which would mean interleaved groups). + final closed = {}; + String? openId; + for (final e in toolEvents) { + if (e is ToolCallStartEvent) { + expect(closed, isNot(contains(e.toolCallId)), + reason: 'toolCallId ${e.toolCallId} reused after END — interleave detected'); + openId = e.toolCallId; + } else if (e is ToolCallArgsEvent) { + expect(e.toolCallId, equals(openId), + reason: 'TOOL_CALL_ARGS for ${e.toolCallId} while $openId is open — interleave detected'); + } else if (e is ToolCallEndEvent) { + closed.add(e.toolCallId); + openId = null; + } + } +} + +void main() { + group('AG-UI Dojo smoke (docker)', () { + final baseUrl = _dojoBaseUrl(); + late bool reachable; + + setUpAll(() async { + reachable = !_skipDojo() && await _dojoReachable(baseUrl); + if (!reachable) { + // ignore: avoid_print + print('[dojo_smoke_test] Dojo not reachable at $baseUrl — ' + 'tests will be skipped. Start the container with:\n' + ' docker run --rm -p 18000:8000 ag-ui-protocol/ag-ui-server'); + } + }); + + // ========================================================================= + // agentic_chat + // ========================================================================= + + test('agentic_chat streams RUN_STARTED → text events → RUN_FINISHED', + () async { + if (!reachable) { + return markTestSkipped('dojo not reachable'); + } + + final client = AgUiClient( + config: AgUiClientConfig(baseUrl: baseUrl), + ); + + try { + final input = SimpleRunAgentInput( + threadId: 'dojo-smoke-thread', + runId: 'dojo-smoke-run-${DateTime.now().millisecondsSinceEpoch}', + messages: [ + UserMessage(id: 'u1', content: 'hello dojo'), + ], + tools: const [], + context: const [], + state: const {}, + ); + + final events = []; + await for (final event in client + .runAgenticChat(input) + .timeout(const Duration(seconds: 30))) { + events.add(event); + } + + // --- Lifecycle invariants (strengthened) --- + _assertLifecycleInvariants(events, input); + + // --- Text message sub-protocol --- + final starts = + events.whereType().toList(growable: false); + final ends = + events.whereType().toList(growable: false); + final contents = + events.whereType().toList(growable: false); + + expect(starts, hasLength(1), + reason: 'expect a single TEXT_MESSAGE_START'); + expect(ends, hasLength(1), reason: 'expect a single TEXT_MESSAGE_END'); + expect(contents, isNotEmpty, + reason: 'expect at least one TEXT_MESSAGE_CONTENT delta'); + + // All three text events share the same messageId. + final messageId = starts.single.messageId; + for (final c in contents) { + expect(c.messageId, equals(messageId), + reason: 'all content deltas share the start messageId'); + } + expect(ends.single.messageId, equals(messageId)); + + // Accumulated body contains the agent's signature countdown. + final body = contents.map((c) => c.delta).join(); + expect(body, contains('counting down'), + reason: 'agentic_chat agent emits a countdown intro'); + } finally { + await client.close(); + } + }, skip: _skipDojo() ? 'AGUI_SKIP_DOJO=1' : null); + + // ========================================================================= + // tool_based_generative_ui + // ========================================================================= + + test('tool_based_generative_ui emits MESSAGES_SNAPSHOT with a tool call', + () async { + if (!reachable) { + return markTestSkipped('dojo not reachable'); + } + + final client = AgUiClient( + config: AgUiClientConfig(baseUrl: baseUrl), + ); + + try { + final input = SimpleRunAgentInput( + threadId: 'dojo-smoke-tool-thread', + runId: + 'dojo-smoke-tool-run-${DateTime.now().millisecondsSinceEpoch}', + messages: [ + UserMessage(id: 'u1', content: 'draw a haiku tree'), + ], + tools: const [ + Tool( + name: 'generate_haiku', + description: 'Generate a haiku', + parameters: { + 'type': 'object', + 'properties': { + 'japanese': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'english': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + }, + ), + ], + context: const [], + state: const {}, + ); + + final events = []; + await for (final event in client + .runToolBasedGenerativeUi(input) + .timeout(const Duration(seconds: 30))) { + events.add(event); + } + + // --- Lifecycle invariants (strengthened) --- + _assertLifecycleInvariants(events, input); + + // --- MESSAGES_SNAPSHOT path --- + // Observed via curl: this endpoint emits only MESSAGES_SNAPSHOT (not + // TOOL_CALL_* streaming events). The snapshot carries the assistant + // message with toolCalls populated. TOOL_CALL_* events are not present + // in this image's response for this endpoint. + final snapshots = events + .whereType() + .toList(growable: false); + expect(snapshots, isNotEmpty, + reason: 'tool_based_generative_ui emits a MESSAGES_SNAPSHOT'); + + final assistant = snapshots.last.messages + .whereType() + .toList(growable: false); + expect(assistant, isNotEmpty, + reason: 'snapshot should contain at least one AssistantMessage'); + + final toolCalls = assistant + .expand((m) => m.toolCalls ?? const []) + .toList(growable: false); + expect(toolCalls, isNotEmpty, + reason: 'assistant message should carry tool calls'); + expect( + toolCalls.map((tc) => tc.function.name), + contains('generate_haiku'), + ); + + // TOOL_CALL_* streaming events are NOT emitted by this endpoint in the + // live dojo image — the tool call arrives only via MESSAGES_SNAPSHOT. + // If they appear in a future image, the grouping assertion below will + // still pass (it is a no-op when toolEvents is empty). + _assertToolCallGrouping(events); + } finally { + await client.close(); + } + }, skip: _skipDojo() ? 'AGUI_SKIP_DOJO=1' : null); + + // ========================================================================= + // agentic_generative_ui + // ========================================================================= + + test( + 'agentic_generative_ui emits STATE_SNAPSHOT then STATE_DELTA patch ops', + () async { + if (!reachable) { + return markTestSkipped('dojo not reachable'); + } + + // Observed via curl: + // RUN_STARTED + // STATE_SNAPSHOT (steps[0..9] all "pending") + // STATE_DELTA × 10 (each: op=replace, path=/steps//status, value=completed) + // STATE_SNAPSHOT (steps[0..9] all "completed") + // RUN_FINISHED + // + // NOTE: This endpoint does NOT emit STEP_STARTED / STEP_FINISHED events + // in the current dojo image. Step-pairing assertions are theoretical and + // are intentionally omitted here. The endpoint uses STATE_SNAPSHOT / + // STATE_DELTA to represent progress instead. + + final client = AgUiClient( + config: AgUiClientConfig(baseUrl: baseUrl), + ); + + try { + final input = SimpleRunAgentInput( + threadId: 'dojo-smoke-agui-thread', + runId: + 'dojo-smoke-agui-run-${DateTime.now().millisecondsSinceEpoch}', + messages: [ + UserMessage(id: 'u1', content: 'show me progress'), + ], + tools: const [], + context: const [], + state: const {}, + ); + + final events = []; + await for (final event in client + .runAgenticGenerativeUi(input) + .timeout(const Duration(seconds: 30))) { + events.add(event); + } + + // --- Lifecycle invariants --- + _assertLifecycleInvariants(events, input); + + // --- At least one STATE_SNAPSHOT --- + final snapshots = + events.whereType().toList(growable: false); + expect(snapshots, isNotEmpty, + reason: 'agentic_generative_ui must emit at least one STATE_SNAPSHOT'); + + // The snapshot payload is a Map (not null, not a scalar). + for (final s in snapshots) { + expect(s.snapshot, isA>(), + reason: 'STATE_SNAPSHOT.snapshot must be a Map'); + } + + // --- STATE_DELTA patch ops (if present) --- + final deltas = + events.whereType().toList(growable: false); + // The live image emits 10 deltas. Asserting > 0 pins the observed behavior. + expect(deltas, isNotEmpty, + reason: 'agentic_generative_ui must emit at least one STATE_DELTA'); + + const validOps = {'add', 'replace', 'remove', 'move', 'copy', 'test'}; + for (final d in deltas) { + expect(d.delta, isNotEmpty, + reason: 'each STATE_DELTA must carry at least one patch op'); + for (final op in d.delta) { + expect(op['op'], isA(), + reason: 'each patch op must have a string "op" field'); + expect(validOps, contains(op['op']), + reason: 'op "${op['op']}" must be a valid RFC 6902 operation'); + expect(op['path'], isA(), + reason: 'each patch op must have a string "path" field'); + expect(op['path'] as String, isNotEmpty, + reason: 'patch op "path" must be non-empty'); + } + } + } finally { + await client.close(); + } + }, skip: _skipDojo() ? 'AGUI_SKIP_DOJO=1' : null); + + // ========================================================================= + // shared_state + // ========================================================================= + + test('shared_state emits STATE_SNAPSHOT with a Map payload', () async { + if (!reachable) { + return markTestSkipped('dojo not reachable'); + } + + // Observed via curl: + // RUN_STARTED + // STATE_SNAPSHOT (recipe object) + // RUN_FINISHED + // + // NOTE: No STATE_DELTA events were emitted by this endpoint in the live + // dojo image. The delta assertions below are conditional — if deltas appear + // in a future image they are validated, but zero deltas is also acceptable. + + final client = AgUiClient( + config: AgUiClientConfig(baseUrl: baseUrl), + ); + + try { + final input = SimpleRunAgentInput( + threadId: 'dojo-smoke-shared-thread', + runId: + 'dojo-smoke-shared-run-${DateTime.now().millisecondsSinceEpoch}', + messages: [ + UserMessage(id: 'u1', content: 'show me state'), + ], + tools: const [], + context: const [], + state: const {}, + ); + + final events = []; + await for (final event in client + .runSharedState(input) + .timeout(const Duration(seconds: 30))) { + events.add(event); + } + + // --- Lifecycle invariants --- + _assertLifecycleInvariants(events, input); + + // --- At least one STATE_SNAPSHOT carrying a Map --- + final snapshots = + events.whereType().toList(growable: false); + expect(snapshots, isNotEmpty, + reason: 'shared_state must emit at least one STATE_SNAPSHOT'); + for (final s in snapshots) { + expect(s.snapshot, isA>(), + reason: 'STATE_SNAPSHOT.snapshot must be a Map'); + } + + // --- Conditional STATE_DELTA validation --- + // The live image emits zero deltas for this endpoint; the loop below + // is a no-op but correctly validates any deltas that appear in future images. + final deltas = + events.whereType().toList(growable: false); + const validOps = {'add', 'replace', 'remove', 'move', 'copy', 'test'}; + for (final d in deltas) { + expect(d.delta, isNotEmpty, + reason: 'each STATE_DELTA must carry at least one patch op'); + for (final op in d.delta) { + expect(op['op'], isA(), + reason: 'each patch op must have a string "op" field'); + expect(validOps, contains(op['op']), + reason: 'op "${op['op']}" must be a valid RFC 6902 operation'); + expect(op['path'], isA(), + reason: 'each patch op must have a string "path" field'); + expect(op['path'] as String, isNotEmpty, + reason: 'patch op "path" must be non-empty'); + } + } + } finally { + await client.close(); + } + }, skip: _skipDojo() ? 'AGUI_SKIP_DOJO=1' : null); + + // ========================================================================= + // predictive_state_updates + // ========================================================================= + + test('predictive_state_updates emits TOOL_CALL groups with no interleaving', + () async { + if (!reachable) { + return markTestSkipped('dojo not reachable'); + } + + // Observed via curl: + // RUN_STARTED + // CUSTOM(name=PredictState, value=[...]) + // TOOL_CALL_START (write_document_local) + // TOOL_CALL_ARGS × N + // TOOL_CALL_END + // TOOL_CALL_START (confirm_changes) + // TOOL_CALL_ARGS × 1 + // TOOL_CALL_END + // RUN_FINISHED + // + // NOTE: This endpoint does NOT emit STATE_SNAPSHOT / STATE_DELTA events in + // the live dojo image — contrary to the "same state-event assertions" hint + // in the review spec. It uses streaming TOOL_CALL_* events instead, with a + // CUSTOM(PredictState) event as a predictor hint. State event assertions + // are intentionally absent here; tool-call grouping is asserted instead. + + final client = AgUiClient( + config: AgUiClientConfig(baseUrl: baseUrl), + ); + + try { + final input = SimpleRunAgentInput( + threadId: 'dojo-smoke-predict-thread', + runId: + 'dojo-smoke-predict-run-${DateTime.now().millisecondsSinceEpoch}', + messages: [ + UserMessage(id: 'u1', content: 'write me a story'), + ], + tools: const [], + context: const [], + state: const {}, + ); + + final events = []; + await for (final event in client + .runPredictiveStateUpdates(input) + .timeout(const Duration(seconds: 30))) { + events.add(event); + } + + // --- Lifecycle invariants --- + _assertLifecycleInvariants(events, input); + + // --- CUSTOM(PredictState) hint event --- + final customEvents = + events.whereType().toList(growable: false); + expect(customEvents, isNotEmpty, + reason: 'predictive_state_updates must emit at least one CUSTOM event'); + + // --- TOOL_CALL grouping: START → ARGS* → END, no cross-id interleave --- + final toolStarts = + events.whereType().toList(growable: false); + expect(toolStarts, isNotEmpty, + reason: 'predictive_state_updates must emit at least one TOOL_CALL_START'); + _assertToolCallGrouping(events); + } finally { + await client.close(); + } + }, skip: _skipDojo() ? 'AGUI_SKIP_DOJO=1' : null); + + // ========================================================================= + // human_in_the_loop + // ========================================================================= + + test('human_in_the_loop emits TOOL_CALL group for task-step generation', + () async { + if (!reachable) { + return markTestSkipped('dojo not reachable'); + } + + // Observed via curl: + // RUN_STARTED + // TOOL_CALL_START (generate_task_steps) + // TOOL_CALL_ARGS × N + // TOOL_CALL_END + // RUN_FINISHED + // + // NOTE: This endpoint does NOT emit MESSAGES_SNAPSHOT in the live dojo image. + // The tool call arrives via TOOL_CALL_* streaming events, not via a + // MESSAGES_SNAPSHOT AssistantMessage. The spec's "AssistantMessage with + // non-empty toolCalls in final MessagesSnapshotEvent" is the fallback path; + // the live image takes the TOOL_CALL_* streaming path instead. + + final client = AgUiClient( + config: AgUiClientConfig(baseUrl: baseUrl), + ); + + try { + final input = SimpleRunAgentInput( + threadId: 'dojo-smoke-hitl-thread', + runId: + 'dojo-smoke-hitl-run-${DateTime.now().millisecondsSinceEpoch}', + messages: [ + UserMessage(id: 'u1', content: 'hello'), + ], + tools: const [], + context: const [], + state: const {}, + ); + + final events = []; + await for (final event in client + .runHumanInTheLoop(input) + .timeout(const Duration(seconds: 30))) { + events.add(event); + } + + // --- Lifecycle invariants --- + _assertLifecycleInvariants(events, input); + + // --- Exactly one TOOL_CALL group (generate_task_steps) --- + final toolStarts = + events.whereType().toList(growable: false); + expect(toolStarts, isNotEmpty, + reason: 'human_in_the_loop must emit at least one TOOL_CALL_START'); + + // All tool calls must name generate_task_steps. + for (final ts in toolStarts) { + expect(ts.toolCallName, equals('generate_task_steps'), + reason: 'human_in_the_loop tool must be generate_task_steps'); + } + + // --- TOOL_CALL grouping: START → ARGS* → END, no cross-id interleave --- + _assertToolCallGrouping(events); + + // MESSAGES_SNAPSHOT is not emitted by this endpoint in the live image. + // If it appears in a future image, this defensive check ensures it would + // still carry an AssistantMessage with tool calls. + final snapshots = + events.whereType().toList(growable: false); + if (snapshots.isNotEmpty) { + final assistantWithTools = snapshots.last.messages + .whereType() + .where((m) => m.toolCalls != null && m.toolCalls!.isNotEmpty) + .toList(growable: false); + expect(assistantWithTools, isNotEmpty, + reason: 'if MESSAGES_SNAPSHOT is present, at least one ' + 'AssistantMessage must carry tool calls'); + } + } finally { + await client.close(); + } + }, skip: _skipDojo() ? 'AGUI_SKIP_DOJO=1' : null); + + // ========================================================================= + // EventType round-trip guard (no dojo required) + // ========================================================================= + + test('all #1018 event types are registered in EventType.fromString', () { + // The dojo server (8-month-old image) does not yet emit ACTIVITY_* + // or REASONING_* events, but this branch adds them to the Dart enum. + // This subtest guards against regressions in the parser surface + // independently of the live stream content above. + const wireNames = [ + 'ACTIVITY_SNAPSHOT', + 'ACTIVITY_DELTA', + 'REASONING_START', + 'REASONING_MESSAGE_START', + 'REASONING_MESSAGE_CONTENT', + 'REASONING_MESSAGE_END', + 'REASONING_MESSAGE_CHUNK', + 'REASONING_END', + 'REASONING_ENCRYPTED_VALUE', + ]; + for (final name in wireNames) { + final parsed = EventType.fromString(name); + expect(parsed.value, equals(name), + reason: '$name must round-trip through EventType.fromString'); + } + }); + }); +} diff --git a/sdks/community/dart/test/integration/event_decoding_integration_test.dart b/sdks/community/dart/test/integration/event_decoding_integration_test.dart index 4ca2158059..76f7ab6f30 100644 --- a/sdks/community/dart/test/integration/event_decoding_integration_test.dart +++ b/sdks/community/dart/test/integration/event_decoding_integration_test.dart @@ -31,7 +31,7 @@ void main() { final event = decoder.decodeJson(pythonJson); expect(event, isA()); - + final runEvent = event as RunStartedEvent; expect(runEvent.threadId, equals('thread-123')); expect(runEvent.runId, equals('run-456')); @@ -79,22 +79,23 @@ void main() { final event = decoder.decodeJson(pythonJson); expect(event, isA()); - + final messagesEvent = event as MessagesSnapshotEvent; expect(messagesEvent.messages.length, equals(3)); - + // Check user message expect(messagesEvent.messages[0].role, equals(MessageRole.user)); expect(messagesEvent.messages[0].content, equals('Generate a haiku')); - + // Check assistant message with tool calls expect(messagesEvent.messages[1].role, equals(MessageRole.assistant)); final assistantMsg = messagesEvent.messages[1] as AssistantMessage; expect(assistantMsg.toolCalls, isNotNull); expect(assistantMsg.toolCalls!.length, equals(1)); expect(assistantMsg.toolCalls![0].id, equals('tool-call-1')); - expect(assistantMsg.toolCalls![0].function.name, equals('generate_haiku')); - + expect( + assistantMsg.toolCalls![0].function.name, equals('generate_haiku')); + // Check tool message expect(messagesEvent.messages[2].role, equals(MessageRole.tool)); final toolMsg = messagesEvent.messages[2] as ToolMessage; @@ -111,29 +112,180 @@ void main() { final event = decoder.decodeJson(pythonJson); expect(event, isA()); - + final runEvent = event as RunFinishedEvent; expect(runEvent.threadId, equals('thread-123')); expect(runEvent.runId, equals('run-456')); }); + + test('decodes ACTIVITY_SNAPSHOT event from Python server format', () { + final pythonJson = { + 'type': 'ACTIVITY_SNAPSHOT', + 'message_id': 'act_001', + 'activity_type': 'task.run', + 'content': {'title': 'Hello', 'progress': 0.25}, + 'replace': false, + }; + + final event = decoder.decodeJson(pythonJson); + expect(event, isA()); + + final activity = event as ActivitySnapshotEvent; + expect(activity.messageId, equals('act_001')); + expect(activity.activityType, equals('task.run')); + expect((activity.content as Map)['title'], equals('Hello')); + expect(activity.replace, isFalse); + }); + + test('decodes ACTIVITY_DELTA event from Python server format', () { + final pythonJson = { + 'type': 'ACTIVITY_DELTA', + 'message_id': 'act_001', + 'activity_type': 'task.run', + 'patch': [ + {'op': 'replace', 'path': '/progress', 'value': 0.5}, + ], + }; + + final event = decoder.decodeJson(pythonJson); + expect(event, isA()); + + final delta = event as ActivityDeltaEvent; + expect(delta.messageId, equals('act_001')); + expect(delta.activityType, equals('task.run')); + expect(delta.patch.length, equals(1)); + }); + + test('decodes TEXT_MESSAGE_* events from Python server format', () { + final start = decoder.decodeJson({ + 'type': 'TEXT_MESSAGE_START', + 'message_id': 'm1', + 'role': 'assistant', + }); + expect(start, isA()); + expect((start as TextMessageStartEvent).messageId, 'm1'); + + final content = decoder.decodeJson({ + 'type': 'TEXT_MESSAGE_CONTENT', + 'message_id': 'm1', + 'delta': 'hello', + }); + expect(content, isA()); + + final end = decoder.decodeJson({ + 'type': 'TEXT_MESSAGE_END', + 'message_id': 'm1', + }); + expect(end, isA()); + }); + + test('decodes TOOL_CALL_* events from Python server format', () { + final start = decoder.decodeJson({ + 'type': 'TOOL_CALL_START', + 'tool_call_id': 'c1', + 'tool_call_name': 'search', + 'parent_message_id': 'm1', + }); + expect(start, isA()); + expect((start as ToolCallStartEvent).toolCallId, 'c1'); + expect(start.toolCallName, 'search'); + expect(start.parentMessageId, 'm1'); + + final args = decoder.decodeJson({ + 'type': 'TOOL_CALL_ARGS', + 'tool_call_id': 'c1', + 'delta': '{"q":"x"}', + }); + expect(args, isA()); + + final end = decoder.decodeJson({ + 'type': 'TOOL_CALL_END', + 'tool_call_id': 'c1', + }); + expect(end, isA()); + + final result = decoder.decodeJson({ + 'type': 'TOOL_CALL_RESULT', + 'message_id': 'm2', + 'tool_call_id': 'c1', + 'content': 'ok', + 'role': 'tool', + }); + expect(result, isA()); + final r = result as ToolCallResultEvent; + expect(r.messageId, 'm2'); + expect(r.toolCallId, 'c1'); + }); + + test('decodes REASONING_* events from Python server format', () { + final start = decoder.decodeJson({ + 'type': 'REASONING_START', + 'message_id': 'rsn_001', + }); + expect(start, isA()); + expect((start as ReasoningStartEvent).messageId, equals('rsn_001')); + + final messageStart = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_START', + 'message_id': 'rsn_001', + 'role': 'reasoning', + }); + expect(messageStart, isA()); + + final content = decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'message_id': 'rsn_001', + 'delta': 'thinking...', + }); + expect(content, isA()); + expect( + (content as ReasoningMessageContentEvent).delta, + equals('thinking...'), + ); + + final encrypted = decoder.decodeJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'tool-call', + 'entity_id': 'tc_001', + 'encrypted_value': 'cipher', + }); + expect(encrypted, isA()); + final encEvent = encrypted as ReasoningEncryptedValueEvent; + expect(encEvent.subtype, ReasoningEncryptedValueSubtype.toolCall); + expect(encEvent.entityId, equals('tc_001')); + expect(encEvent.encryptedValue, equals('cipher')); + }); }); group('TypeScript Dojo Events', () { test('decodes all text message lifecycle events', () { final events = [ - {'type': 'TEXT_MESSAGE_START', 'messageId': 'msg-1', 'role': 'assistant'}, - {'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'msg-1', 'delta': 'Hello '}, - {'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'msg-1', 'delta': 'world!'}, + { + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg-1', + 'role': 'assistant' + }, + { + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': 'msg-1', + 'delta': 'Hello ' + }, + { + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': 'msg-1', + 'delta': 'world!' + }, {'type': 'TEXT_MESSAGE_END', 'messageId': 'msg-1'}, ]; - final decodedEvents = events.map((json) => decoder.decodeJson(json)).toList(); - + final decodedEvents = + events.map((json) => decoder.decodeJson(json)).toList(); + expect(decodedEvents[0], isA()); expect(decodedEvents[1], isA()); expect(decodedEvents[2], isA()); expect(decodedEvents[3], isA()); - + // Verify content accumulation final content1 = (decodedEvents[1] as TextMessageContentEvent).delta; final content2 = (decodedEvents[2] as TextMessageContentEvent).delta; @@ -166,21 +318,22 @@ void main() { }, ]; - final decodedEvents = events.map((json) => decoder.decodeJson(json)).toList(); - + final decodedEvents = + events.map((json) => decoder.decodeJson(json)).toList(); + expect(decodedEvents[0], isA()); expect(decodedEvents[1], isA()); expect(decodedEvents[2], isA()); expect(decodedEvents[3], isA()); - + // Verify tool call details final startEvent = decodedEvents[0] as ToolCallStartEvent; expect(startEvent.toolCallName, equals('search')); expect(startEvent.parentMessageId, equals('msg-1')); - + final resultEvent = decodedEvents[3] as ToolCallResultEvent; expect(resultEvent.content, equals('Found 5 results')); - expect(resultEvent.role, equals('tool')); + expect(resultEvent.role, equals(ToolCallResultRole.tool)); }); test('decodes thinking events', () { @@ -192,12 +345,17 @@ void main() { {'type': 'THINKING_END'}, ]; - final decodedEvents = events.map((json) => decoder.decodeJson(json)).toList(); - + final decodedEvents = + events.map((json) => decoder.decodeJson(json)).toList(); + expect(decodedEvents[0], isA()); - expect((decodedEvents[0] as ThinkingStartEvent).title, equals('Planning approach')); + expect((decodedEvents[0] as ThinkingStartEvent).title, + equals('Planning approach')); + // ignore: deprecated_member_use_from_same_package expect(decodedEvents[1], isA()); + // ignore: deprecated_member_use_from_same_package expect(decodedEvents[2], isA()); + // ignore: deprecated_member_use_from_same_package expect(decodedEvents[3], isA()); expect(decodedEvents[4], isA()); }); @@ -240,12 +398,15 @@ void main() { {'type': 'STEP_FINISHED', 'stepName': 'Analyzing request'}, ]; - final decodedEvents = events.map((json) => decoder.decodeJson(json)).toList(); - + final decodedEvents = + events.map((json) => decoder.decodeJson(json)).toList(); + expect(decodedEvents[0], isA()); - expect((decodedEvents[0] as StepStartedEvent).stepName, equals('Analyzing request')); + expect((decodedEvents[0] as StepStartedEvent).stepName, + equals('Analyzing request')); expect(decodedEvents[1], isA()); - expect((decodedEvents[1] as StepFinishedEvent).stepName, equals('Analyzing request')); + expect((decodedEvents[1] as StepFinishedEvent).stepName, + equals('Analyzing request')); }); }); @@ -253,30 +414,40 @@ void main() { test('processes SSE stream with mixed events', () async { final sseController = StreamController(); final eventStream = adapter.fromSseStream(sseController.stream); - + final events = []; final subscription = eventStream.listen(events.add); - + // Simulate server stream sseController.add(SseMessage( - data: jsonEncode({'type': 'RUN_STARTED', 'thread_id': 't1', 'run_id': 'r1'}), + data: jsonEncode( + {'type': 'RUN_STARTED', 'thread_id': 't1', 'run_id': 'r1'}), )); sseController.add(SseMessage( - data: jsonEncode({'type': 'TEXT_MESSAGE_START', 'messageId': 'm1', 'role': 'assistant'}), + data: jsonEncode({ + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'm1', + 'role': 'assistant' + }), )); sseController.add(SseMessage( - data: jsonEncode({'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'm1', 'delta': 'Hello'}), + data: jsonEncode({ + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': 'm1', + 'delta': 'Hello' + }), )); sseController.add(SseMessage( data: jsonEncode({'type': 'TEXT_MESSAGE_END', 'messageId': 'm1'}), )); sseController.add(SseMessage( - data: jsonEncode({'type': 'RUN_FINISHED', 'thread_id': 't1', 'run_id': 'r1'}), + data: jsonEncode( + {'type': 'RUN_FINISHED', 'thread_id': 't1', 'run_id': 'r1'}), )); - + await sseController.close(); await subscription.cancel(); - + expect(events.length, equals(5)); expect(events.first, isA()); expect(events.last, isA()); @@ -290,38 +461,44 @@ void main() { skipInvalidEvents: true, onError: (error, stack) => errors.add(error), ); - + final events = []; final subscription = eventStream.listen(events.add); - + // Mix valid and invalid events sseController.add(SseMessage( - data: jsonEncode({'type': 'RUN_STARTED', 'thread_id': 't1', 'run_id': 'r1'}), + data: jsonEncode( + {'type': 'RUN_STARTED', 'thread_id': 't1', 'run_id': 'r1'}), )); sseController.add(SseMessage(data: 'not json')); // Invalid sseController.add(SseMessage( data: jsonEncode({'type': 'INVALID_TYPE'}), // Unknown type )); sseController.add(SseMessage( - data: jsonEncode({'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'm1', 'delta': ''}), // Invalid: empty delta + // Invalid: missing required `messageId`. (Empty `delta` is now + // accepted per canonical TS/Python parity, so it can no longer + // serve as the invalid-event trigger here.) + data: jsonEncode({'type': 'TEXT_MESSAGE_CONTENT', 'delta': 'x'}), )); sseController.add(SseMessage( - data: jsonEncode({'type': 'RUN_FINISHED', 'thread_id': 't1', 'run_id': 'r1'}), + data: jsonEncode( + {'type': 'RUN_FINISHED', 'thread_id': 't1', 'run_id': 'r1'}), )); - + await sseController.close(); await subscription.cancel(); - + // Should only get valid events expect(events.length, equals(2)); expect(events[0], isA()); expect(events[1], isA()); - + // Should have collected errors for invalid events expect(errors.length, equals(3)); expect(errors[0], isA()); expect(errors[1], isA()); - expect(errors[2], isA()); // Validation errors are wrapped in DecodingError + expect(errors[2], + isA()); // Validation errors are wrapped in DecodingError }); test('handles unknown fields for forward compatibility', () { @@ -336,11 +513,15 @@ void main() { final event = decoder.decodeJson(jsonWithExtra); expect(event, isA()); - + final textEvent = event as TextMessageStartEvent; expect(textEvent.messageId, equals('msg-1')); expect(textEvent.role, equals(TextMessageRole.assistant)); - // Unknown fields are preserved in rawEvent if needed + // Unknown top-level fields are tolerated and ignored — the SDK + // does NOT preserve them on `rawEvent` (only `json['rawEvent']` + // populates that field). Re-encoding via `toJson` will drop + // `futureField` / `metadata`. If forward-preserve becomes a + // requirement, see the `BaseEvent.fromJson` factory. }); test('validates required fields strictly', () { @@ -350,21 +531,245 @@ void main() { throwsA(isA()), ); - // Empty required field - validation error is wrapped in DecodingError + // Empty `messageId` (still a contract violation post-0.2.0 + // parity work — empty `delta` is now accepted to match + // canonical TS/Python schemas, but identifiers must be + // non-empty). Validation error is wrapped in DecodingError. expect( () => decoder.decodeJson({ 'type': 'TEXT_MESSAGE_CONTENT', - 'messageId': 'msg-1', - 'delta': '', // Empty delta not allowed + 'messageId': '', + 'delta': 'x', }), throwsA(isA()), ); - // Invalid event type + // Invalid event type — surfaces as DecodingError through the + // decoder boundary. The direct factory path (no decoder) sees + // an `AGUIValidationError` instead; see the companion test in + // `test/events/event_test.dart` ("should throw AGUIValidationError + // on invalid event type"). The two together pin down both seams. expect( () => decoder.decodeJson({'type': 'NOT_A_REAL_EVENT'}), throwsA(isA()), ); + + // The wrapped `DecodingError.field` must preserve the original + // failing field name from `AGUIValidationError`, not collapse to + // `'json'`. Pin the contract on at least one factory-side + // failure so a future refactor can't silently regress. + expect( + () => decoder.decodeJson({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'msg-1', + // role intentionally omitted — required since 0.2.0 + }), + throwsA( + isA().having((e) => e.field, 'field', 'role'), + ), + ); + + // TEXT_MESSAGE_END with empty messageId must fail at the + // decoder boundary, matching TEXT_MESSAGE_START / _CONTENT. + expect( + () => decoder.decodeJson({ + 'type': 'TEXT_MESSAGE_END', + 'messageId': '', + }), + throwsA(isA()), + ); + }); + + test( + 'EventDecoder.validate rejects empty required identifiers across ' + 'tool, run, step, activity, and reasoning events', () { + // These cases lock in the boundary contract documented on + // `EventDecoder.validate`: identifiers that pass the + // presence/type check in `fromJson` must still be rejected here + // when they arrive empty from the wire. Adding a new empty-id + // event class without a `validate` case will fail this test. + final emptyIdPayloads = >[ + {'type': 'TOOL_CALL_ARGS', 'toolCallId': '', 'delta': 'x'}, + // NOTE: empty `delta` on TOOL_CALL_ARGS is now accepted per + // canonical TS/Python parity; only empty `toolCallId` is + // still a contract violation. + {'type': 'TOOL_CALL_END', 'toolCallId': ''}, + { + 'type': 'TOOL_CALL_RESULT', + 'messageId': '', + 'toolCallId': 'c', + 'content': 'x', + }, + { + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'm', + 'toolCallId': '', + 'content': 'x', + }, + // NOTE: empty `content` on TOOL_CALL_RESULT is now accepted + // per canonical TS/Python parity. + {'type': 'RUN_FINISHED', 'threadId': '', 'runId': 'r'}, + {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': ''}, + {'type': 'RUN_ERROR', 'message': ''}, + {'type': 'STEP_STARTED', 'stepName': ''}, + {'type': 'STEP_FINISHED', 'stepName': ''}, + {'type': 'CUSTOM', 'name': '', 'value': 1}, + // Activity events — empty messageId or activityType. + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': '', + 'activityType': 't', + 'content': null, + }, + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'm', + 'activityType': '', + 'content': null, + }, + { + 'type': 'ACTIVITY_DELTA', + 'messageId': '', + 'activityType': 't', + 'patch': [], + }, + { + 'type': 'ACTIVITY_DELTA', + 'messageId': 'm', + 'activityType': '', + 'patch': [], + }, + // Reasoning events — empty messageId is still a contract + // violation. Empty `delta` on REASONING_MESSAGE_CONTENT is now + // accepted per canonical parity. Empty `entityId` / + // `encryptedValue` on REASONING_ENCRYPTED_VALUE are also + // accepted (canonical TS `z.string()` / Python `str` impose + // no minimum length); only the strict subtype discriminator + // remains. + {'type': 'REASONING_START', 'messageId': ''}, + { + 'type': 'REASONING_MESSAGE_START', + 'messageId': '', + 'role': 'reasoning', + }, + { + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': '', + 'delta': 'd', + }, + {'type': 'REASONING_MESSAGE_END', 'messageId': ''}, + {'type': 'REASONING_END', 'messageId': ''}, + ]; + + for (final payload in emptyIdPayloads) { + expect( + () => decoder.decodeJson(payload), + throwsA(isA()), + reason: 'expected DecodingError for $payload', + ); + } + }); + + test( + 'REASONING_ENCRYPTED_VALUE with unknown subtype surfaces as ' + 'DecodingError', () { + // The dartdoc on `ReasoningEncryptedValueEvent` and on + // `ReasoningEncryptedValueSubtype.fromString` documents that + // an unknown subtype value MUST fail decoding (mis-tagging an + // encrypted payload is worse than dropping it). This locks in + // the wire→DecodingError contract end-to-end. + expect( + () => decoder.decodeJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'future-mode', + 'entityId': 'e', + 'encryptedValue': 'v', + }), + throwsA(isA()), + ); + }); + + test( + 'REASONING_ENCRYPTED_VALUE unknown subtype is skipped under ' + 'skipInvalidEvents (forward-compat opt-in)', () async { + // Companion to the test above: with per-event recovery enabled + // on the stream adapter, the malformed event is skipped and + // surrounding events still flow. The dartdoc on + // `ReasoningEncryptedValueEvent` promises this opt-in. + final controller = StreamController(); + final stream = adapter.fromSseStream( + controller.stream, + skipInvalidEvents: true, + ); + final events = []; + final sub = stream.listen(events.add); + + controller.add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_START', + 'messageId': 'rsn', + }), + )); + controller.add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'future-mode', + 'entityId': 'e', + 'encryptedValue': 'v', + }), + )); + controller.add(SseMessage( + data: jsonEncode({ + 'type': 'REASONING_END', + 'messageId': 'rsn', + }), + )); + + await controller.close(); + await sub.cancel(); + + expect(events.length, 2); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test( + 'EventDecoder.decodeJson rejects state/raw/custom events missing ' + 'their required value field', () { + // `StateSnapshotEvent.snapshot`, `RawEvent.event`, and + // `CustomEvent.value` accept any JSON shape (including null) but + // the field MUST be present. Distinguishing missing-key from + // explicit-null is the whole point of these checks. + expect( + () => decoder.decodeJson({'type': 'STATE_SNAPSHOT'}), + throwsA(isA()), + ); + expect( + () => decoder.decodeJson({'type': 'RAW'}), + throwsA(isA()), + ); + expect( + () => decoder.decodeJson({'type': 'CUSTOM', 'name': 'n'}), + throwsA(isA()), + ); + + // Explicit-null should be accepted (round-trips a present-but-null + // payload — see the matching note in the fromJson factories). + expect( + () => decoder.decodeJson({ + 'type': 'STATE_SNAPSHOT', + 'snapshot': null, + }), + returnsNormally, + ); + expect( + () => decoder.decodeJson({ + 'type': 'CUSTOM', + 'name': 'n', + 'value': null, + }), + returnsNormally, + ); }); }); @@ -377,20 +782,23 @@ void main() { skipInvalidEvents: true, onError: (error, stack) => errors.add(error), ); - + final events = []; final subscription = eventStream.listen(events.add); - + // Send a mix of valid and invalid SSE data - rawController.add('data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\n\n'); + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\n\n'); rawController.add('data: {broken json\n\n'); // Invalid JSON - rawController.add('data: {"type":"TEXT_MESSAGE_START","messageId":"m1"}\n\n'); + rawController + .add('data: {"type":"TEXT_MESSAGE_START","messageId":"m1"}\n\n'); rawController.add('data: : \n\n'); // SSE comment/keepalive - rawController.add('data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\n\n'); - + rawController + .add('data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\n\n'); + await rawController.close(); await subscription.cancel(); - + // Should process valid events and skip invalid ones expect(events.length, equals(3)); expect(errors.length, equals(1)); // Only the broken JSON @@ -402,39 +810,253 @@ void main() { sseController.stream, skipInvalidEvents: true, ); - + final eventTypes = []; final subscription = eventStream.listen((event) { eventTypes.add(event.eventType.value); }); - + // Send events in specific order with errors in between sseController.add(SseMessage( - data: jsonEncode({'type': 'RUN_STARTED', 'thread_id': 't1', 'run_id': 'r1'}), + data: jsonEncode( + {'type': 'RUN_STARTED', 'thread_id': 't1', 'run_id': 'r1'}), )); sseController.add(SseMessage(data: 'invalid')); // Error - skipped sseController.add(SseMessage( data: jsonEncode({'type': 'TEXT_MESSAGE_START', 'messageId': 'm1'}), )); - sseController.add(SseMessage(data: '{"type": "UNKNOWN"}')); // Error - skipped + sseController + .add(SseMessage(data: '{"type": "UNKNOWN"}')); // Error - skipped sseController.add(SseMessage( data: jsonEncode({'type': 'TEXT_MESSAGE_END', 'messageId': 'm1'}), )); sseController.add(SseMessage( - data: jsonEncode({'type': 'RUN_FINISHED', 'thread_id': 't1', 'run_id': 'r1'}), + data: jsonEncode( + {'type': 'RUN_FINISHED', 'thread_id': 't1', 'run_id': 'r1'}), )); - + await sseController.close(); await subscription.cancel(); - + // Order should be preserved for valid events - expect(eventTypes, equals([ - 'RUN_STARTED', - 'TEXT_MESSAGE_START', - 'TEXT_MESSAGE_END', - 'RUN_FINISHED', - ])); + expect( + eventTypes, + equals([ + 'RUN_STARTED', + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_END', + 'RUN_FINISHED', + ])); + }); + + test( + 'fromRawSseStream emits events from a CRLF-encoded stream before ' + 'close (regression: line-splitter CRLF handling)', () async { + // The WHATWG SSE spec permits CRLF, lone LF, and lone CR line + // terminators. Before the CRLF fix, `fromRawSseStream` split + // only on `\n`, leaving each line ending in `\r` — the + // `line.isEmpty` event-boundary check never fired and events + // buffered until stream close. This test asserts the steady- + // state path: events MUST be emitted before + // `rawController.close()` even on CRLF input. See + // `sse-protocol-parsing-edge-cases.md`. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\r\n\r\n', + ); + rawController.add( + 'data: {"type":"TEXT_MESSAGE_START","messageId":"m1","role":"assistant"}\r\n\r\n', + ); + rawController.add( + 'data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\r\n\r\n', + ); + + // Allow the microtask queue to drain so the line buffer + // processes everything BEFORE we close the stream. + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + // Pre-close assertion: events must already be flowing. + expect( + events.length, + equals(3), + reason: 'CRLF input must be parsed in steady state, not buffered ' + 'until stream close', + ); + + await rawController.close(); + await subscription.cancel(); + + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + }); + + test('fromRawSseStream handles mixed LF and CRLF in the same stream', + () async { + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Mix of pure-LF and CRLF event terminators. + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\n\n', + ); + rawController.add( + 'data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\r\n\r\n', + ); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(2)); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test( + 'fromRawSseStream emits events from a lone-CR-encoded stream ' + '(WHATWG spec: \\r is a valid line terminator)', () async { + // Companion to the CRLF regression at lines 822-868. The WHATWG SSE + // spec permits CRLF, lone LF, and lone CR terminators. Pre-fix, + // `fromRawSseStream` only split on `\n`, so a producer using bare + // `\r` (rare in practice but spec-valid) buffered indefinitely. + // The post-fix multi-terminator scanner consumes lone `\r` in + // steady state, with the trailing-`\r` deferral preserving correct + // chunk-spanning `\r\n` handling. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\r\r', + ); + rawController.add( + 'data: {"type":"TEXT_MESSAGE_START","messageId":"m1","role":"assistant"}\r\r', + ); + rawController.add( + 'data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\r\r', + ); + + // Drain microtasks before close to verify steady-state, not + // flush-on-close. Same pattern as the CRLF test above. + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect( + events.length, + equals(3), + reason: 'Lone-CR input must be parsed in steady state, not buffered ' + 'until stream close', + ); + + await rawController.close(); + await subscription.cancel(); + + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + }); + + test( + 'fromRawSseStream correctly disambiguates chunk-spanning \\r\\n ' + 'from lone \\r + lone \\n', () async { + // The trailing-`\r` deferral guarantees that a CRLF split across + // two chunks (chunk1 ends with `\r`, chunk2 starts with `\n`) is + // treated as a single CRLF terminator, not two separate lone + // terminators. Without the deferral, the empty-line dispatch would + // double-fire and the SSE event boundary would be mis-detected. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Split the CRLF terminators so each spans two chunks. + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}\r', + ); + rawController.add('\n\r'); + rawController.add( + '\ndata: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}\r\n\r\n', + ); + + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(2)); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test( + 'fromRawSseStream handles per-line-chunked lone-CR producer without ' + 'extra RTT (lastWasLoneCr persists across chunks)', () async { + // Regression for Important #II2: when a producer uses lone-CR + // terminators and delivers each `\r` in its own chunk, the + // `lastWasLoneCr` flag must survive across processChunk calls. + // Without persistence the trailing-`\r` deferral misfired on every + // event, delaying dispatch by one chunk-RTT each time. + // + // Stream shape: each data line ends with `\r`, each event boundary + // is a lone `\r`, and each `\r` arrives in a separate chunk. + final rawController = StreamController(); + final eventStream = adapter.fromRawSseStream(rawController.stream); + + final events = []; + final subscription = eventStream.listen(events.add); + + // Event 1: RUN_STARTED — data line `\r` then boundary `\r`, each + // in its own chunk. + rawController.add( + 'data: {"type":"RUN_STARTED","thread_id":"t1","run_id":"r1"}', + ); + rawController.add('\r'); // data-line terminator + rawController.add('\r'); // event-boundary terminator + + // Event 2: RUN_FINISHED + rawController.add( + 'data: {"type":"RUN_FINISHED","thread_id":"t1","run_id":"r1"}', + ); + rawController.add('\r'); + rawController.add('\r'); + + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + await rawController.close(); + await subscription.cancel(); + + expect(events.length, equals(2), + reason: 'Both events must be emitted without stalling'); + expect(events[0], isA()); + expect(events[1], isA()); + }); + + test('decodeSSE handles CRLF terminators (LineSplitter-based)', () { + // The single-message `decodeSSE` API mirrors the streaming + // parser: a `data: ...\r\n\r\n` payload must decode the same as + // a `data: ...\n\n` payload, with no stray `\r` corrupting the + // joined value. + final crlfMessage = + 'data: {"type":"TEXT_MESSAGE_END","messageId":"m1"}\r\n\r\n'; + final event = decoder.decodeSSE(crlfMessage); + expect(event, isA()); + expect((event as TextMessageEndEvent).messageId, equals('m1')); }); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/integration/fixtures_integration_test.dart b/sdks/community/dart/test/integration/fixtures_integration_test.dart index 0262bcbdd8..316a67fa0b 100644 --- a/sdks/community/dart/test/integration/fixtures_integration_test.dart +++ b/sdks/community/dart/test/integration/fixtures_integration_test.dart @@ -16,29 +16,29 @@ void main() { late EventEncoder encoder; late EventStreamAdapter adapter; late SseParser parser; - + setUp(() { decoder = const EventDecoder(); encoder = EventEncoder(); adapter = EventStreamAdapter(); parser = SseParser(); }); - + group('JSON Fixtures', () { late Map fixtures; - + setUpAll(() async { final fixtureFile = File('test/fixtures/events.json'); final content = await fixtureFile.readAsString(); fixtures = json.decode(content) as Map; }); - + test('processes simple text message sequence', () { final events = fixtures['simple_text_message'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - + expect(decodedEvents.length, equals(6)); expect(decodedEvents[0], isA()); expect(decodedEvents[1], isA()); @@ -46,193 +46,240 @@ void main() { expect(decodedEvents[3], isA()); expect(decodedEvents[4], isA()); expect(decodedEvents[5], isA()); - + // Verify content accumulation final content1 = (decodedEvents[2] as TextMessageContentEvent).delta; final content2 = (decodedEvents[3] as TextMessageContentEvent).delta; - expect('$content1$content2', equals('Hello, how can I help you today?')); + expect( + '$content1$content2', equals('Hello, how can I help you today?')); }); - + test('processes tool call sequence', () { final events = fixtures['tool_call_sequence'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - + expect(decodedEvents.length, equals(12)); - + // Find tool call events - final toolStart = decodedEvents - .whereType() - .first; + final toolStart = decodedEvents.whereType().first; expect(toolStart.toolCallName, equals('search')); expect(toolStart.parentMessageId, equals('msg_02')); - - final toolArgs = decodedEvents - .whereType() - .first; + + final toolArgs = decodedEvents.whereType().first; expect(toolArgs.delta, contains('AG-UI protocol')); - - final toolResult = decodedEvents - .whereType() - .first; + + final toolResult = decodedEvents.whereType().first; expect(toolResult.content, contains('event-based protocol')); }); - + test('processes state management events', () { final events = fixtures['state_management'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - + // Find state events - final snapshot = decodedEvents - .whereType() - .first; + final snapshot = decodedEvents.whereType().first; expect(snapshot.snapshot['count'], equals(0)); expect(snapshot.snapshot['user']['name'], equals('Alice')); - - final delta = decodedEvents - .whereType() - .first; + + final delta = decodedEvents.whereType().first; expect(delta.delta.length, equals(2)); expect(delta.delta[0]['op'], equals('replace')); expect(delta.delta[0]['path'], equals('/count')); expect(delta.delta[0]['value'], equals(1)); }); - + test('processes messages snapshot', () { final events = fixtures['messages_snapshot'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - + final snapshot = decodedEvents .whereType() .first; - expect(snapshot.messages.length, equals(3)); - + expect(snapshot.messages.length, equals(4)); + // Check message types expect(snapshot.messages[0], isA()); expect(snapshot.messages[1], isA()); expect(snapshot.messages[2], isA()); - + expect(snapshot.messages[3], isA()); + // Check assistant message has tool calls final assistantMsg = snapshot.messages[1] as AssistantMessage; expect(assistantMsg.toolCalls, isNotNull); expect(assistantMsg.toolCalls!.length, equals(1)); expect(assistantMsg.toolCalls![0].function.name, equals('get_weather')); + + // The multimodal user message decodes end-to-end into typed parts. + final multimodalMsg = snapshot.messages[3] as UserMessage; + final body = multimodalMsg.messageContent; + expect(body, isA()); + final parts = (body as MultimodalContent).parts; + expect(parts.length, equals(2)); + expect(parts[0], isA()); + expect((parts[0] as TextInputContent).text, equals('Describe this image')); + expect(parts[1], isA()); + expect((parts[1] as ImageInputContent).source, isA()); + // Plain-text projection getter is null for multimodal content. + expect(multimodalMsg.content, isNull); + }); + + test('processes messages snapshot with activity and reasoning roles', () { + final events = fixtures['messages_snapshot_activity_reasoning'] as List; + final decodedEvents = events + .map((e) => decoder.decodeJson(e as Map)) + .toList(); + + final snapshot = decodedEvents.whereType().first; + expect(snapshot.messages.length, equals(4)); + + expect(snapshot.messages[0], isA()); + expect(snapshot.messages[1], isA()); + expect(snapshot.messages[2], isA()); + expect(snapshot.messages[3], isA()); + + final activity = snapshot.messages[1] as ActivityMessage; + expect(activity.activityType, equals('task.run')); + expect(activity.activityContent['title'], equals('Indexing files')); + expect(activity.activityContent['progress'], equals(0.5)); + + final reasoning = snapshot.messages[2] as ReasoningMessage; + expect(reasoning.content, contains('Considering')); + expect( + reasoning.encryptedValue, equals('ZW5jcnlwdGVkLXJlYXNvbmluZw==')); + + // Cross-SDK parity: AssistantMessage carries encryptedValue from + // the canonical BaseMessageSchema. Closes the silent-drop bug + // documented in the #1018 review. + final assistant = snapshot.messages[3] as AssistantMessage; + expect( + assistant.encryptedValue, equals('ZW5jcnlwdGVkLWFzc2lzdGFudA==')); + + // Round-trip the snapshot through the encoder boundary so + // toJson()/fromJson() symmetry is exercised end-to-end for the + // new Message subtypes, not just at the factory level. + final reEncoded = MessagesSnapshotEvent.fromJson(snapshot.toJson()); + expect(reEncoded.messages.length, equals(4)); + expect(reEncoded.messages[1], isA()); + expect(reEncoded.messages[2], isA()); + expect( + (reEncoded.messages[1] as ActivityMessage).activityContent['title'], + equals('Indexing files'), + ); + expect( + (reEncoded.messages[2] as ReasoningMessage).encryptedValue, + equals('ZW5jcnlwdGVkLXJlYXNvbmluZw=='), + ); + expect( + (reEncoded.messages[3] as AssistantMessage).encryptedValue, + equals('ZW5jcnlwdGVkLWFzc2lzdGFudA=='), + ); }); - + test('processes multiple sequential runs', () { final events = fixtures['multiple_runs'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - + // Count run lifecycle events final runStarts = decodedEvents.whereType().toList(); final runEnds = decodedEvents.whereType().toList(); - + expect(runStarts.length, equals(2)); expect(runEnds.length, equals(2)); - + // Verify different run IDs expect(runStarts[0].runId, equals('run_05')); expect(runStarts[1].runId, equals('run_06')); - + // Verify same thread ID expect(runStarts[0].threadId, equals(runStarts[1].threadId)); }); - + test('processes thinking events', () { final events = fixtures['thinking_events'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - - final thinkingStart = decodedEvents - .whereType() - .first; + + final thinkingStart = + decodedEvents.whereType().first; expect(thinkingStart.title, equals('Analyzing request')); - - // Use the new ThinkingContentEvent class + + // Decoding still emits the (deprecated) ThinkingContentEvent for + // backward compatibility until removal. See [ThinkingContentEvent]. + // ignore: deprecated_member_use_from_same_package final thinkingEvents = decodedEvents + // ignore: deprecated_member_use_from_same_package .whereType() .toList(); expect(thinkingEvents.length, equals(2)); - + // Extract delta from the events - final fullContent = thinkingEvents - .map((e) => e.delta) - .join(); + final fullContent = thinkingEvents.map((e) => e.delta).join(); expect(fullContent, contains('Let me think about this')); expect(fullContent, contains('The user is asking about')); }); - + test('processes step events', () { final events = fixtures['step_events'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - - final stepStarts = decodedEvents - .whereType() - .toList(); + + final stepStarts = decodedEvents.whereType().toList(); expect(stepStarts.length, equals(2)); expect(stepStarts[0].stepName, equals('Initialize')); expect(stepStarts[1].stepName, equals('Process')); - - final stepEnds = decodedEvents - .whereType() - .toList(); + + final stepEnds = decodedEvents.whereType().toList(); expect(stepEnds.length, equals(2)); expect(stepEnds[0].stepName, equals('Initialize')); expect(stepEnds[1].stepName, equals('Process')); }); - + test('processes error handling events', () { final events = fixtures['error_handling'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - - final errorEvent = decodedEvents - .whereType() - .first; + + final errorEvent = decodedEvents.whereType().first; // RunErrorEvent has message and code properties expect(errorEvent.message, equals('Connection timeout')); expect(errorEvent.code, equals('TIMEOUT')); }); - + test('processes custom events', () { final events = fixtures['custom_events'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - - final customEvent = decodedEvents - .whereType() - .first; + + final customEvent = decodedEvents.whereType().first; expect(customEvent.name, equals('user_feedback')); expect(customEvent.value['rating'], equals(5)); - - final rawEvent = decodedEvents - .whereType() - .first; + + final rawEvent = decodedEvents.whereType().first; expect(rawEvent.event['customType'], equals('metrics')); expect(rawEvent.event['data']['latency'], equals(123)); }); - + test('processes concurrent messages', () { final events = fixtures['concurrent_messages'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - + // Track message IDs and their content final messageContents = >{}; - + for (final event in decodedEvents) { if (event is TextMessageStartEvent) { messageContents[event.messageId] = []; @@ -240,126 +287,227 @@ void main() { messageContents[event.messageId]?.add(event.delta); } } - + expect(messageContents['msg_14']?.join(), equals('First message')); - expect(messageContents['msg_15']?.join(), equals('System message continues...')); + expect(messageContents['msg_15']?.join(), + equals('System message continues...')); }); - + test('processes text message chunk events', () { final events = fixtures['text_message_chunk'] as List; final decodedEvents = events .map((e) => decoder.decodeJson(e as Map)) .toList(); - - final chunkEvent = decodedEvents - .whereType() - .first; + + final chunkEvent = + decodedEvents.whereType().first; expect(chunkEvent.messageId, equals('msg_16')); expect(chunkEvent.role, equals(TextMessageRole.assistant)); expect(chunkEvent.delta, equals('Complete message in a single chunk')); }); + + test('processes activity events', () { + final events = fixtures['activity_events'] as List; + final decodedEvents = events + .map((e) => decoder.decodeJson(e as Map)) + .toList(); + + final snapshot = decodedEvents.whereType().first; + expect(snapshot.messageId, equals('act_01')); + expect(snapshot.activityType, equals('task.run')); + expect(snapshot.replace, isTrue); + expect((snapshot.content as Map)['title'], equals('Indexing files')); + + final deltas = decodedEvents.whereType().toList(); + expect(deltas.length, equals(2)); + expect(deltas[0].patch.length, equals(2)); + expect(deltas[0].patch[0]['op'], equals('replace')); + expect(deltas[1].patch[0]['value'], equals(1.0)); + }); + + test('processes reasoning events', () { + final events = fixtures['reasoning_events'] as List; + final decodedEvents = events + .map((e) => decoder.decodeJson(e as Map)) + .toList(); + + expect( + decodedEvents.whereType().length, + equals(1), + ); + expect( + decodedEvents.whereType().single.role, + equals(ReasoningMessageRole.reasoning), + ); + + final content = + decodedEvents.whereType().single; + expect(content.delta, contains('Analyzing')); + + final chunk = + decodedEvents.whereType().single; + expect(chunk.delta, contains('considering options')); + + final encrypted = + decodedEvents.whereType().single; + expect( + encrypted.subtype, + equals(ReasoningEncryptedValueSubtype.message), + ); + expect(encrypted.entityId, equals('rsn_01')); + expect(encrypted.encryptedValue, isNotEmpty); + + expect( + decodedEvents.whereType().length, + equals(1), + ); + }); + + // TODO(1.0.0): Delete this fixture group alongside the + // THINKING_TEXT_MESSAGE_* deprecation sweep. + test('processes deprecated thinking_text_message_legacy events', () { + final events = fixtures['thinking_text_message_legacy'] as List; + // ignore: deprecated_member_use_from_same_package + final decodedEvents = events + .map((e) => decoder.decodeJson(e as Map)) + .toList(); + + expect(decodedEvents[0], isA()); + // ignore: deprecated_member_use_from_same_package + expect(decodedEvents[1], isA()); + // ignore: deprecated_member_use_from_same_package + expect(decodedEvents[2], isA()); + // ignore: deprecated_member_use_from_same_package + expect((decodedEvents[2] as ThinkingTextMessageContentEvent).delta, + equals('Let me think...')); + // ignore: deprecated_member_use_from_same_package + expect(decodedEvents[3], isA()); + expect(decodedEvents[4], isA()); + }); }); - + group('SSE Stream Fixtures', () { late String sseFixtures; - + setUpAll(() async { final fixtureFile = File('test/fixtures/sse_streams.txt'); sseFixtures = await fixtureFile.readAsString(); }); - + test('parses simple text message SSE stream', () async { - final section = _extractSection(sseFixtures, 'Simple Text Message Stream'); + final section = + _extractSection(sseFixtures, 'Simple Text Message Stream'); final lines = section.split('\n'); - - final messages = await parser.parseLines(Stream.fromIterable(lines)).toList(); - + + final messages = + await parser.parseLines(Stream.fromIterable(lines)).toList(); + // Filter out empty messages - final dataMessages = messages.where((m) => m.data != null && m.data!.isNotEmpty).toList(); - + final dataMessages = messages + .where((m) => m.data != null && m.data!.isNotEmpty) + .toList(); + expect(dataMessages.length, equals(6)); - + // Decode and verify events for (final message in dataMessages) { final event = decoder.decode(message.data!); expect(event, isA()); } }); - + test('parses tool call SSE stream', () async { final section = _extractSection(sseFixtures, 'Tool Call Stream'); final lines = section.split('\n'); - - final messages = await parser.parseLines(Stream.fromIterable(lines)).toList(); - final dataMessages = messages.where((m) => m.data != null && m.data!.isNotEmpty).toList(); - + + final messages = + await parser.parseLines(Stream.fromIterable(lines)).toList(); + final dataMessages = messages + .where((m) => m.data != null && m.data!.isNotEmpty) + .toList(); + expect(dataMessages.length, equals(6)); - + // Verify tool call args are split across messages final toolArgsMessages = dataMessages .where((m) => m.data!.contains('TOOL_CALL_ARGS')) .toList(); expect(toolArgsMessages.length, equals(2)); }); - + test('handles heartbeat and comments', () async { final section = _extractSection(sseFixtures, 'Heartbeat and Comments'); final lines = section.split('\n'); - - final messages = await parser.parseLines(Stream.fromIterable(lines)).toList(); - + + final messages = + await parser.parseLines(Stream.fromIterable(lines)).toList(); + // Comments should be ignored, only data messages processed - final dataMessages = messages.where((m) => m.data != null && m.data!.isNotEmpty).toList(); + final dataMessages = messages + .where((m) => m.data != null && m.data!.isNotEmpty) + .toList(); expect(dataMessages.length, equals(5)); }); - + test('parses multi-line data fields', () async { final section = _extractSection(sseFixtures, 'Multi-line Data Fields'); final lines = section.split('\n'); - - final messages = await parser.parseLines(Stream.fromIterable(lines)).toList(); - + + final messages = + await parser.parseLines(Stream.fromIterable(lines)).toList(); + // Multi-line data should be concatenated - final dataMessages = messages.where((m) => m.data != null && m.data!.isNotEmpty).toList(); + final dataMessages = messages + .where((m) => m.data != null && m.data!.isNotEmpty) + .toList(); expect(dataMessages.length, equals(1)); - + final concatenatedData = dataMessages[0].data!; expect(concatenatedData, contains('STATE_SNAPSHOT')); expect(concatenatedData, contains('"count":42')); }); - + test('handles event IDs and retry', () async { - final section = _extractSection(sseFixtures, 'With Event IDs and Retry'); + final section = + _extractSection(sseFixtures, 'With Event IDs and Retry'); final lines = section.split('\n'); - - final messages = await parser.parseLines(Stream.fromIterable(lines)).toList(); - final dataMessages = messages.where((m) => m.data != null && m.data!.isNotEmpty).toList(); - + + final messages = + await parser.parseLines(Stream.fromIterable(lines)).toList(); + final dataMessages = messages + .where((m) => m.data != null && m.data!.isNotEmpty) + .toList(); + expect(dataMessages.length, equals(3)); expect(dataMessages[0].id, equals('evt_001')); expect(dataMessages[0].event, equals('message')); expect(dataMessages[0].retry, equals(Duration(milliseconds: 5000))); - + // ID should be preserved across messages expect(dataMessages[1].id, equals('evt_002')); expect(dataMessages[2].id, equals('evt_003')); }); - + test('handles malformed SSE gracefully', () async { final section = _extractSection(sseFixtures, 'Malformed Examples'); final lines = section.split('\n'); - - final messages = await parser.parseLines(Stream.fromIterable(lines)).toList(); - final dataMessages = messages.where((m) => m.data != null && m.data!.isNotEmpty).toList(); - + + final messages = + await parser.parseLines(Stream.fromIterable(lines)).toList(); + final dataMessages = messages + .where((m) => m.data != null && m.data!.isNotEmpty) + .toList(); + // Some messages will fail to decode but should still be captured for (final message in dataMessages) { if (message.data == 'not valid json') { // This should fail decoding - expect(() => decoder.decode(message.data!), throwsA(isA())); + expect( + () => decoder.decode(message.data!), throwsA(isA())); } else if (message.data == '{"incomplete":') { // This is incomplete JSON - expect(() => decoder.decode(message.data!), throwsA(isA())); + expect( + () => decoder.decode(message.data!), throwsA(isA())); } else if (message.data!.isNotEmpty && message.data != '') { // Try to decode other messages try { @@ -370,20 +518,26 @@ void main() { } } }); - + test('handles unicode and special characters', () async { - final section = _extractSection(sseFixtures, 'Unicode and Special Characters'); + final section = + _extractSection(sseFixtures, 'Unicode and Special Characters'); final lines = section.split('\n'); - - final messages = await parser.parseLines(Stream.fromIterable(lines)).toList(); - final dataMessages = messages.where((m) => m.data != null && m.data!.isNotEmpty).toList(); - + + final messages = + await parser.parseLines(Stream.fromIterable(lines)).toList(); + final dataMessages = messages + .where((m) => m.data != null && m.data!.isNotEmpty) + .toList(); + expect(dataMessages.length, equals(4)); - + // Decode and verify unicode content - final events = dataMessages.map((m) => decoder.decode(m.data!)).toList(); - - final contentEvents = events.whereType().toList(); + final events = + dataMessages.map((m) => decoder.decode(m.data!)).toList(); + + final contentEvents = + events.whereType().toList(); expect(contentEvents[0].delta, contains('你好')); expect(contentEvents[0].delta, contains('🌟')); expect(contentEvents[0].delta, contains('€')); @@ -391,12 +545,13 @@ void main() { expect(contentEvents[1].delta, contains('\\backslash\\')); }); }); - + group('Round-trip Encoding/Decoding', () { test('events survive encoding and decoding', () { final originalEvents = [ RunStartedEvent(threadId: 'thread_01', runId: 'run_01'), - TextMessageStartEvent(messageId: 'msg_01', role: TextMessageRole.assistant), + TextMessageStartEvent( + messageId: 'msg_01', role: TextMessageRole.assistant), TextMessageContentEvent(messageId: 'msg_01', delta: 'Hello, world!'), TextMessageEndEvent(messageId: 'msg_01'), ToolCallStartEvent( @@ -406,60 +561,150 @@ void main() { ), ToolCallArgsEvent(toolCallId: 'tool_01', delta: '{"query": "test"}'), ToolCallEndEvent(toolCallId: 'tool_01'), - StateSnapshotEvent(snapshot: {'count': 42, 'items': ['a', 'b', 'c']}), + StateSnapshotEvent(snapshot: { + 'count': 42, + 'items': ['a', 'b', 'c'] + }), StateDeltaEvent(delta: [ {'op': 'replace', 'path': '/count', 'value': 43}, ]), - const ActivitySnapshotEvent( - messageId: 'rag:abc123', - activityType: 'skill_tool_call', - content: {'skill': 'rag', 'tool_name': 'search'}, + ActivitySnapshotEvent( + messageId: 'act_01', + activityType: 'task.run', + content: {'progress': 0.25}, ), + ActivityDeltaEvent( + messageId: 'act_01', + activityType: 'task.run', + patch: [ + {'op': 'replace', 'path': '/progress', 'value': 0.5}, + ], + ), + ReasoningStartEvent(messageId: 'rsn_01'), + ReasoningMessageStartEvent(messageId: 'rsn_01'), + ReasoningMessageContentEvent( + messageId: 'rsn_01', + delta: 'thinking', + ), + ReasoningMessageEndEvent(messageId: 'rsn_01'), + ReasoningMessageChunkEvent(messageId: 'rsn_01', delta: 'chunk'), + ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.message, + entityId: 'rsn_01', + encryptedValue: 'cipher', + ), + ReasoningEndEvent(messageId: 'rsn_01'), RunFinishedEvent(threadId: 'thread_01', runId: 'run_01'), ]; - + // Encode to SSE - final encodedEvents = originalEvents.map((e) => encoder.encodeSSE(e)).toList(); - + final encodedEvents = + originalEvents.map((e) => encoder.encodeSSE(e)).toList(); + // Decode back final decodedEvents = []; for (final sse in encodedEvents) { decodedEvents.add(decoder.decodeSSE(sse)); } - + // Verify types match expect(decodedEvents.length, equals(originalEvents.length)); for (var i = 0; i < originalEvents.length; i++) { - expect(decodedEvents[i].runtimeType, equals(originalEvents[i].runtimeType)); + expect(decodedEvents[i].runtimeType, + equals(originalEvents[i].runtimeType)); } - + // Verify specific field values final decodedRun = decodedEvents[0] as RunStartedEvent; expect(decodedRun.threadId, equals('thread_01')); expect(decodedRun.runId, equals('run_01')); - + final decodedContent = decodedEvents[2] as TextMessageContentEvent; expect(decodedContent.delta, equals('Hello, world!')); - + final decodedSnapshot = decodedEvents[7] as StateSnapshotEvent; expect(decodedSnapshot.snapshot['count'], equals(42)); expect(decodedSnapshot.snapshot['items'], equals(['a', 'b', 'c'])); - final decodedActivity = decodedEvents[9] as ActivitySnapshotEvent; - expect(decodedActivity.messageId, equals('rag:abc123')); - expect(decodedActivity.activityType, equals('skill_tool_call')); - expect(decodedActivity.content, equals({'skill': 'rag', 'tool_name': 'search'})); - expect(decodedActivity.replace, isTrue); + // Field-value parity for the new activity / reasoning events. + // `whereType().first` is used instead of positional indices + // so the assertions stay stable if the fixture order shifts. + final activitySnapshot = + decodedEvents.whereType().first; + expect(activitySnapshot.replace, isTrue); + expect(activitySnapshot.activityType, equals('task.run')); + expect( + activitySnapshot.content, + equals({'progress': 0.25}), + ); + + final reasoningStart = + decodedEvents.whereType().first; + expect(reasoningStart.role, equals(ReasoningMessageRole.reasoning)); + expect(reasoningStart.messageId, equals('rsn_01')); + + final encrypted = + decodedEvents.whereType().first; + expect( + encrypted.subtype, + equals(ReasoningEncryptedValueSubtype.message), + ); + expect(encrypted.entityId, equals('rsn_01')); + expect(encrypted.encryptedValue, equals('cipher')); + }); + + test('round-trip preserves explicit-null payload', () { + // Regression guard for the encoder null-strip bug: previously + // `encodeSSE` ran `json.removeWhere((k, v) => v == null)` which + // silently dropped fields that intentionally serialize as `null`. + // The factories below all REQUIRE the key to be present (an absent + // key raises `AGUIValidationError`), so the round-trip would fail + // with `DecodingError(field: 'content' | 'event' | 'value')`. The + // post-fix encoder leaves the toJson output untouched. + final originals = [ + ActivitySnapshotEvent( + messageId: 'm', + activityType: 't', + content: null, + ), + RawEvent(event: null), + CustomEvent(name: 'evt', value: null), + StateSnapshotEvent(snapshot: null), + ]; + + for (final original in originals) { + final sse = encoder.encodeSSE(original); + final decoded = decoder.decodeSSE(sse); + expect( + decoded.runtimeType, + equals(original.runtimeType), + reason: 'round-trip type mismatch for ${original.runtimeType}', + ); + } + + final activity = decoder.decodeSSE(encoder.encodeSSE(originals[0])) + as ActivitySnapshotEvent; + expect(activity.content, isNull); + final raw = + decoder.decodeSSE(encoder.encodeSSE(originals[1])) as RawEvent; + expect(raw.event, isNull); + final custom = + decoder.decodeSSE(encoder.encodeSSE(originals[2])) as CustomEvent; + expect(custom.value, isNull); + final snapshot = decoder.decodeSSE(encoder.encodeSSE(originals[3])) + as StateSnapshotEvent; + expect(snapshot.snapshot, isNull); }); - + test('handles protobuf content type negotiation', () { // Test with protobuf accept header final protoEncoder = EventEncoder( accept: 'application/vnd.ag-ui.event+proto, text/event-stream', ); expect(protoEncoder.acceptsProtobuf, isTrue); - expect(protoEncoder.getContentType(), equals('application/vnd.ag-ui.event+proto')); - + expect(protoEncoder.getContentType(), + equals('application/vnd.ag-ui.event+proto')); + // Test without protobuf final sseEncoder = EventEncoder(accept: 'text/event-stream'); expect(sseEncoder.acceptsProtobuf, isFalse); @@ -472,9 +717,10 @@ void main() { // Helper to extract sections from fixture file String _extractSection(String content, String sectionName) { final lines = content.split('\n'); - final startIndex = lines.indexWhere((line) => line.startsWith('## $sectionName')); + final startIndex = + lines.indexWhere((line) => line.startsWith('## $sectionName')); if (startIndex == -1) return ''; - + var endIndex = lines.length; for (var i = startIndex + 1; i < lines.length; i++) { if (lines[i].startsWith('##')) { @@ -482,6 +728,6 @@ String _extractSection(String content, String sectionName) { break; } } - + return lines.sublist(startIndex + 1, endIndex).join('\n'); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/integration/helpers/test_helpers.dart b/sdks/community/dart/test/integration/helpers/test_helpers.dart index 42bd9b2026..a315855f5b 100644 --- a/sdks/community/dart/test/integration/helpers/test_helpers.dart +++ b/sdks/community/dart/test/integration/helpers/test_helpers.dart @@ -8,8 +8,7 @@ import 'package:test/test.dart'; class TestHelpers { /// Get base URL from environment or default static String get baseUrl { - return Platform.environment['AGUI_BASE_URL'] ?? - 'http://127.0.0.1:20203'; + return Platform.environment['AGUI_BASE_URL'] ?? 'http://127.0.0.1:20203'; } /// Check if integration tests should be skipped @@ -39,7 +38,8 @@ class TestHelpers { dynamic state, }) { return SimpleRunAgentInput( - threadId: threadId ?? 'test-thread-${DateTime.now().millisecondsSinceEpoch}', + threadId: + threadId ?? 'test-thread-${DateTime.now().millisecondsSinceEpoch}', runId: runId ?? 'test-run-${DateTime.now().millisecondsSinceEpoch}', messages: messages ?? [], tools: tools ?? [], @@ -65,7 +65,7 @@ class TestHelpers { completer.complete(); } }, - onError: (error) { + onError: (Object error) { completer.completeError(error); }, onDone: () { @@ -113,10 +113,11 @@ class TestHelpers { if (expectMessages) { final hasMessages = events.any( - (e) => e.eventType == EventType.messagesSnapshot || - e.eventType == EventType.textMessageStart || - e.eventType == EventType.textMessageContent || - e.eventType == EventType.textMessageEnd, + (e) => + e.eventType == EventType.messagesSnapshot || + e.eventType == EventType.textMessageStart || + e.eventType == EventType.textMessageContent || + e.eventType == EventType.textMessageEnd, ); expect(hasMessages, isTrue, reason: 'Should have message events'); } @@ -125,32 +126,32 @@ class TestHelpers { /// Extract messages from events static List extractMessages(List events) { final messages = []; - + for (final event in events) { if (event is MessagesSnapshotEvent) { messages.clear(); messages.addAll(event.messages); } } - + return messages; } - /// Find tool calls in messages + /// Find tool calls in messages. + /// + /// Uses the typed accessor on `AssistantMessage` rather than round-tripping + /// through `toJson` — the previous implementation read `json['tool_calls']` + /// (snake_case) but `AssistantMessage.toJson` emits the camelCase key + /// `'toolCalls'`, so the helper silently always returned an empty list. static List findToolCalls(List messages) { final toolCalls = []; - + for (final message in messages) { - // Tool calls are stored in the message's toJson representation - final json = message.toJson(); - if (json['tool_calls'] != null) { - final calls = json['tool_calls'] as List; - for (final call in calls) { - toolCalls.add(ToolCall.fromJson(call as Map)); - } + if (message is AssistantMessage && message.toolCalls != null) { + toolCalls.addAll(message.toolCalls!); } } - + return toolCalls; } @@ -166,7 +167,7 @@ class TestHelpers { final filepath = '${artifactsDir.path}/$filename'; final file = File(filepath); - + // Convert events to JSONL format final jsonLines = events.map((event) { // Create a JSON representation of the event @@ -198,8 +199,9 @@ class TestHelpers { json['messages'] = event.messages.map(_messageToJson).toList(); } else if (event is TextMessageChunkEvent) { json['messageId'] = event.messageId; - // TextMessageChunkEvent stores content differently - // Will need to check the actual implementation + // Other chunk fields (`role`, `delta`, `name`) are intentionally + // omitted from this minimal helper; tests that need them build the + // JSON map directly rather than going through this round-tripper. } else if (event is ToolCallStartEvent) { json['toolCallId'] = event.toolCallId; } @@ -237,4 +239,4 @@ class TestHelpers { skip: skip || shouldSkipIntegration, ); } -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/sse/backoff_strategy_test.dart b/sdks/community/dart/test/sse/backoff_strategy_test.dart index ac33ccc437..efcb8efa28 100644 --- a/sdks/community/dart/test/sse/backoff_strategy_test.dart +++ b/sdks/community/dart/test/sse/backoff_strategy_test.dart @@ -45,7 +45,7 @@ void main() { for (var i = 0; i < 20; i++) { final delay = backoff.nextDelay(0); final delayMs = delay.inMilliseconds; - + // Expected: 10000ms ± 30% = 7000ms to 13000ms expect(delayMs, greaterThanOrEqualTo(7000)); expect(delayMs, lessThanOrEqualTo(13000)); @@ -112,4 +112,4 @@ void main() { expect(backoff.attempt, 1); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/sse/sse_client_basic_test.dart b/sdks/community/dart/test/sse/sse_client_basic_test.dart index 597368c371..ef556f0ee0 100644 --- a/sdks/community/dart/test/sse/sse_client_basic_test.dart +++ b/sdks/community/dart/test/sse/sse_client_basic_test.dart @@ -72,4 +72,4 @@ void main() { expect(client.isConnected, isFalse); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/sse/sse_client_stream_test.dart b/sdks/community/dart/test/sse/sse_client_stream_test.dart index defbd567a5..6df71cacf6 100644 --- a/sdks/community/dart/test/sse/sse_client_stream_test.dart +++ b/sdks/community/dart/test/sse/sse_client_stream_test.dart @@ -186,7 +186,8 @@ void main() { // SSE spec: single leading space after colon is removed controller.add(utf8.encode('data: With space\n')); - controller.add(utf8.encode('data: Two spaces\n')); // Only first space removed + controller + .add(utf8.encode('data: Two spaces\n')); // Only first space removed controller.add(utf8.encode('data:No space\n')); controller.add(utf8.encode('\n')); @@ -198,4 +199,4 @@ void main() { expect(messages[0].data, equals('With space\n Two spaces\nNo space')); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/sse/sse_message_test.dart b/sdks/community/dart/test/sse/sse_message_test.dart index cfb575bbe9..1a0e74641e 100644 --- a/sdks/community/dart/test/sse/sse_message_test.dart +++ b/sdks/community/dart/test/sse/sse_message_test.dart @@ -57,7 +57,8 @@ void main() { final message = SseMessage(); final str = message.toString(); - expect(str, equals('SseMessage(event: null, id: null, data: null, retry: null)')); + expect(str, + equals('SseMessage(event: null, id: null, data: null, retry: null)')); }); test('creates message with only event', () { @@ -120,4 +121,4 @@ void main() { expect(message.data, equals('const-data')); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/sse/sse_parser_test.dart b/sdks/community/dart/test/sse/sse_parser_test.dart index e1d4062b48..3a46cbcd2d 100644 --- a/sdks/community/dart/test/sse/sse_parser_test.dart +++ b/sdks/community/dart/test/sse/sse_parser_test.dart @@ -78,6 +78,43 @@ void main() { expect(messages[0].data, 'line 1\nline 2\nline 3'); }); + test('preserves leading newline when first data field is empty', + () async { + // Per WHATWG, every `data:` field appends `\n` before its value + // (with the trailing `\n` stripped at dispatch). An empty first + // `data:` followed by `data: x` MUST yield `"\nx"`, not `"x"`. + // Regression for the `_dataBuffer.isNotEmpty` heuristic that + // collapsed the empty-then-non-empty sequence pre-fix. + final lines = Stream.fromIterable([ + 'data:', + 'data: x', + '', + ]); + + final messages = await parser.parseLines(lines).toList(); + expect(messages.length, 1); + expect(messages[0].data, '\nx'); + }); + + test('event field replaces (not appends) on repeated event: lines', + () async { + // Per WHATWG, "If the field name is 'event', set the event type + // buffer to field value." Repeated `event:` lines within one + // dispatch block must REPLACE, not concatenate. Pre-fix, this + // produced `"firstsecond"`. + final lines = Stream.fromIterable([ + 'event: first', + 'event: second', + 'data: payload', + '', + ]); + + final messages = await parser.parseLines(lines).toList(); + expect(messages.length, 1); + expect(messages[0].event, 'second'); + expect(messages[0].data, 'payload'); + }); + test('ignores comments', () async { final lines = Stream.fromIterable([ ': this is a comment', @@ -179,6 +216,28 @@ void main() { expect(messages[0].id, isNull); }); + test('ignores id containing NUL byte per WHATWG SSE spec', () async { + // WHATWG SSE spec: id values must not contain U+0000 (NUL). + // A NUL-bearing id is silently ignored; _lastEventId is not updated. + // Per spec, each dispatched message carries the current _lastEventId + // value, so the second message still inherits 'good-id'. + final lines = Stream.fromIterable([ + 'id: good-id', + 'data: first', + '', + 'id: bad\x00id', + 'data: second', + '', + ]); + + final messages = await parser.parseLines(lines).toList(); + expect(messages.length, 2); + expect(messages[0].id, equals('good-id')); + // NUL id ignored — _lastEventId unchanged, second message inherits it + expect(messages[1].id, equals('good-id')); + expect(parser.lastEventId, equals('good-id')); + }); + test('ignores invalid retry values', () async { final lines = Stream.fromIterable([ 'retry: not-a-number', @@ -232,7 +291,12 @@ void main() { test('removes BOM if present', () async { // UTF-8 BOM + data - final bytesWithBom = [0xEF, 0xBB, 0xBF, ...utf8.encode('data: test\n\n')]; + final bytesWithBom = [ + 0xEF, + 0xBB, + 0xBF, + ...utf8.encode('data: test\n\n') + ]; final stream = Stream.value(bytesWithBom); final messages = await parser.parseBytes(stream).toList(); @@ -258,7 +322,7 @@ void main() { // Test with \r\n (CRLF) final crlfBytes = utf8.encode('data: line1\r\ndata: line2\r\n\r\n'); final crlfStream = Stream.value(crlfBytes); - + final crlfMessages = await parser.parseBytes(crlfStream).toList(); expect(crlfMessages.length, 1); expect(crlfMessages[0].data, 'line1\nline2'); @@ -269,13 +333,86 @@ void main() { // Test with \n (LF) final lfBytes = utf8.encode('data: line1\ndata: line2\n\n'); final lfStream = Stream.value(lfBytes); - + final lfMessages = await parser.parseBytes(lfStream).toList(); expect(lfMessages.length, 1); expect(lfMessages[0].data, 'line1\nline2'); }); }); + group('reset()', () { + test('clears sticky _lastEventId across independent streams', () async { + // I-1 regression: reset() must zero _lastEventId so a reused parser + // does not carry the prior connection's id into a new stream. + await parser + .parseLines(Stream.fromIterable(['id: abc', 'data: x', ''])) + .toList(); + expect(parser.lastEventId, equals('abc')); + parser.reset(); + expect(parser.lastEventId, isNull); + // Subsequent stream without id: line must dispatch with null id. + final msgs = await parser + .parseLines(Stream.fromIterable(['data: y', ''])) + .toList(); + expect(msgs.single.id, isNull); + expect(parser.lastEventId, isNull); + }); + + test('reset() clears all buffer state, not just _lastEventId', () async { + // Partially fill the parser state (data: line without blank line). + final firstStream = parser.parseLines( + Stream.fromIterable(['id: xyz', 'data: partial']), + ); + await firstStream.toList(); // consumes stream + end-of-stream flush + parser.reset(); + // After reset, a fresh stream should parse cleanly with no carryover. + final msgs = await parser + .parseLines(Stream.fromIterable(['data: clean', ''])) + .toList(); + expect(msgs.single.data, equals('clean')); + expect(msgs.single.id, isNull); // _lastEventId was cleared + }); + }); + + group('size caps', () { + test('rejects oversized data: field beyond maxDataCodeUnits (I-2)', () { + final parser = SseParser(maxDataCodeUnits: 16); + final lines = Stream.fromIterable([ + 'data: this string is longer than sixteen units', + '', + ]); + expect( + parser.parseLines(lines).toList(), + throwsA(isA()), + ); + }); + + test('rejects oversized event: field beyond maxDataCodeUnits (I-2)', + () async { + final parser = SseParser(maxDataCodeUnits: 8); + final lines = Stream.fromIterable([ + 'event: very-long-event-name', + 'data: x', + '', + ]); + expect( + parser.parseLines(lines).toList(), + throwsA(isA()), + ); + }); + + test('silently ignores id: field longer than maxIdCodeUnits (I-2)', + () async { + final hugeId = 'x' * 2048; // well above 1024 cap + final parser = SseParser(); + final messages = await parser + .parseLines(Stream.fromIterable(['id: $hugeId', 'data: y', ''])) + .toList(); + expect(messages.single.id, isNull); // oversized id dropped, not stored + expect(parser.lastEventId, isNull); + }); + }); + group('complex scenarios', () { test('handles real-world SSE stream', () async { final lines = Stream.fromIterable([ @@ -308,7 +445,8 @@ void main() { expect(messages[1].event, 'message'); expect(messages[1].id, 'evt-002'); - expect(messages[1].data, '{"from": "alice",\n "text": "Hello, world!",\n "timestamp": 1234567891}'); + expect(messages[1].data, + '{"from": "alice",\n "text": "Hello, world!",\n "timestamp": 1234567891}'); expect(messages[2].event, isNull); expect(messages[2].id, 'evt-002'); // Preserved from previous @@ -316,4 +454,4 @@ void main() { }); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/types/base_test.dart b/sdks/community/dart/test/types/base_test.dart index c95c71964a..2508c95ca9 100644 --- a/sdks/community/dart/test/types/base_test.dart +++ b/sdks/community/dart/test/types/base_test.dart @@ -175,7 +175,8 @@ void main() { expect( () => JsonDecoder.requireField(json, 'name'), throwsA(isA() - .having((e) => e.message, 'message', contains('Missing required field')) + .having((e) => e.message, 'message', + contains('Missing required field')) .having((e) => e.field, 'field', 'name')), ); }); @@ -184,8 +185,8 @@ void main() { final json = {'name': null}; expect( () => JsonDecoder.requireField(json, 'name'), - throwsA(isA() - .having((e) => e.message, 'message', contains('Required field is null'))), + throwsA(isA().having( + (e) => e.message, 'message', contains('Required field is null'))), ); }); @@ -216,8 +217,8 @@ void main() { 'age', transform: (value) => int.parse(value as String), ), - throwsA(isA() - .having((e) => e.message, 'message', contains('Failed to transform'))), + throwsA(isA().having( + (e) => e.message, 'message', contains('Failed to transform'))), ); }); }); @@ -263,7 +264,9 @@ void main() { group('requireListField', () { test('extracts required list field', () { - final json = {'items': ['a', 'b', 'c']}; + final json = { + 'items': ['a', 'b', 'c'] + }; final items = JsonDecoder.requireListField(json, 'items'); expect(items, equals(['a', 'b', 'c'])); }); @@ -298,15 +301,38 @@ void main() { 'numbers', itemTransform: (value) => int.parse(value as String), ), - throwsA(isA() - .having((e) => e.message, 'message', contains('Failed to transform list item'))), + throwsA(isA().having((e) => e.message, 'message', + contains('Failed to transform list item'))), ); }); + + test('item transform error reports index in field name', () { + // Regression for I3: itemTransform errors must name the bad index + // (`numbers[1]`) rather than the bare field name (`numbers`), matching + // the _eagerCast no-transform path's `field: '$field[$i]'` contract. + final json = { + 'numbers': ['1', 'bad', '3'] + }; + AGUIValidationError? caught; + try { + JsonDecoder.requireListField( + json, + 'numbers', + itemTransform: (value) => int.parse(value as String), + ); + } on AGUIValidationError catch (e) { + caught = e; + } + expect(caught, isNotNull); + expect(caught!.field, equals('numbers[1]')); + }); }); group('optionalListField', () { test('extracts optional list field when present', () { - final json = {'items': ['a', 'b']}; + final json = { + 'items': ['a', 'b'] + }; final items = JsonDecoder.optionalListField(json, 'items'); expect(items, equals(['a', 'b'])); }); @@ -398,4 +424,4 @@ void main() { expect(camel, equals(original)); }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/types/message_test.dart b/sdks/community/dart/test/types/message_test.dart index a6e1dc963a..9638ff61f6 100644 --- a/sdks/community/dart/test/types/message_test.dart +++ b/sdks/community/dart/test/types/message_test.dart @@ -132,6 +132,197 @@ void main() { }); }); + group('ActivityMessage', () { + test('round-trips canonical wire shape', () { + final message = ActivityMessage( + id: 'act_001', + activityType: 'task.run', + activityContent: const {'progress': 0.5, 'items': []}, + ); + + final json = message.toJson(); + expect(json['id'], 'act_001'); + expect(json['role'], 'activity'); + expect(json['activityType'], 'task.run'); + expect(json['content'], const {'progress': 0.5, 'items': []}); + + final decoded = ActivityMessage.fromJson(json); + expect(decoded.id, 'act_001'); + expect(decoded.activityType, 'task.run'); + expect(decoded.activityContent, equals(message.activityContent)); + expect(decoded.role, MessageRole.activity); + }); + + test('accepts snake_case activity_type (Python server)', () { + final message = ActivityMessage.fromJson({ + 'id': 'act_002', + 'role': 'activity', + 'activity_type': 'task.run', + 'content': {'progress': 0.0}, + }); + + expect(message.activityType, 'task.run'); + expect(message.activityContent['progress'], 0.0); + }); + + test('rejects missing required content', () { + expect( + () => ActivityMessage.fromJson({ + 'id': 'act_003', + 'role': 'activity', + 'activityType': 'task.run', + }), + throwsA(isA()), + ); + }); + + test('copyWith preserves subtype', () { + final original = ActivityMessage( + id: 'act_004', + activityType: 'task.run', + activityContent: const {'progress': 0.0}, + ); + + final updated = original.copyWith( + activityContent: const {'progress': 1.0}, + ); + + expect(updated, isA()); + expect(updated.id, original.id); + expect(updated.activityType, original.activityType); + expect(updated.activityContent['progress'], 1.0); + }); + + test( + 'strips camelCase encryptedValue silently (not a BaseMessage extension)', + () { + final msg = ActivityMessage.fromJson({ + 'id': 'act_005', + 'role': 'activity', + 'activityType': 'task.run', + 'content': {'progress': 0.5}, + 'encryptedValue': 'ZW5jcnlwdGVkLXBheWxvYWQ=', + }); + expect(msg.id, 'act_005'); + expect(msg.activityType, 'task.run'); + // encryptedValue is not exposed on ActivityMessage — stripping is silent. + expect(msg.toJson().containsKey('encryptedValue'), isFalse); + }); + + test( + 'strips snake_case encrypted_value silently (not a BaseMessage extension)', + () { + final msg = ActivityMessage.fromJson({ + 'id': 'act_006', + 'role': 'activity', + 'activityType': 'task.run', + 'content': {'progress': 0.5}, + 'encrypted_value': 'ZW5jcnlwdGVkLXBheWxvYWQ=', + }); + expect(msg.id, 'act_006'); + expect(msg.activityType, 'task.run'); + expect(msg.toJson().containsKey('encryptedValue'), isFalse); + }); + + test( + 'LSP: name is always null; encryptedValue returns null on ActivityMessage', + () { + // ActivityMessage is NOT a BaseMessage extension — cipher-payload + // forwarding does not apply. `name` is always null; `encryptedValue` + // returns null (wire-correct) so polymorphic List iteration + // does not crash. toJson never emits either field. + final direct = ActivityMessage( + id: 'act_007', + activityType: 'task.run', + activityContent: const {'x': 1}, + ); + expect(direct.name, isNull, + reason: 'name must be null on ActivityMessage'); + // Fix for Opus2 I2: returns null instead of throwing — LSP compliance. + expect(direct.encryptedValue, isNull, + reason: + 'encryptedValue must return null on ActivityMessage (not throw)'); + expect(direct.toJson().containsKey('name'), isFalse); + expect(direct.toJson().containsKey('encryptedValue'), isFalse); + + // Also via fromJson — even if a proxy emits name/encryptedValue. + final fromJson = ActivityMessage.fromJson({ + 'id': 'act_008', + 'role': 'activity', + 'activityType': 'task.run', + 'content': {'x': 1}, + 'name': 'should_be_stripped', + 'encryptedValue': 'should_be_stripped', + }); + expect(fromJson.name, isNull); + expect(fromJson.encryptedValue, isNull, + reason: + 'encryptedValue must return null on ActivityMessage (not throw)'); + expect(fromJson.toJson().containsKey('name'), isFalse); + expect(fromJson.toJson().containsKey('encryptedValue'), isFalse); + }); + }); + + group('ReasoningMessage', () { + test('round-trips canonical wire shape with encryptedValue', () { + final message = ReasoningMessage( + id: 'rsn_001', + content: 'Analyzing the request...', + encryptedValue: 'ZW5jcnlwdGVkLXBheWxvYWQ=', + ); + + final json = message.toJson(); + expect(json['id'], 'rsn_001'); + expect(json['role'], 'reasoning'); + expect(json['content'], 'Analyzing the request...'); + expect(json['encryptedValue'], 'ZW5jcnlwdGVkLXBheWxvYWQ='); + + final decoded = ReasoningMessage.fromJson(json); + expect(decoded.id, 'rsn_001'); + expect(decoded.content, message.content); + expect(decoded.encryptedValue, message.encryptedValue); + expect(decoded.role, MessageRole.reasoning); + }); + + test('omits encryptedValue when null', () { + final message = ReasoningMessage( + id: 'rsn_002', + content: 'Plain reasoning text', + ); + + final json = message.toJson(); + expect(json.containsKey('encryptedValue'), isFalse); + + final decoded = ReasoningMessage.fromJson(json); + expect(decoded.encryptedValue, isNull); + }); + + test('accepts snake_case encrypted_value (Python server)', () { + final message = ReasoningMessage.fromJson({ + 'id': 'rsn_003', + 'role': 'reasoning', + 'content': 'Thinking', + 'encrypted_value': 'cGF5bG9hZA==', + }); + + expect(message.encryptedValue, 'cGF5bG9hZA=='); + }); + + test('copyWith preserves subtype', () { + final original = ReasoningMessage( + id: 'rsn_005', + content: 'first', + ); + + final updated = original.copyWith(content: 'second'); + + expect(updated, isA()); + expect(updated.id, original.id); + expect(updated.content, 'second'); + expect(updated.encryptedValue, isNull); + }); + }); + group('Message Factory', () { test('should create correct message type based on role', () { final messages = [ @@ -145,6 +336,17 @@ void main() { 'content': 'Tool result', 'toolCallId': 'call_001' }, + { + 'id': '6', + 'role': 'activity', + 'activityType': 'task.run', + 'content': {'progress': 0.0}, + }, + { + 'id': '7', + 'role': 'reasoning', + 'content': 'Thinking out loud', + }, ]; final decoded = messages.map((json) => Message.fromJson(json)).toList(); @@ -154,6 +356,8 @@ void main() { expect(decoded[2], isA()); expect(decoded[3], isA()); expect(decoded[4], isA()); + expect(decoded[5], isA()); + expect(decoded[6], isA()); }); test('should throw on invalid role', () { @@ -170,67 +374,211 @@ void main() { }); }); - group('ActivityMessage', () { - test('MessageRole.fromString("activity") returns activity role', () { - expect(MessageRole.fromString('activity'), MessageRole.activity); + group('copyWith null-clearing parity (sentinel pattern)', () { + test('DeveloperMessage.copyWith(name: null) clears name', () { + // Sentinel pattern parity with the event layer: a nullable field + // must be clearable via `copyWith(field: null)`. The default + // `?? this.field` pattern (events.dart calls this out via + // `_unsetCopyWith`) cannot distinguish "omitted" from + // "explicitly null" — sentinel resolves it. + final msg = DeveloperMessage( + id: 'd1', + content: 'x', + name: 'devbot', + ); + expect(msg.copyWith(name: null).name, isNull); + expect(msg.copyWith().name, equals('devbot')); }); - test('round-trips activityType and content', () { - final message = ActivityMessage( - id: 'act_001', - activityType: 'file_upload', - activityContent: {'progress': 0.5, 'filename': 'data.csv'}, + test('SystemMessage.copyWith(name: null) clears name', () { + final msg = SystemMessage(id: 's1', content: 'x', name: 'sys'); + expect(msg.copyWith(name: null).name, isNull); + expect(msg.copyWith().name, equals('sys')); + }); + + test('UserMessage.copyWith(name: null) clears name', () { + final msg = UserMessage(id: 'u1', content: 'x', name: 'alice'); + expect(msg.copyWith(name: null).name, isNull); + expect(msg.copyWith().name, equals('alice')); + }); + + test( + 'AssistantMessage.copyWith with explicit null clears ' + 'content/name/toolCalls', () { + // All three nullable fields use the sentinel — verify each one + // independently. + final msg = AssistantMessage( + id: 'a1', + content: 'hi', + name: 'asst', + toolCalls: [ + ToolCall( + id: 'c1', + function: FunctionCall(name: 'fn', arguments: '{}'), + ), + ], ); + expect(msg.copyWith(content: null).content, isNull); + expect(msg.copyWith(name: null).name, isNull); + expect(msg.copyWith(toolCalls: null).toolCalls, isNull); - final json = message.toJson(); - expect(json['id'], 'act_001'); - expect(json['role'], 'activity'); - expect(json['activityType'], 'file_upload'); - expect(json['content'], {'progress': 0.5, 'filename': 'data.csv'}); + // Argument omitted preserves all three fields. + final cloned = msg.copyWith(); + expect(cloned.content, equals('hi')); + expect(cloned.name, equals('asst')); + expect(cloned.toolCalls, isNotNull); + }); - final decoded = ActivityMessage.fromJson(json); - expect(decoded.id, 'act_001'); - expect(decoded.activityType, 'file_upload'); - expect(decoded.activityContent, - {'progress': 0.5, 'filename': 'data.csv'}); + test( + 'ToolMessage.copyWith with explicit null clears error and ' + 'encryptedValue', () { + final msg = ToolMessage( + id: 't1', + content: 'result', + toolCallId: 'c1', + error: 'oops', + encryptedValue: 'cipher', + ); + expect(msg.copyWith(error: null).error, isNull); + expect(msg.copyWith(encryptedValue: null).encryptedValue, isNull); + + final cloned = msg.copyWith(); + expect(cloned.error, equals('oops')); + expect(cloned.encryptedValue, equals('cipher')); }); - test('Message.fromJson routes role=activity to ActivityMessage', () { - final decoded = Message.fromJson({ - 'id': 'act_002', - 'role': 'activity', - 'activityType': 'thinking', - 'content': {'note': 'x'}, - }); - expect(decoded, isA()); + test('ReasoningMessage.copyWith(encryptedValue: null) clears it', () { + final msg = ReasoningMessage( + id: 'r1', + content: 'thinking', + encryptedValue: 'cipher', + ); + expect(msg.copyWith(encryptedValue: null).encryptedValue, isNull); + expect(msg.copyWith().encryptedValue, equals('cipher')); }); - test('copyWith overrides fields', () { - final original = ActivityMessage( + test('ActivityMessage.copyWith(id: null) clears id', () { + final msg = ActivityMessage( id: 'act_1', - activityType: 'upload', - activityContent: const {'progress': 0.1}, + activityType: 'task.run', + activityContent: const {'progress': 0.0}, ); - final copy = original.copyWith( - id: 'act_2', - activityType: 'download', - activityContent: const {'progress': 0.9}, + expect(msg.copyWith(id: null).id, isNull); + expect(msg.copyWith().id, equals('act_1')); + }); + }); + + group('AssistantMessage.fromJson dual-key precedence', () { + test( + 'empty toolCalls does not silently win over snake_case ' + 'tool_calls (regression for #1018 review)', () { + // Before the fix, the `??`-on-value chain only fired on null; + // an empty `toolCalls: []` short-circuited and silently + // dropped the populated `tool_calls` snake_case alias. + // `optionalEitherField` resolves on the KEY itself: camelCase + // wins when present (matching the documented falsy-non-null + // contract in `requireEitherField`), and falls back to + // snake_case ONLY when camelCase is entirely absent. + final emptyCamel = AssistantMessage.fromJson({ + 'id': 'a1', + 'role': 'assistant', + 'toolCalls': [], + 'tool_calls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': {'name': 'fn', 'arguments': '{}'}, + }, + ], + }); + // Documented behavior: camelCase wins when the key is present, + // even when the list is empty. The snake_case payload is + // silently ignored — surprising if you read the code as a + // "fallback", correct if you read it as + // "camelCase-key-present always wins". + expect(emptyCamel.toolCalls, isEmpty); + + // When camelCase is absent, snake_case is consulted. + final onlySnake = AssistantMessage.fromJson({ + 'id': 'a2', + 'role': 'assistant', + 'tool_calls': [ + { + 'id': 'call_2', + 'type': 'function', + 'function': {'name': 'fn', 'arguments': '{}'}, + }, + ], + }); + expect(onlySnake.toolCalls, isNotNull); + expect(onlySnake.toolCalls!.length, 1); + expect(onlySnake.toolCalls![0].id, equals('call_2')); + }); + }); + + group('ToolCall.encryptedValue parity', () { + test( + 'round-trips encryptedValue (camelCase) on AssistantMessage.toolCalls', + () { + final msg = AssistantMessage.fromJson({ + 'id': 'a1', + 'role': 'assistant', + 'content': null, + 'toolCalls': [ + { + 'id': 'call_1', + 'type': 'function', + 'function': {'name': 'fn', 'arguments': '{"a":1}'}, + 'encryptedValue': 'cipher-camel', + }, + ], + }); + expect(msg.toolCalls!.single.encryptedValue, equals('cipher-camel')); + + final round = AssistantMessage.fromJson(msg.toJson()); + expect(round.toolCalls!.single.encryptedValue, equals('cipher-camel')); + }); + + test( + 'accepts snake_case encrypted_value alias and emits camelCase ' + 'on toJson', () { + final tc = ToolCall.fromJson({ + 'id': 'call_1', + 'type': 'function', + 'function': {'name': 'fn', 'arguments': '{}'}, + 'encrypted_value': 'cipher-snake', + }); + expect(tc.encryptedValue, equals('cipher-snake')); + expect(tc.toJson()['encryptedValue'], equals('cipher-snake')); + expect(tc.toJson().containsKey('encrypted_value'), isFalse); + }); + + test('omits encryptedValue from toJson when null', () { + final tc = ToolCall( + id: 'call_1', + function: const FunctionCall(name: 'fn', arguments: '{}'), ); - expect(copy.id, 'act_2'); - expect(copy.activityType, 'download'); - expect(copy.activityContent, {'progress': 0.9}); + expect(tc.encryptedValue, isNull); + expect(tc.toJson().containsKey('encryptedValue'), isFalse); }); - test('copyWith preserves fields when no overrides given', () { - final original = ActivityMessage( - id: 'act_1', - activityType: 'upload', - activityContent: const {'progress': 0.1}, + test('copyWith preserves encryptedValue when omitted', () { + final tc = ToolCall( + id: 'call_1', + function: const FunctionCall(name: 'fn', arguments: '{}'), + encryptedValue: 'cipher', ); - final copy = original.copyWith(); - expect(copy.id, 'act_1'); - expect(copy.activityType, 'upload'); - expect(copy.activityContent, {'progress': 0.1}); + expect(tc.copyWith(id: 'call_2').encryptedValue, equals('cipher')); + }); + + test('copyWith(encryptedValue: null) clears the field', () { + final tc = ToolCall( + id: 'call_1', + function: const FunctionCall(name: 'fn', arguments: '{}'), + encryptedValue: 'cipher', + ); + expect(tc.copyWith(encryptedValue: null).encryptedValue, isNull); + expect(tc.copyWith().encryptedValue, equals('cipher')); }); }); @@ -247,12 +595,292 @@ void main() { final message = UserMessage.fromJson(json); expect(message.id, 'msg_unknown'); expect(message.content, 'User message'); - + // Verify unknown fields are not included in serialized output final serialized = message.toJson(); expect(serialized.containsKey('unknown_field'), false); expect(serialized.containsKey('another_unknown'), false); }); }); + + group('BaseMessage.encryptedValue parity', () { + // Closes the cross-SDK parity gap noted in the #1018 review: + // canonical TS `BaseMessageSchema.encryptedValue: z.string().optional()` + // and Python `BaseMessage.encrypted_value: Optional[str]` mean every + // BaseMessage extension (Developer/System/Assistant/User/Tool) must + // round-trip the field. Before this fix, only `ToolMessage` and + // `ReasoningMessage` (the latter not strictly a BaseMessage) carried + // it; a Dart proxy decoding an `assistant.encryptedValue` from a + // TS or Python server silently dropped the value on every hop. + + test('AssistantMessage round-trips encryptedValue (camelCase)', () { + final original = AssistantMessage( + id: 'asst_001', + content: 'Routed via cipher.', + encryptedValue: 'YXNzaXN0YW50LWNpcGhlcg==', + ); + + final json = original.toJson(); + expect(json['encryptedValue'], 'YXNzaXN0YW50LWNpcGhlcg=='); + expect(json.containsKey('encrypted_value'), isFalse, + reason: 'wire output is camelCase regardless of input spelling'); + + final decoded = AssistantMessage.fromJson(json); + expect(decoded.encryptedValue, original.encryptedValue); + expect(decoded.role, MessageRole.assistant); + }); + + test('AssistantMessage accepts snake_case encrypted_value', () { + final decoded = AssistantMessage.fromJson({ + 'id': 'asst_002', + 'role': 'assistant', + 'content': 'From a Python server', + 'encrypted_value': 'cHl0aG9uLWNpcGhlcg==', + }); + expect(decoded.encryptedValue, 'cHl0aG9uLWNpcGhlcg=='); + // Re-emit on the next hop in canonical camelCase. + expect(decoded.toJson()['encryptedValue'], 'cHl0aG9uLWNpcGhlcg=='); + }); + + test('UserMessage round-trips encryptedValue (camelCase)', () { + final original = UserMessage( + id: 'user_001', + content: 'hi', + encryptedValue: 'dXNlci1jaXBoZXI=', + ); + + final json = original.toJson(); + expect(json['encryptedValue'], 'dXNlci1jaXBoZXI='); + + final decoded = UserMessage.fromJson(json); + expect(decoded.encryptedValue, original.encryptedValue); + expect(decoded.role, MessageRole.user); + }); + + test('UserMessage accepts snake_case encrypted_value', () { + final decoded = UserMessage.fromJson({ + 'id': 'user_002', + 'role': 'user', + 'content': 'hi', + 'encrypted_value': 'cHk=', + }); + expect(decoded.encryptedValue, 'cHk='); + }); + + test( + 'DeveloperMessage and SystemMessage round-trip encryptedValue ' + '(camelCase + snake_case)', () { + final dev = DeveloperMessage( + id: 'd1', + content: 'dev', + encryptedValue: 'ZGV2LWNpcGhlcg==', + ); + expect(dev.toJson()['encryptedValue'], 'ZGV2LWNpcGhlcg=='); + expect( + DeveloperMessage.fromJson(dev.toJson()).encryptedValue, + 'ZGV2LWNpcGhlcg==', + ); + expect( + DeveloperMessage.fromJson({ + 'id': 'd2', + 'role': 'developer', + 'content': 'dev', + 'encrypted_value': 'ZGV2LXNuYWtl', + }).encryptedValue, + 'ZGV2LXNuYWtl', + ); + + final sys = SystemMessage( + id: 's1', + content: 'sys', + encryptedValue: 'c3lzLWNpcGhlcg==', + ); + expect(sys.toJson()['encryptedValue'], 'c3lzLWNpcGhlcg=='); + expect( + SystemMessage.fromJson(sys.toJson()).encryptedValue, + 'c3lzLWNpcGhlcg==', + ); + expect( + SystemMessage.fromJson({ + 'id': 's2', + 'role': 'system', + 'content': 'sys', + 'encrypted_value': 'c3lzLXNuYWtl', + }).encryptedValue, + 'c3lzLXNuYWtl', + ); + }); + + test( + 'AssistantMessage.copyWith(encryptedValue: null) clears the ' + 'field; omitted argument preserves it', () { + final msg = AssistantMessage( + id: 'asst_003', + content: 'hi', + encryptedValue: 'cipher', + ); + expect(msg.copyWith(encryptedValue: null).encryptedValue, isNull); + expect(msg.copyWith().encryptedValue, equals('cipher')); + }); + + test( + 'UserMessage.copyWith(encryptedValue: null) clears the field; ' + 'omitted argument preserves it', () { + final msg = UserMessage( + id: 'user_003', + content: 'hi', + encryptedValue: 'cipher', + ); + expect(msg.copyWith(encryptedValue: null).encryptedValue, isNull); + expect(msg.copyWith().encryptedValue, equals('cipher')); + }); + + test('omits encryptedValue from toJson when null', () { + final msg = AssistantMessage(id: 'asst_004', content: 'hi'); + expect(msg.toJson().containsKey('encryptedValue'), isFalse); + }); + }); + + group('ReasoningMessage', () { + test('Message.fromJson routes role=reasoning to ReasoningMessage', () { + final msg = Message.fromJson({ + 'id': 'r1', + 'role': 'reasoning', + 'content': 'I reasoned about X', + 'thinking': 'step-by-step...', + }); + expect(msg, isA()); + expect(msg.role, MessageRole.reasoning); + }); + + test('round-trips all fields', () { + final msg = ReasoningMessage( + id: 'r1', + content: 'conclusion', + thinking: 'step-by-step', + encryptedValue: 'ENC==', + ); + final json = msg.toJson(); + expect(json['role'], 'reasoning'); + expect(json['content'], 'conclusion'); + expect(json['thinking'], 'step-by-step'); + expect(json['encryptedValue'], 'ENC=='); + + final decoded = ReasoningMessage.fromJson(json); + expect(decoded.id, 'r1'); + expect(decoded.content, 'conclusion'); + expect(decoded.thinking, 'step-by-step'); + expect(decoded.encryptedValue, 'ENC=='); + }); + + test('accepts absent optional fields', () { + final msg = ReasoningMessage.fromJson({'role': 'reasoning'}); + expect(msg.id, isNull); + expect(msg.content, isNull); + expect(msg.thinking, isNull); + expect(msg.encryptedValue, isNull); + expect(msg.toJson().containsKey('thinking'), false); + expect(msg.toJson().containsKey('encryptedValue'), false); + }); + + test('reads snake_case encrypted_value', () { + final msg = ReasoningMessage.fromJson({ + 'role': 'reasoning', + 'encrypted_value': 'SNAKE_ENC', + }); + expect(msg.encryptedValue, 'SNAKE_ENC'); + }); + + test('copyWith overrides fields', () { + final original = ReasoningMessage( + id: 'r1', + content: 'old', + thinking: 'think', + encryptedValue: 'ENC', + ); + final copy = original.copyWith(content: 'new', encryptedValue: 'ENC2'); + expect(copy.id, 'r1'); + expect(copy.content, 'new'); + expect(copy.thinking, 'think'); + expect(copy.encryptedValue, 'ENC2'); + }); + + test('MESSAGES_SNAPSHOT list containing reasoning message decodes without throwing', () { + final snapshot = [ + {'id': '1', 'role': 'user', 'content': 'hi'}, + {'id': '2', 'role': 'assistant', 'content': 'thinking...'}, + {'id': '3', 'role': 'reasoning', 'thinking': 'step 1', 'content': 'answer'}, + {'id': '4', 'role': 'assistant', 'content': 'done'}, + ]; + final messages = snapshot.map((j) => Message.fromJson(j)).toList(); + expect(messages[2], isA()); + expect((messages[2] as ReasoningMessage).thinking, 'step 1'); + }); + }); + + group('encryptedValue on Message types', () { + test('AssistantMessage round-trips encryptedValue', () { + final msg = AssistantMessage( + id: 'a1', + content: 'hello', + encryptedValue: 'ENC==', + ); + final json = msg.toJson(); + expect(json['encryptedValue'], 'ENC=='); + + final decoded = AssistantMessage.fromJson(json); + expect(decoded.encryptedValue, 'ENC=='); + }); + + test('UserMessage round-trips encryptedValue', () { + final msg = UserMessage.fromJson({ + 'id': 'u1', + 'role': 'user', + 'content': 'hi', + 'encryptedValue': 'EU==', + }); + expect(msg.encryptedValue, 'EU=='); + expect(msg.toJson()['encryptedValue'], 'EU=='); + }); + + test('ToolMessage round-trips encryptedValue', () { + final msg = ToolMessage( + id: 't1', + content: 'result', + toolCallId: 'call_1', + encryptedValue: 'ET==', + ); + final decoded = ToolMessage.fromJson(msg.toJson()); + expect(decoded.encryptedValue, 'ET=='); + }); + + test('reads snake_case encrypted_value on AssistantMessage', () { + final msg = AssistantMessage.fromJson({ + 'id': 'a2', + 'role': 'assistant', + 'content': 'hi', + 'encrypted_value': 'SNAKE_ENC', + }); + expect(msg.encryptedValue, 'SNAKE_ENC'); + }); + + test('omits encryptedValue from toJson when null', () { + final msg = AssistantMessage(id: 'a3', content: 'hi'); + expect(msg.toJson().containsKey('encryptedValue'), false); + }); + + test('copyWith threads encryptedValue through AssistantMessage', () { + final original = AssistantMessage( + id: 'a4', + content: 'original', + encryptedValue: 'ENC', + ); + final copy = original.copyWith(content: 'updated'); + expect(copy.encryptedValue, 'ENC'); + + final cleared = original.copyWith(encryptedValue: 'NEW'); + expect(cleared.encryptedValue, 'NEW'); + }); + }); }); -} \ No newline at end of file +} diff --git a/sdks/community/dart/test/types/multimodal_message_test.dart b/sdks/community/dart/test/types/multimodal_message_test.dart new file mode 100644 index 0000000000..b1d148e1e8 --- /dev/null +++ b/sdks/community/dart/test/types/multimodal_message_test.dart @@ -0,0 +1,513 @@ +import 'package:ag_ui/ag_ui.dart'; +import 'package:test/test.dart'; + +/// Extracts the `source` from any media [InputContent] part. +InputContentSource _sourceOf(InputContent part) => switch (part) { + ImageInputContent(:final source) => source, + AudioInputContent(:final source) => source, + VideoInputContent(:final source) => source, + DocumentInputContent(:final source) => source, + _ => throw StateError('not a media part: ${part.type}'), + }; + +/// Extracts the `metadata` from any media [InputContent] part. +Object? _metadataOf(InputContent part) => switch (part) { + ImageInputContent(:final metadata) => metadata, + AudioInputContent(:final metadata) => metadata, + VideoInputContent(:final metadata) => metadata, + DocumentInputContent(:final metadata) => metadata, + _ => throw StateError('not a media part: ${part.type}'), + }; + +const _mimeByModality = { + 'image': 'image/png', + 'audio': 'audio/wav', + 'video': 'video/mp4', + 'document': 'application/pdf', +}; + +void main() { + group('Multimodal messages', () { + test('parses user message with content array (text + image url)', () { + final msg = UserMessage.fromJson({ + 'id': 'user_multimodal', + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 'Check this out'}, + { + 'type': 'image', + 'source': { + 'type': 'url', + 'value': 'https://example.com/image.png', + 'mimeType': 'image/png', + }, + }, + ], + }); + + final body = msg.messageContent; + expect(body, isA()); + final parts = (body as MultimodalContent).parts; + expect(parts.length, 2); + expect(parts[0], isA()); + expect((parts[0] as TextInputContent).text, 'Check this out'); + expect(parts[1], isA()); + final source = (parts[1] as ImageInputContent).source; + expect(source, isA()); + expect((source as UrlSource).value, 'https://example.com/image.png'); + }); + + test('parses image part with inline data source and metadata', () { + final part = InputContent.fromJson({ + 'type': 'image', + 'source': { + 'type': 'data', + 'value': 'base64-value', + 'mimeType': 'image/png', + }, + 'metadata': {'detail': 'high'}, + }); + + expect(part, isA()); + final source = (part as ImageInputContent).source; + expect(source, isA()); + expect((source as DataSource).mimeType, 'image/png'); + expect(part.metadata, {'detail': 'high'}); + }); + + test('parses url source', () { + final source = InputContentSource.fromJson({ + 'type': 'url', + 'value': 'https://example.com/file.pdf', + }); + + expect(source, isA()); + expect((source as UrlSource).value, 'https://example.com/file.pdf'); + expect(source.mimeType, isNull); + }); + + test('parses data source', () { + final source = InputContentSource.fromJson({ + 'type': 'data', + 'value': 'Zm9v', + 'mimeType': 'application/pdf', + }); + + expect(source, isA()); + expect((source as DataSource).mimeType, 'application/pdf'); + }); + + test('rejects binary content without payload source', () { + expect( + () => InputContent.fromJson({'type': 'binary', 'mimeType': 'image/png'}), + throwsA(isA()), + ); + }); + + test('parses binary input with embedded data', () { + final part = InputContent.fromJson({ + 'type': 'binary', + 'mimeType': 'image/png', + 'data': 'base64', + }); + + expect(part, isA()); + expect((part as BinaryInputContent).data, 'base64'); + }); + + test('rejects binary without mimeType', () { + expect( + () => InputContent.fromJson({'type': 'binary', 'data': 'base64'}), + throwsA(isA()), + ); + }); + + test('rejects binary with empty mimeType', () { + expect( + () => InputContent.fromJson({ + 'type': 'binary', + 'mimeType': '', + 'data': 'base64', + }), + throwsA(isA()), + ); + }); + + test('parses a user message containing all modalities (order preserved)', + () { + final msg = UserMessage.fromJson({ + 'id': 'user_all_modalities', + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 'Process all inputs'}, + { + 'type': 'image', + 'source': {'type': 'url', 'value': 'https://example.com/image.png'}, + }, + { + 'type': 'audio', + 'source': {'type': 'data', 'value': 'Zm9v', 'mimeType': 'audio/wav'}, + }, + { + 'type': 'video', + 'source': {'type': 'url', 'value': 'https://example.com/video.mp4'}, + }, + { + 'type': 'document', + 'source': { + 'type': 'data', + 'value': 'YmFy', + 'mimeType': 'application/pdf', + }, + }, + ], + }); + + final parts = (msg.messageContent as MultimodalContent).parts; + expect( + parts.map((p) => p.type).toList(), + ['text', 'image', 'audio', 'video', 'document'], + ); + }); + + for (final modality in _mimeByModality.keys) { + final mime = _mimeByModality[modality]!; + group('$modality modality combinations', () { + for (final withMetadata in [true, false]) { + test('parses url source (metadata: $withMetadata)', () { + final part = InputContent.fromJson({ + 'type': modality, + 'source': { + 'type': 'url', + 'value': 'https://example.com/$modality', + 'mimeType': mime, + }, + if (withMetadata) 'metadata': {'providerHint': 'high'}, + }); + + expect(part.type, modality); + final source = _sourceOf(part); + expect(source, isA()); + expect((source as UrlSource).value, 'https://example.com/$modality'); + if (withMetadata) { + expect(_metadataOf(part), {'providerHint': 'high'}); + } else { + expect(_metadataOf(part), isNull); + } + }); + + test('parses data source (metadata: $withMetadata)', () { + final part = InputContent.fromJson({ + 'type': modality, + 'source': {'type': 'data', 'value': 'Zm9v', 'mimeType': mime}, + if (withMetadata) 'metadata': {'providerHint': 'high'}, + }); + + expect(part.type, modality); + final source = _sourceOf(part); + expect(source, isA()); + expect((source as DataSource).mimeType, mime); + if (withMetadata) { + expect(_metadataOf(part), {'providerHint': 'high'}); + } else { + expect(_metadataOf(part), isNull); + } + }); + } + + test('accepts url source without mimeType', () { + final part = InputContent.fromJson({ + 'type': modality, + 'source': {'type': 'url', 'value': 'https://example.com/$modality/raw'}, + }); + + final source = _sourceOf(part); + expect(source, isA()); + expect((source as UrlSource).mimeType, isNull); + }); + + test('rejects data source without mimeType', () { + expect( + () => InputContent.fromJson({ + 'type': modality, + 'source': {'type': 'data', 'value': 'Zm9v'}, + }), + throwsA(isA()), + ); + }); + + test('rejects missing source', () { + expect( + () => InputContent.fromJson({'type': modality}), + throwsA(isA()), + ); + }); + + test('rejects invalid source discriminator', () { + expect( + () => InputContent.fromJson({ + 'type': modality, + 'source': {'type': 'file', 'value': 'abc'}, + }), + throwsA(isA()), + ); + }); + }); + } + }); + + group('UserMessage content union', () { + test('text constructor: content getter returns the text', () { + final msg = UserMessage(id: 'u1', content: 'hello'); + expect(msg.content, 'hello'); + expect(msg.messageContent, isA()); + expect(msg.toJson()['content'], 'hello'); + }); + + test('multimodal constructor: content getter is null', () { + final msg = UserMessage.multimodal( + id: 'u1', + parts: [const TextInputContent('hi')], + ); + expect(msg.content, isNull); + expect(msg.messageContent, isA()); + expect(msg.toJson()['content'], isA>()); + }); + + test('fromJson with string content', () { + final msg = UserMessage.fromJson({ + 'id': 'u1', + 'role': 'user', + 'content': 'plain text', + }); + expect(msg.messageContent, isA()); + expect(msg.content, 'plain text'); + }); + + test('copyWith replaces messageContent', () { + final original = UserMessage(id: 'u1', content: 'first'); + final updated = original.copyWith( + messageContent: const TextContent('second'), + ); + expect(updated.id, 'u1'); + expect(updated.content, 'second'); + }); + + test('round-trip: text toJson is a String', () { + const content = TextContent('hello'); + expect(content.toJson(), 'hello'); + }); + + test('round-trip: multimodal toJson is a List of maps', () { + const content = MultimodalContent([ + TextInputContent('hi'), + ImageInputContent( + source: UrlSource(value: 'https://example.com/i.png'), + ), + ]); + final json = content.toJson(); + expect(json, isA>()); + expect((json as List).length, 2); + }); + + test('round-trip: fromJson(toJson(message)) reproduces structure', () { + final msg = UserMessage.multimodal( + id: 'u1', + parts: [ + const TextInputContent('look'), + const ImageInputContent( + source: DataSource(value: 'Zm9v', mimeType: 'image/png'), + metadata: {'detail': 'high'}, + ), + const BinaryInputContent(mimeType: 'application/pdf', data: 'YmFy'), + const AudioInputContent( + source: DataSource(value: 'YXVk', mimeType: 'audio/wav'), + metadata: {'duration': '3s'}, + ), + const VideoInputContent( + source: UrlSource(value: 'https://example.com/video.mp4'), + ), + const DocumentInputContent( + source: DataSource(value: 'ZG9j', mimeType: 'application/pdf'), + metadata: {'pages': '5'}, + ), + ], + ); + + final decoded = UserMessage.fromJson(msg.toJson()); + final parts = (decoded.messageContent as MultimodalContent).parts; + expect(parts.map((p) => p.type).toList(), + ['text', 'image', 'binary', 'audio', 'video', 'document']); + expect((parts[0] as TextInputContent).text, 'look'); + final imageSource = (parts[1] as ImageInputContent).source; + expect((imageSource as DataSource).mimeType, 'image/png'); + expect((parts[1] as ImageInputContent).metadata, {'detail': 'high'}); + expect((parts[2] as BinaryInputContent).data, 'YmFy'); + final audioSource = (parts[3] as AudioInputContent).source; + expect((audioSource as DataSource).mimeType, 'audio/wav'); + expect((parts[3] as AudioInputContent).metadata, {'duration': '3s'}); + final videoSource = (parts[4] as VideoInputContent).source; + expect((videoSource as UrlSource).value, 'https://example.com/video.mp4'); + final docSource = (parts[5] as DocumentInputContent).source; + expect((docSource as DataSource).mimeType, 'application/pdf'); + expect((parts[5] as DocumentInputContent).metadata, {'pages': '5'}); + }); + }); + + group('UserMessageContent edge cases', () { + test('empty parts list decodes to MultimodalContent', () { + final content = UserMessageContent.fromJson([]); + expect(content, isA()); + expect((content as MultimodalContent).parts, isEmpty); + }); + + test('null content is rejected', () { + expect( + () => UserMessageContent.fromJson(null), + throwsA(isA()), + ); + }); + + test('absent content key is rejected via fromJson', () { + expect( + () => UserMessage.fromJson({'id': 'u1', 'role': 'user'}), + throwsA(isA()), + ); + }); + + test('mixed valid + invalid part names the bad index', () { + expect( + () => UserMessageContent.fromJson([ + {'type': 'text', 'text': 'ok'}, + {'type': 'binary', 'mimeType': 'image/png'}, + ]), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('index 1'), + ), + ), + ); + }); + + test('unknown top-level part type is rejected', () { + expect( + () => InputContent.fromJson({'type': 'hologram'}), + throwsA(isA()), + ); + }); + + test('non-object part is rejected', () { + expect( + () => UserMessageContent.fromJson(['not-an-object']), + throwsA(isA()), + ); + }); + }); + + group('copyWith can clear optional fields', () { + test('ImageInputContent.copyWith(metadata: null) clears metadata', () { + final part = ImageInputContent( + source: UrlSource(value: 'https://example.com/img.png'), + metadata: {'key': 'value'}, + ); + expect(part.copyWith(metadata: null).metadata, isNull); + }); + + test('ImageInputContent.copyWith() without metadata preserves it', () { + final part = ImageInputContent( + source: UrlSource(value: 'https://example.com/img.png'), + metadata: {'key': 'value'}, + ); + expect(part.copyWith().metadata, {'key': 'value'}); + }); + + test('AudioInputContent.copyWith(metadata: null) clears metadata', () { + final part = AudioInputContent( + source: DataSource(value: 'base64data', mimeType: 'audio/mp3'), + metadata: {'duration': 42}, + ); + expect(part.copyWith(metadata: null).metadata, isNull); + }); + + test('VideoInputContent.copyWith(metadata: null) clears metadata', () { + final part = VideoInputContent( + source: DataSource(value: 'base64data', mimeType: 'video/mp4'), + metadata: {'fps': 30}, + ); + expect(part.copyWith(metadata: null).metadata, isNull); + }); + + test('DocumentInputContent.copyWith(metadata: null) clears metadata', () { + final part = DocumentInputContent( + source: DataSource(value: 'base64data', mimeType: 'application/pdf'), + metadata: {'pages': 10}, + ); + expect(part.copyWith(metadata: null).metadata, isNull); + }); + + test('UrlSource.copyWith(mimeType: null) clears mimeType', () { + final src = UrlSource( + value: 'https://example.com/img.png', + mimeType: 'image/png', + ); + expect(src.copyWith(mimeType: null).mimeType, isNull); + }); + + test('UrlSource.copyWith() without mimeType preserves it', () { + final src = UrlSource( + value: 'https://example.com/img.png', + mimeType: 'image/png', + ); + expect(src.copyWith().mimeType, 'image/png'); + }); + + test('BinaryInputContent.copyWith(id: null) clears id', () { + final part = BinaryInputContent( + mimeType: 'image/png', + id: 'bin_1', + data: 'base64data', + ); + expect(part.copyWith(id: null).id, isNull); + }); + + test('BinaryInputContent.copyWith() without id preserves it', () { + final part = BinaryInputContent( + mimeType: 'image/png', + id: 'bin_1', + data: 'base64data', + ); + expect(part.copyWith().id, 'bin_1'); + }); + }); + + group('snake_case mime_type tolerance', () { + test('data source accepts mime_type', () { + final source = InputContentSource.fromJson({ + 'type': 'data', + 'value': 'Zm9v', + 'mime_type': 'application/pdf', + }); + expect((source as DataSource).mimeType, 'application/pdf'); + }); + + test('url source accepts mime_type', () { + final source = InputContentSource.fromJson({ + 'type': 'url', + 'value': 'https://example.com/x', + 'mime_type': 'image/png', + }); + expect((source as UrlSource).mimeType, 'image/png'); + }); + + test('binary accepts mime_type', () { + final part = InputContent.fromJson({ + 'type': 'binary', + 'mime_type': 'image/png', + 'data': 'base64', + }); + expect((part as BinaryInputContent).mimeType, 'image/png'); + }); + }); +} diff --git a/sdks/community/dart/test/types/tool_context_test.dart b/sdks/community/dart/test/types/tool_context_test.dart index 55da7f3e79..90813774f9 100644 --- a/sdks/community/dart/test/types/tool_context_test.dart +++ b/sdks/community/dart/test/types/tool_context_test.dart @@ -110,6 +110,52 @@ void main() { final result = ToolResult.fromJson(json); expect(result.toolCallId, 'call_002'); }); + + group('ToolCall encryptedValue', () { + test('round-trips encryptedValue', () { + final call = ToolCall( + id: 'call_enc', + function: FunctionCall(name: 'fn', arguments: '{}'), + encryptedValue: 'ENC==', + ); + final json = call.toJson(); + expect(json['encryptedValue'], 'ENC=='); + + final decoded = ToolCall.fromJson(json); + expect(decoded.encryptedValue, 'ENC=='); + }); + + test('reads snake_case encrypted_value', () { + final call = ToolCall.fromJson({ + 'id': 'call_snake', + 'type': 'function', + 'function': {'name': 'fn', 'arguments': '{}'}, + 'encrypted_value': 'SNAKE_ENC', + }); + expect(call.encryptedValue, 'SNAKE_ENC'); + }); + + test('omits encryptedValue from toJson when null', () { + final call = ToolCall( + id: 'call_no_enc', + function: FunctionCall(name: 'fn', arguments: '{}'), + ); + expect(call.toJson().containsKey('encryptedValue'), false); + }); + + test('copyWith threads encryptedValue', () { + final original = ToolCall( + id: 'call_1', + function: FunctionCall(name: 'fn', arguments: '{}'), + encryptedValue: 'ENC', + ); + final copy = original.copyWith(id: 'call_2'); + expect(copy.encryptedValue, 'ENC'); + + final updated = original.copyWith(encryptedValue: 'NEW_ENC'); + expect(updated.encryptedValue, 'NEW_ENC'); + }); + }); }); group('Context Types', () { @@ -282,5 +328,52 @@ void main() { expect(modified.description, 'original'); expect(modified.value, 'value2'); }); + + test( + 'RunAgentInput.copyWith — sentinel-clear semantics for state and ' + 'forwardedProps (regression for #1018 review)', () { + // Before the sentinel sweep these fields used `?? this.field`, so a + // caller could not clear them explicitly via `copyWith(state: null)`. + // Now the sentinel allows omitted-vs-explicit-null to be distinguished. + final original = RunAgentInput( + threadId: 'thread_001', + runId: 'run_001', + state: const {'k': 'v'}, + messages: const [], + tools: const [], + context: const [], + forwardedProps: const {'fp': 1}, + ); + + // Omitted argument preserves the existing value. + final keep = original.copyWith(); + expect(keep.state, equals(const {'k': 'v'})); + expect(keep.forwardedProps, equals(const {'fp': 1})); + + // Explicit null clears each field independently. + final clearedState = original.copyWith(state: null); + expect(clearedState.state, isNull); + expect(clearedState.forwardedProps, equals(const {'fp': 1})); + + final clearedFP = original.copyWith(forwardedProps: null); + expect(clearedFP.forwardedProps, isNull); + expect(clearedFP.state, equals(const {'k': 'v'})); + }); + + test( + 'Run.copyWith(result: null) clears result; omitted preserves it ' + '(regression for #1018 review)', () { + final original = Run( + threadId: 't', + runId: 'r', + result: const {'ok': true}, + ); + + final keep = original.copyWith(); + expect(keep.result, equals(const {'ok': true})); + + final cleared = original.copyWith(result: null); + expect(cleared.result, isNull); + }); }); -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/CHANGELOG.md b/sdks/community/kotlin/CHANGELOG.md index dbaaa6322d..08b1613c9e 100644 --- a/sdks/community/kotlin/CHANGELOG.md +++ b/sdks/community/kotlin/CHANGELOG.md @@ -7,9 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.1] - 2026-06-02 + +### Added +- **Interrupts** ([AG-UI spec](https://docs.ag-ui.com/concepts/interrupts)). The Kotlin SDK now models the interrupt protocol that the TypeScript and Python SDKs already ship. Without this change a Kotlin client connected to an interrupt-aware server would either fail polymorphic deserialization of `outcome` or silently drop the interrupt payload on a `RUN_FINISHED` event. + - New types in `com.agui.core.types`: + - `Interrupt(id, reason, message?, toolCallId?, responseSchema?, expiresAt?, metadata?)` + - `ResumeStatus` enum (`RESOLVED` → `"resolved"`, `CANCELLED` → `"cancelled"`) + - `ResumeEntry(interruptId, status, payload?)` + - Sealed `RunFinishedOutcome` with `@JsonClassDiscriminator("type")`: + - `RunFinishedSuccessOutcome` (`{"type":"success"}`) + - `RunFinishedInterruptOutcome(interrupts)` (`{"type":"interrupt","interrupts":[…]}`) — `interrupts` is validated non-empty at construction. + - `RunAgentInput` gains an optional `resume: List?` field for resuming a previously interrupted run on the same `threadId`. + - `RunFinishedEvent` gains optional `result: JsonElement?` and `outcome: RunFinishedOutcome?` fields. Both default to `null`; legacy producers that omit them continue to decode unchanged, and Python `exclude_none=False` callers that emit explicit JSON `null` also decode to `null`. + - `AgUiSerializersModule` registers the two `RunFinishedOutcome` subclasses for polymorphic serialization. + - 13 new tests in `InterruptSerializationTest` covering minimal/full `Interrupt` round-trips, `ResumeEntry` status enum mapping and rejection of unknown statuses, object payloads, `RunAgentInput.resume` omit/round-trip, `RunFinishedInterruptOutcome` non-empty validation, and `RunFinishedEvent` round-trips for the legacy shape, the success outcome, the interrupt outcome (including a server-produced JSON shape), and explicit `null` outcome/result. + ### Examples - Chatapp surfaces `REASONING_*` events as a transient "💭 Reasoning…" bubble (new `MessageRole.REASONING` + `EphemeralType.REASONING`), mirroring the existing tool-call / step ephemeral pattern. Clears on `RUN_FINISHED`, run cancel, or run error. Handles `REASONING_START` / `REASONING_END`, `REASONING_MESSAGE_START` / `REASONING_MESSAGE_CONTENT` / `REASONING_MESSAGE_END`, and `REASONING_MESSAGE_CHUNK`. -- Bump all Kotlin sample apps (chatapp, chatapp-java, chatapp-wearos, chatapp-swiftui, tools) from `agui-core 0.3.0` to `0.4.0` and consume the published artefacts from Maven by removing the `includeBuild("../../library")` + dependencySubstitution blocks from the four chatapp variants' settings files. +- Bump all Kotlin sample apps (chatapp, chatapp-java, chatapp-wearos, chatapp-swiftui, tools) from `agui-core 0.3.0` to `0.4.1` and consume the published artefacts from Maven by removing the `includeBuild("../../library")` + dependencySubstitution blocks from the four chatapp variants' settings files. - Bump chatapp Kotlin `2.1.20 → 2.2.20` so the iOS targets can consume `com.mikepenz:multiplatform-markdown-renderer-m3:0.37.0` klibs (require ABI 2.2.0+). ## [0.4.0] - 2026-05-12 diff --git a/sdks/community/kotlin/examples/chatapp-java/gradle/libs.versions.toml b/sdks/community/kotlin/examples/chatapp-java/gradle/libs.versions.toml index 67c273ffc1..c58b7b58db 100644 --- a/sdks/community/kotlin/examples/chatapp-java/gradle/libs.versions.toml +++ b/sdks/community/kotlin/examples/chatapp-java/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activity-compose = "1.10.1" -agui-core = "0.4.0" +agui-core = "0.4.1" a2ui4k = "0.8.1" appcompat = "1.7.1" core = "1.6.1" diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/gradle/libs.versions.toml b/sdks/community/kotlin/examples/chatapp-swiftui/gradle/libs.versions.toml index 1502a03186..78aec2a2ce 100644 --- a/sdks/community/kotlin/examples/chatapp-swiftui/gradle/libs.versions.toml +++ b/sdks/community/kotlin/examples/chatapp-swiftui/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activity-compose = "1.10.1" -agui-core = "0.4.0" +agui-core = "0.4.1" appcompat = "1.7.1" core = "1.6.1" core-ktx = "1.16.0" diff --git a/sdks/community/kotlin/examples/chatapp-wearos/gradle/libs.versions.toml b/sdks/community/kotlin/examples/chatapp-wearos/gradle/libs.versions.toml index 18eb049466..ddd1e3839c 100644 --- a/sdks/community/kotlin/examples/chatapp-wearos/gradle/libs.versions.toml +++ b/sdks/community/kotlin/examples/chatapp-wearos/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activity-compose = "1.10.1" -agui-core = "0.4.0" +agui-core = "0.4.1" appcompat = "1.7.1" core = "1.6.1" core-ktx = "1.16.0" diff --git a/sdks/community/kotlin/examples/chatapp/gradle/libs.versions.toml b/sdks/community/kotlin/examples/chatapp/gradle/libs.versions.toml index be47b0a6b8..4eac63ce7c 100644 --- a/sdks/community/kotlin/examples/chatapp/gradle/libs.versions.toml +++ b/sdks/community/kotlin/examples/chatapp/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activity-compose = "1.10.1" -agui-core = "0.4.0" +agui-core = "0.4.1" appcompat = "1.7.1" core = "1.6.1" core-ktx = "1.16.0" diff --git a/sdks/community/kotlin/examples/tools/gradle/libs.versions.toml b/sdks/community/kotlin/examples/tools/gradle/libs.versions.toml index 18aa2e81f9..3d18a2b8f6 100644 --- a/sdks/community/kotlin/examples/tools/gradle/libs.versions.toml +++ b/sdks/community/kotlin/examples/tools/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activity-compose = "1.10.1" -agui-core = "0.4.0" +agui-core = "0.4.1" appcompat = "1.7.1" core = "1.6.1" core-ktx = "1.16.0" diff --git a/sdks/community/kotlin/library/build.gradle.kts b/sdks/community/kotlin/library/build.gradle.kts index a2e97a738a..6baf7b2e2b 100644 --- a/sdks/community/kotlin/library/build.gradle.kts +++ b/sdks/community/kotlin/library/build.gradle.kts @@ -27,7 +27,7 @@ plugins { // - publish.sh script (reads dynamically) // - GitHub Actions workflow (reads dynamically) // Only update these values here - they propagate automatically -version = "0.4.0" +version = "0.4.1" group = "com.ag-ui.community" allprojects { diff --git a/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/AgUiSerializersModule.kt b/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/AgUiSerializersModule.kt index 3237bbca53..d7169c7e6b 100644 --- a/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/AgUiSerializersModule.kt +++ b/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/AgUiSerializersModule.kt @@ -45,5 +45,11 @@ val AgUiSerializersModule by lazy { subclass(UserMessage::class) subclass(ToolMessage::class) } + + // Polymorphic serialization for RUN_FINISHED outcomes + polymorphic(RunFinishedOutcome::class) { + subclass(RunFinishedSuccessOutcome::class) + subclass(RunFinishedInterruptOutcome::class) + } } } \ No newline at end of file diff --git a/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Events.kt b/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Events.kt index 9a2c414384..6c1277e68c 100644 --- a/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Events.kt +++ b/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Events.kt @@ -200,6 +200,51 @@ sealed class BaseEvent { abstract val rawEvent: JsonElement? } +// ============== Run Outcomes (2) ============== + +/** + * Discriminated union describing how a run terminated. Carried in the optional + * [RunFinishedEvent.outcome] field. The wire-level `"type"` field discriminates + * between successful completion ([RunFinishedSuccessOutcome]) and an + * interrupt-driven pause ([RunFinishedInterruptOutcome]). + * + * Producers written before the interrupt-aware run lifecycle simply omit the + * `outcome` field on `RunFinishedEvent`; newer producers set it explicitly. + * + * @see AG-UI Interrupts + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("type") +sealed class RunFinishedOutcome + +/** + * Outcome variant signalling that a run completed normally. + */ +@Serializable +@SerialName("success") +data object RunFinishedSuccessOutcome : RunFinishedOutcome() + +/** + * Outcome variant signalling that a run paused on one or more interrupts. + * + * The client resumes by addressing every open interrupt in + * [RunAgentInput.resume] of the next request, reusing the same `threadId`. + * + * @param interrupts The pending interrupts; must be non-empty. + */ +@Serializable +@SerialName("interrupt") +data class RunFinishedInterruptOutcome( + val interrupts: List +) : RunFinishedOutcome() { + init { + require(interrupts.isNotEmpty()) { + "outcome 'interrupt' requires at least one interrupt" + } + } +} + // ============== Lifecycle Events (5) ============== /** @@ -227,21 +272,31 @@ data class RunStartedEvent( } /** - * Event indicating that an agent run has completed successfully. - * - * This event is emitted when an agent has finished processing a run request - * and has generated all output. It signals the end of the execution lifecycle. - * - * @param threadId The identifier for the conversation thread - * @param runId The unique identifier for the completed run - * @param timestamp Optional timestamp when the run finished - * @param rawEvent Optional raw JSON representation of the event + * Event indicating that an agent run has completed. + * + * This event is emitted when an agent has finished processing a run request. + * Whether the run completed successfully or paused on interrupts is signalled + * by the optional [outcome] field. Producers written before the interrupt-aware + * run lifecycle simply omit [outcome] (legacy back-compat); newer producers set + * it to [RunFinishedSuccessOutcome] or [RunFinishedInterruptOutcome]. + * + * @param threadId The identifier for the conversation thread. + * @param runId The unique identifier for the completed run. + * @param result Optional terminal value produced by the run. + * @param outcome Optional discriminated outcome of the run; see [RunFinishedOutcome]. + * @param timestamp Optional timestamp when the run finished. + * @param rawEvent Optional raw JSON representation of the event. + * + * @see RunFinishedOutcome + * @see AG-UI Interrupts */ @Serializable @SerialName("RUN_FINISHED") data class RunFinishedEvent( val threadId: String, val runId: String, + val result: JsonElement? = null, + val outcome: RunFinishedOutcome? = null, override val timestamp: Long? = null, override val rawEvent: JsonElement? = null ) : BaseEvent () { diff --git a/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Types.kt b/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Types.kt index 1c941ca2c4..8ea99dd41b 100644 --- a/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Types.kt +++ b/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Types.kt @@ -433,5 +433,79 @@ data class RunAgentInput( val messages: List = emptyList(), val tools: List = emptyList(), val context: List = emptyList(), - val forwardedProps: JsonElement = JsonObject(emptyMap()) + val forwardedProps: JsonElement = JsonObject(emptyMap()), + /** + * Per-interrupt responses sent when resuming a previously interrupted run. + * + * Each entry addresses an [Interrupt] from the prior `RunFinishedEvent` whose + * `outcome` was a [com.agui.core.types.RunFinishedInterruptOutcome]. The same + * `threadId` must be reused. Omitted when not resuming. + * + * @see Interrupt + * @see ResumeEntry + * @see AG-UI Interrupts + */ + val resume: List? = null +) + +// ============== Interrupts ============== + +/** + * A pause carried inside [com.agui.core.types.RunFinishedInterruptOutcome] when a + * run finishes on one or more interrupts. The client resumes by addressing this + * interrupt in the [RunAgentInput.resume] array of the next request, using the + * same `threadId`. + * + * @param id Stable identifier of this interrupt; echoed back as [ResumeEntry.interruptId]. + * @param reason Machine-readable reason describing why the agent paused + * (e.g. `"tool_call"`, `"human_approval"`). + * @param message Optional human-readable explanation suitable for surfacing to the user. + * @param toolCallId Optional tool-call this interrupt is associated with. + * @param responseSchema Optional JSON Schema describing the expected shape of + * [ResumeEntry.payload]. Agents MAY validate the payload against this. + * @param expiresAt Optional ISO-8601 timestamp after which a resume MUST NOT be submitted. + * @param metadata Optional opaque metadata for the agent / UI to use. + * + * @see AG-UI Interrupts + */ +@Serializable +data class Interrupt( + val id: String, + val reason: String, + val message: String? = null, + val toolCallId: String? = null, + val responseSchema: JsonElement? = null, + val expiresAt: String? = null, + val metadata: JsonElement? = null +) + +/** + * Status of a [ResumeEntry]. + * + * - [RESOLVED]: the user provided a response (typically with a `payload`). + * - [CANCELLED]: the user abandoned the interrupt without providing input. + */ +@Serializable +enum class ResumeStatus { + @SerialName("resolved") + RESOLVED, + @SerialName("cancelled") + CANCELLED +} + +/** + * A per-interrupt response in [RunAgentInput.resume]. + * + * @param interruptId The [Interrupt.id] this entry resolves. + * @param status See [ResumeStatus]. + * @param payload Optional response value. Shape is dictated by the originating + * interrupt's [Interrupt.responseSchema] (if any). + * + * @see AG-UI Interrupts + */ +@Serializable +data class ResumeEntry( + val interruptId: String, + val status: ResumeStatus, + val payload: JsonElement? = null ) diff --git a/sdks/community/kotlin/library/core/src/commonTest/kotlin/com/agui/tests/InterruptSerializationTest.kt b/sdks/community/kotlin/library/core/src/commonTest/kotlin/com/agui/tests/InterruptSerializationTest.kt new file mode 100644 index 0000000000..c4e1491377 --- /dev/null +++ b/sdks/community/kotlin/library/core/src/commonTest/kotlin/com/agui/tests/InterruptSerializationTest.kt @@ -0,0 +1,315 @@ +package com.agui.tests + +import com.agui.core.types.* +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.* +import kotlin.test.* + +/** + * Coverage for the AG-UI interrupt protocol additions: + * - [Interrupt], [ResumeStatus], [ResumeEntry] + * - [RunAgentInput.resume] + * - [RunFinishedEvent.outcome] / [RunFinishedEvent.result] + * - [RunFinishedOutcome] discriminated union ([RunFinishedSuccessOutcome] / [RunFinishedInterruptOutcome]) + * + * Mirrors TS (`sdks/typescript/packages/core/src/__tests__/interrupts.test.ts`) + * and Python interrupt tests. + * + * @see AG-UI Interrupts + */ +class InterruptSerializationTest { + + private val json = AgUiJson + + // ========== Interrupt ========== + + @Test + fun testInterruptMinimalRoundTrip() { + val interrupt = Interrupt(id = "int-1", reason = "tool_call") + + val jsonString = json.encodeToString(Interrupt.serializer(), interrupt) + val jsonObj = json.parseToJsonElement(jsonString).jsonObject + + assertEquals("int-1", jsonObj["id"]?.jsonPrimitive?.content) + assertEquals("tool_call", jsonObj["reason"]?.jsonPrimitive?.content) + // explicitNulls = false → optional null fields must be omitted + assertFalse(jsonObj.containsKey("message")) + assertFalse(jsonObj.containsKey("toolCallId")) + assertFalse(jsonObj.containsKey("responseSchema")) + assertFalse(jsonObj.containsKey("expiresAt")) + assertFalse(jsonObj.containsKey("metadata")) + + val decoded = json.decodeFromString(Interrupt.serializer(), jsonString) + assertEquals(interrupt, decoded) + } + + @Test + fun testInterruptFullRoundTrip() { + val schema = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("approved", buildJsonObject { put("type", "boolean") }) + }) + } + val metadata = buildJsonObject { + put("priority", "high") + put("retries", 0) + } + val interrupt = Interrupt( + id = "int-1", + reason = "human_approval", + message = "Please confirm before sending the email.", + toolCallId = "call-42", + responseSchema = schema, + expiresAt = "2026-05-27T12:00:00Z", + metadata = metadata + ) + + val jsonString = json.encodeToString(Interrupt.serializer(), interrupt) + val decoded = json.decodeFromString(Interrupt.serializer(), jsonString) + assertEquals(interrupt, decoded) + } + + // ========== ResumeEntry / ResumeStatus ========== + + @Test + fun testResumeEntryResolvedRoundTrip() { + val entry = ResumeEntry( + interruptId = "int-1", + status = ResumeStatus.RESOLVED, + payload = JsonPrimitive("ok") + ) + + val jsonString = json.encodeToString(ResumeEntry.serializer(), entry) + val jsonObj = json.parseToJsonElement(jsonString).jsonObject + + assertEquals("int-1", jsonObj["interruptId"]?.jsonPrimitive?.content) + assertEquals("resolved", jsonObj["status"]?.jsonPrimitive?.content) + assertEquals("ok", jsonObj["payload"]?.jsonPrimitive?.content) + + val decoded = json.decodeFromString(ResumeEntry.serializer(), jsonString) + assertEquals(entry, decoded) + } + + @Test + fun testResumeEntryCancelledRoundTrip() { + val entry = ResumeEntry( + interruptId = "int-1", + status = ResumeStatus.CANCELLED + ) + + val jsonString = json.encodeToString(ResumeEntry.serializer(), entry) + val jsonObj = json.parseToJsonElement(jsonString).jsonObject + + assertEquals("cancelled", jsonObj["status"]?.jsonPrimitive?.content) + assertFalse(jsonObj.containsKey("payload")) + + val decoded = json.decodeFromString(ResumeEntry.serializer(), jsonString) + assertEquals(entry, decoded) + } + + @Test + fun testResumeEntryRejectsUnknownStatus() { + val malformed = """{"interruptId":"int-1","status":"denied"}""" + assertFailsWith { + json.decodeFromString(ResumeEntry.serializer(), malformed) + } + } + + @Test + fun testResumeEntryAcceptsObjectPayload() { + val payload = buildJsonObject { + put("approved", true) + put("note", "looks good") + } + val entry = ResumeEntry( + interruptId = "int-1", + status = ResumeStatus.RESOLVED, + payload = payload + ) + + val jsonString = json.encodeToString(ResumeEntry.serializer(), entry) + val decoded = json.decodeFromString(ResumeEntry.serializer(), jsonString) + assertEquals(payload, decoded.payload) + } + + // ========== RunAgentInput.resume ========== + + @Test + fun testRunAgentInputResumeOmittedWhenNull() { + val input = RunAgentInput(threadId = "t", runId = "r") + + val jsonString = json.encodeToString(input) + val jsonObj = json.parseToJsonElement(jsonString).jsonObject + + // explicitNulls = false → resume must not appear when null + assertFalse(jsonObj.containsKey("resume")) + + val decoded = json.decodeFromString(jsonString) + assertNull(decoded.resume) + } + + @Test + fun testRunAgentInputResumeRoundTrip() { + val input = RunAgentInput( + threadId = "t", + runId = "r", + resume = listOf( + ResumeEntry("int-1", ResumeStatus.RESOLVED, JsonPrimitive("yes")), + ResumeEntry("int-2", ResumeStatus.CANCELLED) + ) + ) + + val jsonString = json.encodeToString(input) + val jsonObj = json.parseToJsonElement(jsonString).jsonObject + val resumeArr = jsonObj["resume"]?.jsonArray + assertNotNull(resumeArr) + assertEquals(2, resumeArr.size) + assertEquals("int-1", resumeArr[0].jsonObject["interruptId"]?.jsonPrimitive?.content) + assertEquals("resolved", resumeArr[0].jsonObject["status"]?.jsonPrimitive?.content) + assertEquals("cancelled", resumeArr[1].jsonObject["status"]?.jsonPrimitive?.content) + + val decoded = json.decodeFromString(jsonString) + assertEquals(input, decoded) + } + + // ========== RunFinishedOutcome ========== + + @Test + fun testRunFinishedInterruptOutcomeRejectsEmptyList() { + assertFailsWith { + RunFinishedInterruptOutcome(interrupts = emptyList()) + } + } + + // ========== RunFinishedEvent.outcome ========== + + @Test + fun testRunFinishedEventLegacyShapeRoundTrip() { + // Legacy producer: no result, no outcome. + val event = RunFinishedEvent(threadId = "t", runId = "r") + + val jsonString = json.encodeToString(event) + val jsonObj = json.parseToJsonElement(jsonString).jsonObject + + assertEquals("RUN_FINISHED", jsonObj["type"]?.jsonPrimitive?.content) + assertFalse(jsonObj.containsKey("outcome")) + assertFalse(jsonObj.containsKey("result")) + + val decoded = json.decodeFromString(jsonString) + assertTrue(decoded is RunFinishedEvent) + assertNull(decoded.outcome) + assertNull(decoded.result) + } + + @Test + fun testRunFinishedEventSuccessOutcomeSerialization() { + val event = RunFinishedEvent( + threadId = "t", + runId = "r", + outcome = RunFinishedSuccessOutcome + ) + + val jsonString = json.encodeToString(event) + val outcomeObj = json.parseToJsonElement(jsonString).jsonObject["outcome"]?.jsonObject + assertNotNull(outcomeObj) + // Schema is exactly `{ type: "success" }` — discriminator only, no extra keys. + assertEquals(setOf("type"), outcomeObj.keys) + assertEquals("success", outcomeObj["type"]?.jsonPrimitive?.content) + + val decoded = json.decodeFromString(jsonString) + assertTrue(decoded is RunFinishedEvent) + assertEquals(RunFinishedSuccessOutcome, decoded.outcome) + } + + @Test + fun testRunFinishedEventInterruptOutcomeSerialization() { + val interrupts = listOf( + Interrupt(id = "int-1", reason = "tool_call"), + Interrupt(id = "int-2", reason = "human_approval", message = "ok?") + ) + val event = RunFinishedEvent( + threadId = "t", + runId = "r", + outcome = RunFinishedInterruptOutcome(interrupts) + ) + + val jsonString = json.encodeToString(event) + val outcomeObj = json.parseToJsonElement(jsonString).jsonObject["outcome"]?.jsonObject + assertNotNull(outcomeObj) + assertEquals("interrupt", outcomeObj["type"]?.jsonPrimitive?.content) + val emittedInterrupts = outcomeObj["interrupts"]?.jsonArray + assertNotNull(emittedInterrupts) + assertEquals(2, emittedInterrupts.size) + assertEquals("int-1", emittedInterrupts[0].jsonObject["id"]?.jsonPrimitive?.content) + + val decoded = json.decodeFromString(jsonString) + assertTrue(decoded is RunFinishedEvent) + val outcome = decoded.outcome + assertTrue(outcome is RunFinishedInterruptOutcome) + assertEquals(interrupts, outcome.interrupts) + } + + @Test + fun testRunFinishedEventWithResultRoundTrip() { + val result = buildJsonObject { + put("answer", 42) + put("note", "ok") + } + val event = RunFinishedEvent( + threadId = "t", + runId = "r", + result = result, + outcome = RunFinishedSuccessOutcome + ) + + val jsonString = json.encodeToString(event) + val decoded = json.decodeFromString(jsonString) + assertTrue(decoded is RunFinishedEvent) + assertEquals(result, decoded.result) + assertEquals(RunFinishedSuccessOutcome, decoded.outcome) + } + + @Test + fun testRunFinishedEventAcceptsExplicitNullOutcome() { + // Python `exclude_none=False` callers serialize the optional outcome as + // JSON `null`. The Kotlin SDK must accept that and normalize to null. + val rawJson = """{ + "type":"RUN_FINISHED", + "threadId":"t", + "runId":"r", + "outcome":null, + "result":null + }""".trimIndent() + + val decoded = json.decodeFromString(rawJson) + assertTrue(decoded is RunFinishedEvent) + assertNull(decoded.outcome) + assertNull(decoded.result) + } + + @Test + fun testRunFinishedEventDecodesServerProducedInterruptShape() { + // Sanity check that the JSON shape a TS/Python server emits decodes + // cleanly through the polymorphic BaseEvent dispatch. + val rawJson = """{ + "type":"RUN_FINISHED", + "threadId":"t", + "runId":"r", + "outcome":{ + "type":"interrupt", + "interrupts":[ + {"id":"i1","reason":"tool_call"} + ] + } + }""".trimIndent() + + val decoded = json.decodeFromString(rawJson) + assertTrue(decoded is RunFinishedEvent) + val outcome = decoded.outcome + assertTrue(outcome is RunFinishedInterruptOutcome) + assertEquals(1, outcome.interrupts.size) + assertEquals("i1", outcome.interrupts[0].id) + assertEquals("tool_call", outcome.interrupts[0].reason) + } +} diff --git a/sdks/community/ruby/LICENSE b/sdks/community/ruby/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/sdks/community/ruby/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdks/community/ruby/ag-ui-protocol.gemspec b/sdks/community/ruby/ag-ui-protocol.gemspec index 058e2f39e6..054cd65887 100644 --- a/sdks/community/ruby/ag-ui-protocol.gemspec +++ b/sdks/community/ruby/ag-ui-protocol.gemspec @@ -11,7 +11,7 @@ Gem::Specification.new do |spec| spec.license = "MIT" spec.homepage = "https://docs.ag-ui.com/introduction" - spec.files = Dir.glob("{lib}/**/*") + spec.files = Dir.glob("{lib}/**/*") + ["LICENSE"] spec.require_paths = ["lib"] spec.required_ruby_version = ">= 3.0" diff --git a/sdks/python/a2ui_toolkit/LICENSE b/sdks/python/a2ui_toolkit/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/sdks/python/a2ui_toolkit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdks/python/a2ui_toolkit/README.md b/sdks/python/a2ui_toolkit/README.md new file mode 100644 index 0000000000..daf7e0b0c5 --- /dev/null +++ b/sdks/python/a2ui_toolkit/README.md @@ -0,0 +1,26 @@ +# ag-ui-a2ui-toolkit + +Framework-agnostic helpers for building A2UI subagent tools. + +Each per-framework adapter (LangGraph, ADK, Mastra, …) composes these helpers +with its own framework-specific glue: tool decorator, runtime accessor, model +binding + invoke. Nothing in this package depends on any agent framework. + +## Surface + +- Constants: `A2UI_OPERATIONS_KEY`, `BASIC_CATALOG_ID`, `DEFAULT_SURFACE_ID`, + `GENERATE_A2UI_TOOL_NAME`, `GENERATE_A2UI_TOOL_DESCRIPTION`, + `GENERATE_A2UI_ARG_DESCRIPTIONS` +- Op builders: `create_surface`, `update_components`, `update_data_model` +- `RENDER_A2UI_TOOL_DEF` — JSON schema for the inner structured-output tool +- State + history helpers: `build_context_prompt`, `find_prior_surface` +- Prompt composer: `build_subagent_prompt` +- High-level orchestration: `prepare_a2ui_request`, `build_a2ui_envelope` +- Output wrappers: `assemble_ops`, `wrap_as_operations_envelope`, + `wrap_error_envelope` + +## See also + +The TypeScript counterpart lives in +[`@ag-ui/a2ui-toolkit`](../../typescript/packages/a2ui-toolkit) and exposes the +same surface in camelCase. diff --git a/sdks/python/a2ui_toolkit/ag_ui_a2ui_toolkit/__init__.py b/sdks/python/a2ui_toolkit/ag_ui_a2ui_toolkit/__init__.py new file mode 100644 index 0000000000..e369928ac3 --- /dev/null +++ b/sdks/python/a2ui_toolkit/ag_ui_a2ui_toolkit/__init__.py @@ -0,0 +1,951 @@ +""" +ag-ui-a2ui-toolkit +================== + +Framework-agnostic building blocks for A2UI subagent tools. Each per- +framework adapter (LangGraph, ADK, Mastra, …) composes these helpers with its +framework-specific glue (tool decorator, runtime accessor, model binding + +invoke). Nothing in this package depends on any agent framework. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Optional, TypedDict + + +__all__ = [ + "A2UI_OPERATIONS_KEY", + "BASIC_CATALOG_ID", + "A2UI_SCHEMA_CONTEXT_DESCRIPTION", + "split_a2ui_schema_context", + "resolve_a2ui_catalog", + "RENDER_A2UI_TOOL_DEF", + "DEFAULT_SURFACE_ID", + "GENERATE_A2UI_TOOL_NAME", + "GENERATE_A2UI_TOOL_DESCRIPTION", + "GENERATE_A2UI_ARG_DESCRIPTIONS", + "create_surface", + "update_components", + "update_data_model", + "build_context_prompt", + "find_prior_surface", + "build_subagent_prompt", + "A2UIGuidelines", + "DEFAULT_GENERATION_GUIDELINES", + "DEFAULT_DESIGN_GUIDELINES", + "A2UIToolParams", + "ResolvedA2UIToolParams", + "resolve_a2ui_tool_params", + "assemble_ops", + "wrap_as_operations_envelope", + "wrap_error_envelope", + "prepare_a2ui_request", + "build_a2ui_envelope", + "PriorSurface", + "EditContext", + "PreparedA2UIRequest", + # Error-recovery loop (OSS-162) + "validate_a2ui_components", + "A2UIValidationError", + "ValidateA2UIResult", + "MAX_A2UI_ATTEMPTS", + "A2UI_RECOVERY_ACTIVITY_TYPE", + "format_validation_errors", + "augment_prompt_with_validation_errors", + "run_a2ui_generation_with_recovery", +] + +# Error-recovery loop (OSS-162) — semantic validation + validate→retry loop, +# shared so the middleware (paint gate) and adapters (retry driver) agree. +from .validate import ( # noqa: E402 + validate_a2ui_components, + A2UIValidationError, + ValidateA2UIResult, +) +from .recovery import ( # noqa: E402 + MAX_A2UI_ATTEMPTS, + A2UI_RECOVERY_ACTIVITY_TYPE, + format_validation_errors, + augment_prompt_with_validation_errors, + run_a2ui_generation_with_recovery, +) + + +A2UI_OPERATIONS_KEY = "a2ui_operations" +"""Container key the A2UI middleware looks for in tool results.""" + +BASIC_CATALOG_ID = "https://a2ui.org/specification/v0_9/basic_catalog.json" +"""Default catalog id used when the subagent does not specify one.""" + +A2UI_SCHEMA_CONTEXT_DESCRIPTION = ( + "A2UI Component Schema — available components for generating UI surfaces. " + "Use these component names and properties when creating A2UI operations." +) +"""Context-entry description the ``@ag-ui/a2ui-middleware`` stamps onto the A2UI +component schema it injects into ``RunAgentInput.context``. Single home for the +constant so every framework adapter splits on the same string. MUST stay +byte-identical to ``A2UI_SCHEMA_CONTEXT_DESCRIPTION`` in +``@ag-ui/a2ui-middleware`` (the TypeScript twin cannot import this Python copy). +``split_a2ui_schema_context`` matches it by exact equality — any drift silently +routes the schema into the generic context block instead of +``## Available Components``.""" + + +# --------------------------------------------------------------------------- +# Op builders +# --------------------------------------------------------------------------- + + +def create_surface(surface_id: str, catalog_id: str) -> dict[str, Any]: + return { + "version": "v0.9", + "createSurface": {"surfaceId": surface_id, "catalogId": catalog_id}, + } + + +def update_components( + surface_id: str, components: list[dict[str, Any]] +) -> dict[str, Any]: + return { + "version": "v0.9", + "updateComponents": {"surfaceId": surface_id, "components": components}, + } + + +def update_data_model( + surface_id: str, data: Any, path: str = "/" +) -> dict[str, Any]: + return { + "version": "v0.9", + "updateDataModel": {"surfaceId": surface_id, "path": path, "value": data}, + } + + +# --------------------------------------------------------------------------- +# Inner render_a2ui tool definition +# --------------------------------------------------------------------------- + +RENDER_A2UI_TOOL_DEF: dict[str, Any] = { + "type": "function", + "function": { + "name": "render_a2ui", + "description": ( + "Render a dynamic A2UI v0.9 surface. The root component must have " + "id 'root'. Use components from the available catalog only." + ), + "parameters": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + "description": "Unique surface identifier.", + }, + "components": { + "type": "array", + "description": ( + "A2UI v0.9 component array (flat format). The root " + "component must have id 'root'." + ), + "items": {"type": "object"}, + }, + "data": { + "type": "object", + "description": ( + "Optional initial data model for the surface (form " + "values, list items for data-bound components, etc.)." + ), + }, + }, + "required": ["surfaceId", "components"], + }, + }, +} +"""JSON schema for the inner ``render_a2ui`` tool the subagent is forced to call.""" + + +# --------------------------------------------------------------------------- +# State helpers +# --------------------------------------------------------------------------- + + +def build_context_prompt(state: dict) -> str: + """Assemble the prompt prefix from AG-UI state context entries + the A2UI + component catalog. + + Framework integrations conventionally extract the catalog into + ``state["ag-ui"]["a2ui_schema"]`` and forward other context entries + (generation guidelines, design guidelines) under + ``state["ag-ui"]["context"]``. + """ + ag_ui = state.get("ag-ui", {}) or {} + parts: list[str] = [] + + for entry in ag_ui.get("context", []) or []: + if isinstance(entry, dict): + desc = entry.get("description") + value = entry.get("value") + else: + desc = getattr(entry, "description", None) + value = getattr(entry, "value", None) + # Mirror the TS toolkit: a null/None value with a description must NOT + # leak the literal string "None" into the subagent prompt. f-string + # interpolation would do that — coerce to "" first. + value_str = "" if value is None else str(value) + if desc: + parts.append(f"## {desc}\n{value_str}\n") + elif value_str: + parts.append(f"{value_str}\n") + + a2ui_schema = ag_ui.get("a2ui_schema") + if a2ui_schema: + parts.append(f"## Available Components\n{a2ui_schema}\n") + + return "\n".join(parts) + + +def split_a2ui_schema_context(context: Optional[list]) -> tuple: + """Split AG-UI context entries into the A2UI component-schema entry and the + rest. The schema entry is the one whose ``description`` exactly equals + ``A2UI_SCHEMA_CONTEXT_DESCRIPTION`` (stamped by ``@ag-ui/a2ui-middleware``). + + Returns ``(schema_value, regular_context)``: framework adapters route + ``schema_value`` to ``state["ag-ui"]["a2ui_schema"]`` (rendered as + ``## Available Components`` by ``build_context_prompt``) and + ``regular_context`` to ``state["ag-ui"]["context"]``. ``schema_value`` is + ``None`` when no schema entry is present. Entries are returned unchanged + (dicts or objects exposing ``.description``/``.value``) — the same dual + shape ``build_context_prompt`` already tolerates. + """ + schema_value = None + regular_context: list = [] + for entry in context or []: + if isinstance(entry, dict): + description = entry.get("description") + value = entry.get("value") + else: + description = getattr(entry, "description", None) + value = getattr(entry, "value", None) + if description == A2UI_SCHEMA_CONTEXT_DESCRIPTION: + schema_value = value + else: + regular_context.append(entry) + return schema_value, regular_context + + +def resolve_a2ui_catalog(state: dict) -> "Optional[tuple]": + """Find the frontend-registered A2UI catalog in run ``state``, returning + ``(component_schema, catalog_id)`` — or ``None`` when no catalog is present + (so the adapter falls back to its configured default / the basic catalog). + + Framework-agnostic, so every adapter resolves the catalog the same way + instead of each reimplementing it. Two delivery paths are supported because + the catalog lands in different places depending on how the agent is served: + + Both live under ``state["ag-ui"]`` — the canonical key every adapter + populates: + + - **Schema entry** → ``state["ag-ui"]["a2ui_schema"]``, a JSON string + ``{"catalogId": ..., "components": [...]}`` (routed there from + ``RunAgentInput.context`` by ``split_a2ui_schema_context``). The toolkit + reads ``a2ui_schema`` from state for the prompt itself, so only the + ``catalog_id`` is surfaced here (``component_schema`` is ``None``). + - **Catalog context entry** → an ``state["ag-ui"]["context"]`` entry whose + description mentions ``"A2UI catalog"`` (catalog id + component schemas as + text); the value lists catalogs as ``"- "`` lines, the first + being the custom catalog the client registered. + + ``component_schema`` becomes the sub-agent ``composition_guide``; + ``catalog_id`` becomes ``default_catalog_id`` so generated surfaces bind to + the frontend's catalog (BYOC custom catalogs render their own components, + not the basic one). + """ + ag_ui = state.get("ag-ui") or {} + a2ui_schema = ag_ui.get("a2ui_schema") + if a2ui_schema: + catalog_id = None + try: + parsed = ( + json.loads(a2ui_schema) + if isinstance(a2ui_schema, str) + else a2ui_schema + ) + if isinstance(parsed, dict): + catalog_id = parsed.get("catalogId") + except (TypeError, ValueError): + pass + return None, catalog_id + + context = ag_ui.get("context") or [] + for entry in context: + if not isinstance(entry, dict): + continue + description = entry.get("description") or "" + value = entry.get("value") or "" + if "A2UI catalog" not in description or not value: + continue + match = re.search(r"(?m)^\s*-\s+(\S+)", value) + catalog_id = match.group(1) if match else None + return value, catalog_id + + return None + + +# --------------------------------------------------------------------------- +# Prior surface lookup (used for intent="update") +# --------------------------------------------------------------------------- + + +class PriorSurface(TypedDict, total=False): + components: list[dict[str, Any]] + data: Any + catalogId: Optional[str] + + +def _message_role_and_content(msg: Any) -> tuple[Optional[str], Any]: + """Read a message's role/type and content from either an object or a dict. + + LangChain ToolMessage instances expose ``.type``/``.role``/``.content`` as + attributes; messages that round-tripped through JSON arrive as plain dicts. + Either shape needs to work — the prior-surface walker must not silently skip + dict-shaped history. + """ + if isinstance(msg, dict): + role = msg.get("type") or msg.get("role") + return role, msg.get("content") + return ( + getattr(msg, "type", None) or getattr(msg, "role", None), + getattr(msg, "content", None), + ) + + +def find_prior_surface( + messages: list[Any], surface_id: str +) -> Optional[PriorSurface]: + """Locate the most recent rendered state for ``surface_id`` in message history. + + Walks backwards over tool messages whose content is a JSON string containing + ``a2ui_operations`` for the given surface, accumulating the most recent + value of each field (``components``, ``data``, ``catalogId``) across the + walk. A late-turn message that only emits ``updateDataModel`` no longer + blanks the components / catalogId established by an earlier turn — the + function returns the surface's *latest known state*, not just what the most + recent matching message happened to carry. + + Accepts both object-shaped and dict-shaped messages. + + Returns the reconstructed ``{"components": [...], "data": ..., "catalogId": ...}`` + or ``None`` if no matching surface is found anywhere in history. + """ + # Per-message end-state is computed FORWARD because the renderer applies + # ops in document order. The last op affecting the surface in a message + # determines that message's contribution — including ``deleteSurface``, + # which wipes the surface. If the NEWEST message to mention the surface + # ends in delete, return ``None``: older create/update ops are stale and + # would resurrect a surface the renderer no longer shows. + components: Optional[list[dict[str, Any]]] = None + data: Any = None + data_seen = False + catalog_id: Optional[str] = None + matched = False + + for msg in reversed(messages): + role, content = _message_role_and_content(msg) + if role not in ("tool", "ToolMessage"): + continue + if not isinstance(content, str): + continue + try: + parsed = json.loads(content) + except (ValueError, TypeError): + continue + if not isinstance(parsed, dict): + continue + ops = parsed.get(A2UI_OPERATIONS_KEY) + if not isinstance(ops, list): + continue + + # Compute this message's end state for surface_id by walking ops + # forward. ``deleteSurface`` resets the per-message accumulator; + # subsequent create / update ops in the same message restore it. + msg_mentions = False + msg_deleted = False + msg_catalog_id: Optional[str] = None + msg_components: Optional[list[dict[str, Any]]] = None + msg_data: Any = None + msg_data_seen = False + + for op in ops: + if not isinstance(op, dict): + continue + if "deleteSurface" in op: + ds = op["deleteSurface"] + if isinstance(ds, dict) and ds.get("surfaceId") == surface_id: + msg_mentions = True + msg_deleted = True + msg_catalog_id = None + msg_components = None + msg_data = None + msg_data_seen = False + continue + if "createSurface" in op: + cs = op["createSurface"] + if isinstance(cs, dict) and cs.get("surfaceId") == surface_id: + msg_mentions = True + msg_deleted = False + if isinstance(cs.get("catalogId"), str): + msg_catalog_id = cs["catalogId"] + if "updateComponents" in op: + uc = op["updateComponents"] + if isinstance(uc, dict) and uc.get("surfaceId") == surface_id: + msg_mentions = True + msg_deleted = False + if isinstance(uc.get("components"), list): + msg_components = uc["components"] + if "updateDataModel" in op: + ud = op["updateDataModel"] + if isinstance(ud, dict) and ud.get("surfaceId") == surface_id: + msg_mentions = True + msg_deleted = False + msg_data = ud.get("value") + msg_data_seen = True + + if not msg_mentions: + continue + + if not matched: + # Newest message that mentions the surface — its end state is + # authoritative. + if msg_deleted: + return None + matched = True + catalog_id = msg_catalog_id + components = msg_components + data = msg_data + data_seen = msg_data_seen + else: + # Older message: fill in only the fields not yet set. A delete + # here is overridden by the newer state already recorded. + if msg_deleted: + continue + if catalog_id is None and msg_catalog_id is not None: + catalog_id = msg_catalog_id + if components is None and msg_components is not None: + components = msg_components + if not data_seen and msg_data_seen: + data = msg_data + data_seen = True + + # Early-exit once every field is populated — nothing older can override. + if matched and components is not None and catalog_id is not None and data_seen: + return {"components": components, "data": data, "catalogId": catalog_id} + + if not matched: + return None + return { + "components": components or [], + "data": data, + "catalogId": catalog_id, + } + + +# --------------------------------------------------------------------------- +# Prompt assembly +# --------------------------------------------------------------------------- + + +class EditContext(TypedDict, total=False): + surfaceId: str + prior: PriorSurface + changes: Optional[str] + + +# --------------------------------------------------------------------------- +# Subagent prompt guidelines (OSS-248) +# +# Re-enables the rich generation + design guidance the legacy +# ``copilotkit.a2ui.a2ui_prompt`` shipped. The two DEFAULT_* blocks are applied +# automatically (per-field) so subagent output is well-designed out of the box; +# a host overrides either block via ``A2UIGuidelines``. Pass an empty string to +# suppress a block entirely. +# --------------------------------------------------------------------------- + +DEFAULT_GENERATION_GUIDELINES = """\ +Generate A2UI v0.9 JSON. + +## A2UI Protocol Instructions + +A2UI (Agent to UI) is a protocol for rendering rich UI surfaces from agent responses. + +CRITICAL: You MUST call the render_a2ui tool with ALL of these arguments: +- surfaceId: A unique ID for the surface (e.g. "product-comparison") +- components: REQUIRED — the A2UI component array. NEVER omit this. Use a List with + children: { componentId: "card-id", path: "/items" } for repeating cards. +- data: OPTIONAL — a JSON object written to the root of the surface data model. + Use for pre-filling form values or providing data for path-bound components. +- every component must have the "component" field specifying the component type (e.g. "Text", "Image", "Row", "Column", "List", "Button", etc.) + +COMPONENT ID RULES: +- Every component ID must be unique within the surface. +- A component MUST NOT reference itself as child/children. This causes a + circular dependency error. For example, if a component has id="avatar", + its child must be a DIFFERENT id (e.g. "avatar-img"), never "avatar". +- The child/children tree must be a DAG — no cycles allowed. + +PATH RULES FOR TEMPLATES: +Components inside a repeating List use RELATIVE paths (no leading slash). +The path is resolved relative to each array item automatically. +If List has children: { componentId: "card", path: "/items" } and item has key "name", +use { "path": "name" } (NO leading slash — relative to item). +CRITICAL: Do NOT use "/name" (absolute) inside templates — use "name" (relative). +The List's own path ("/items") uses a leading slash (absolute), but all +components INSIDE the template card use paths WITHOUT leading slash. +Do NOT use "/items/0/name" or "/items/{@key}/name" — just "name". + +DATA MODEL: +The "data" key in the tool args is a plain JSON object that initializes the surface +data model. Components bound to paths (e.g. "value": { "path": "/form/name" }) +read from and write to this data model. Examples: + For forms: "data": { "form": { "name": "Alice", "email": "" } } + For lists: "data": { "items": [{"name": "Product A"}, {"name": "Product B"}] } + For mixed: "data": { "form": { "query": "" }, "results": [...] } + +FORMS AND TWO-WAY DATA BINDING: +To create editable forms, bind input components to data model paths using { "path": "..." }. +The client automatically writes user input back to the data model at the bound path. +CRITICAL: Using a literal value (e.g. "value": "") makes the field READ-ONLY. +You MUST use { "path": "..." } to make inputs editable. + +All input components use "value" as the binding property: +- TextField: "value": { "path": "/form/fieldName" } +- CheckBox: "value": { "path": "/form/isChecked" } +- Slider: "value": { "path": "/form/sliderVal" } +- DateTimeInput: "value": { "path": "/form/date" } +- ChoicePicker: "value": { "path": "/form/choices" } + +To retrieve form values when a button is clicked, include "context" with path references +in the button's action. Paths are resolved to their current values at click time: + "action": { "event": { "name": "submit", "context": { "userName": { "path": "/form/name" } } } } + +To pre-fill form values, pass initial data via the "data" tool argument: + "data": { "form": { "name": "Markus" } } + +FORM EXAMPLE (editable text field with pre-filled value + submit button): + "components": [ + { "id": "root", "component": "Card", "child": "form-col" }, + { "id": "form-col", "component": "Column", "children": ["name-field", "submit-row"] }, + { "id": "name-field", "component": "TextField", "label": "Name", "value": { "path": "/form/name" } }, + { "id": "submit-row", "component": "Row", "justify": "end", "children": ["submit-btn"] }, + { "id": "submit-btn", "component": "Button", "child": "btn-text", "variant": "primary", + "action": { "event": { "name": "submit", "context": { "userName": { "path": "/form/name" } } } } }, + { "id": "btn-text", "component": "Text", "text": "Submit" } + ], + "data": { "form": { "name": "Markus" } }""" +"""Default generation guidance (tool-call contract, id/path/data-binding rules). + +Applied when ``A2UIGuidelines["generation_guidelines"]`` is unset (``None``). +Ported verbatim from the legacy ``copilotkit.a2ui`` defaults (OSS-248).""" + +DEFAULT_DESIGN_GUIDELINES = """\ +Create polished, visually appealing interfaces: +- Always include a title heading (h2) for the surface, outside the List. + Wrap in a Column: [title, list] as root. +- For card templates, create clear visual hierarchy: + - h3 for primary text (names, titles) + - h2 for featured numbers (prices, scores) — makes them stand out + - caption for secondary info (ratings, categories, metadata) + - body for descriptions +- Use Divider between logical sections within cards. +- Use Row with justify="spaceBetween" for label-value pairs + (e.g. "Rating" on left, "4.5/5" on right). +- Include images when relevant (logos, icons, product photos): + - Use Image component with variant="smallFeature" or "avatar" + - Prefer company logos for branded products — Google favicons are reliable: + https://www.google.com/s2/favicons?domain=sony.com&sz=128 + https://www.google.com/s2/favicons?domain=bose.com&sz=128 + - For generic icons: https://placehold.co/128x128/EEE/999?text=🎧 + - Do NOT invent Unsplash photo-IDs — they will 404. Only use real, known URLs. +- Use horizontal List direction for side-by-side comparison cards. +- Keep cards clean — avoid clutter. Whitespace is good. +- Use consistent surfaceIds (lowercase, hyphenated). +- NEVER use the same ID for a component and its child — this creates a + circular dependency. E.g. if id="avatar", child must NOT be "avatar". +- Both Row and Column support "justify" and "align". +- Add Button for interactivity. Button needs child (Text ID) + action. + Action MUST use this exact nested format: + "action": { "event": { "name": "myAction", "context": { "key": "value" } } } + The "event" key holds an OBJECT with "name" (required) and "context" (optional). + Do NOT use a flat format like {"event": "name"} — "event" must be an object. + Use variant="primary" for main action buttons, variant="borderless" for links. +- For forms: wrap fields in a Card with a Column. Place the submit button in a + Row with justify="end". Every input MUST use path binding on the "value" property + (e.g. "value": { "path": "/form/name" }) to be editable. The submit button's action + context MUST reference the same paths to capture the user's input. + +Use the SAME surfaceId as the main surface. Match action names to Button action event names.""" +"""Default design guidance (visual hierarchy, layout, imagery, action format). + +Applied when ``A2UIGuidelines["design_guidelines"]`` is unset (``None``). +Ported verbatim from the legacy ``copilotkit.a2ui`` defaults (OSS-248).""" + + +class A2UIGuidelines(TypedDict, total=False): + """Prompt knobs threaded from the host through the adapter into the subagent + prompt. The toolkit owns this shape so a new knob is added here (and rendered + in ``build_subagent_prompt``) without editing any framework adapter — each + adapter forwards this bag verbatim. + + Per-field semantics (mirrors the legacy ``a2ui_prompt`` defaults): + - key absent / ``None`` → the built-in ``DEFAULT_*`` block is used. + - ``""`` (empty string) → that block is suppressed (no section emitted). + - any other string → replaces the default for that block. + + ``composition_guide`` has no default; it is appended only when provided. + """ + + generation_guidelines: Optional[str] + design_guidelines: Optional[str] + composition_guide: Optional[str] + + +def build_subagent_prompt( + *, + context_prompt: str, + guidelines: Optional[A2UIGuidelines] = None, + edit_context: Optional[EditContext] = None, +) -> str: + """Compose the full subagent system prompt. + + Section order: generation guidelines → design guidelines → context (catalog) + → composition guide → edit block. Faithful to the legacy ``a2ui_prompt`` + ordering (generation lead, design header, then available components). + + Args: + context_prompt: Output of ``build_context_prompt(state)``. + guidelines: Generation/design/composition prompt knobs. Generation and + design fall back per-field to ``DEFAULT_GENERATION_GUIDELINES`` / + ``DEFAULT_DESIGN_GUIDELINES`` when unset; an empty string suppresses + the block. + edit_context: When set, instructs the subagent to edit a prior surface + in place (used by ``intent="update"``). + """ + guidelines = guidelines or {} + + # Per-field fallback: ``None`` (or absent) → built-in default; ``""`` → the + # host explicitly suppressed the block. ``.get()`` returns ``None`` for an + # absent key, so both unset paths collapse to the default. + generation = guidelines.get("generation_guidelines") + if generation is None: + generation = DEFAULT_GENERATION_GUIDELINES + design = guidelines.get("design_guidelines") + if design is None: + design = DEFAULT_DESIGN_GUIDELINES + composition_guide = guidelines.get("composition_guide") + + parts: list[str] = [] + if generation: + parts.append(generation) + if design: + parts.append(f"## Design Guidelines\n{design}") + if context_prompt: + parts.append(context_prompt) + if composition_guide: + parts.append(composition_guide) + + if edit_context: + surface_id = edit_context.get("surfaceId") + prior = edit_context.get("prior") or {} + changes = edit_context.get("changes") + edit_block = ( + "## Editing an existing surface\n" + f"You are editing surface '{surface_id}'. Produce the FULL " + f"updated components array and data model — not just a diff. " + f"Preserve component ids that the user has not asked to change so " + f"the renderer can reconcile them. Reuse the same catalogId.\n\n" + f"### Previous components\n" + f"{json.dumps(prior.get('components', []), indent=2)}\n\n" + f"### Previous data\n" + f"{json.dumps(prior.get('data'), indent=2)}\n" + ) + if changes: + edit_block += f"\n### Requested changes\n{changes}\n" + parts.append(edit_block) + + return "\n".join(p for p in parts if p) + + +# --------------------------------------------------------------------------- +# Operations envelope +# --------------------------------------------------------------------------- + + +def assemble_ops( + *, + intent: str, + surface_id: str, + catalog_id: str, + components: list[dict[str, Any]], + data: Optional[dict[str, Any]] = None, +) -> list[dict[str, Any]]: + """Produce the final A2UI v0.9 operation list for a render result. + + ``intent="create"`` emits ``[createSurface, updateComponents, updateDataModel?]``. + Any other intent (e.g. ``"update"``) skips ``createSurface`` so the + frontend reconciles the existing surface in place rather than erroring + (per v0.9 spec, ``createSurface`` on an existing id is invalid). + """ + ops: list[dict[str, Any]] = [] + if intent != "update": + ops.append(create_surface(surface_id, catalog_id)) + ops.append(update_components(surface_id, components)) + if data: + ops.append(update_data_model(surface_id, data)) + return ops + + +def wrap_as_operations_envelope(ops: list[dict[str, Any]]) -> str: + """Wrap a list of A2UI operations as the JSON envelope the A2UI middleware + looks for in tool results.""" + return json.dumps({A2UI_OPERATIONS_KEY: ops}) + + +def wrap_error_envelope(message: str) -> str: + """Wrap an error as the JSON string a subagent tool returns when it can't + produce a surface. Keeps the error shape consistent across frameworks.""" + return json.dumps({"error": message}) + + +# --------------------------------------------------------------------------- +# Subagent-tool defaults (shared so every framework adapter advertises the +# same planner-facing surface and behaviour) +# --------------------------------------------------------------------------- + +DEFAULT_SURFACE_ID = "dynamic-surface" +"""Surface id used when the subagent omits ``surfaceId`` on a create.""" + +GENERATE_A2UI_TOOL_NAME = "generate_a2ui" +"""Default name the outer A2UI tool is advertised under to the main planner.""" + +GENERATE_A2UI_TOOL_DESCRIPTION = ( + "Generate or update a dynamic A2UI surface based on the conversation. " + "A secondary LLM designs the UI components and data. " + "Use intent='create' (default) when the user requests new visual content " + "(cards, forms, lists, dashboards, comparisons, etc.). " + "Use intent='update' with target_surface_id to modify a surface you " + "previously rendered (e.g. 'change the second card's price', " + "'add a Buy button', 'use red instead of blue')." +) +"""Default description shown to the main agent's planner.""" + +GENERATE_A2UI_ARG_DESCRIPTIONS: dict[str, str] = { + "intent": ( + "'create' to render a new surface; 'update' to modify a surface " + "previously rendered in this conversation. Defaults to 'create'." + ), + "target_surface_id": ( + "Required when intent='update'. The surface id of the prior render to modify." + ), + "changes": ( + "Optional natural-language description of the changes to apply when intent='update'." + ), +} +"""Planner-facing descriptions for the outer tool's three arguments.""" + + +# --------------------------------------------------------------------------- +# Shared A2UI tool-factory params (OSS-248) +# +# One params shape, owned by the toolkit, consumed identically by every +# framework adapter. A framework's factory is always +# ``get_a2ui_tools(params: A2UIToolParams)`` — only the body (tool decorator, +# runtime/state accessor, model bind+invoke) differs per framework. +# +# ``model`` is the single framework-specific field (typed ``Any`` here so the +# toolkit stays framework-agnostic). Adding a new knob = add a field here (+ its +# default in ``resolve_a2ui_tool_params``) — NO adapter signature ever changes, +# and a brand-new framework adapter gets the knob for free on day one. +# --------------------------------------------------------------------------- + + +class A2UIToolParams(TypedDict, total=False): + """Shared input shape for every framework's ``get_a2ui_tools`` factory.""" + + model: Any # required in practice; framework-specific chat model + guidelines: Optional[A2UIGuidelines] + default_surface_id: Optional[str] + default_catalog_id: Optional[str] + tool_name: Optional[str] + tool_description: Optional[str] + catalog: Optional[dict] + recovery: Optional[dict] + on_a2ui_attempt: Optional[Any] + + +class ResolvedA2UIToolParams(TypedDict): + """``A2UIToolParams`` with every optional knob resolved to its effective + value — returned by ``resolve_a2ui_tool_params`` so adapters never + re-implement defaults.""" + + model: Any + guidelines: Optional[A2UIGuidelines] + default_surface_id: str + default_catalog_id: str + tool_name: str + tool_description: str + catalog: Optional[dict] + recovery: Optional[dict] + on_a2ui_attempt: Optional[Any] + + +def resolve_a2ui_tool_params(params: A2UIToolParams) -> ResolvedA2UIToolParams: + """Normalize ``A2UIToolParams`` into ``ResolvedA2UIToolParams``, filling the + canonical defaults so each framework adapter stops re-implementing + ``tool_name or DEFAULT`` / ``catalog_id or BASIC`` lines. + + Uses ``or`` (not ``is None``) so an accidental empty-string override falls + back to the canonical default rather than advertising a nameless tool or + emitting a blank surface/catalog id. + """ + return { + "model": params.get("model"), + "guidelines": params.get("guidelines"), + "default_surface_id": params.get("default_surface_id") or DEFAULT_SURFACE_ID, + "default_catalog_id": params.get("default_catalog_id") or BASIC_CATALOG_ID, + "tool_name": params.get("tool_name") or GENERATE_A2UI_TOOL_NAME, + "tool_description": params.get("tool_description") + or GENERATE_A2UI_TOOL_DESCRIPTION, + "catalog": params.get("catalog"), + "recovery": params.get("recovery"), + "on_a2ui_attempt": params.get("on_a2ui_attempt"), + } + + +# --------------------------------------------------------------------------- +# High-level orchestration +# +# These two functions hold the entire create/update decision + prompt prep + +# result-assembly logic so every framework adapter is reduced to pure glue +# (tool decorator, state access, model bind+invoke, tool-call read). +# --------------------------------------------------------------------------- + + +class PreparedA2UIRequest(TypedDict, total=False): + prompt: str + is_update: bool + prior: Optional[PriorSurface] + error: Optional[str] + + +def prepare_a2ui_request( + *, + intent: Optional[str], + target_surface_id: Optional[str], + changes: Optional[str], + messages: list[Any], + state: dict, + guidelines: Optional[A2UIGuidelines] = None, +) -> PreparedA2UIRequest: + """Resolve the create/update decision, locate any prior surface, and build + the subagent system prompt. + + ``guidelines`` is forwarded verbatim to ``build_subagent_prompt`` — the + toolkit owns the shape so adapters never need editing when a knob is added. + + Returns a dict with ``error`` set (and no ``prompt``) when the request is + invalid — an ``update`` referencing a surface not found in history. + """ + resolved_intent = intent or "create" + is_update = resolved_intent == "update" and bool(target_surface_id) + + # is_update being True already narrows target_surface_id to non-empty str; + # assert it explicitly so a type checker sees the same narrowing the runtime + # condition guarantees, without resorting to a blanket type: ignore. + if is_update: + assert target_surface_id is not None + prior = find_prior_surface(messages, target_surface_id) + else: + prior = None + + if is_update and prior is None: + # Match TS shape: omit ``prior`` from the error branch so presence + # checks like ``"prior" in prep`` distinguish success from failure. + return { + "prompt": "", + "is_update": is_update, + "error": ( + f"intent='update' requested target_surface_id=" + f"'{target_surface_id}' but no prior render of that surface " + f"was found in conversation history" + ), + } + + prompt = build_subagent_prompt( + context_prompt=build_context_prompt(state), + guidelines=guidelines, + edit_context=( + {"surfaceId": target_surface_id, "prior": prior, "changes": changes} + if prior is not None + else None + ), + ) + + # Omit ``error`` on success so ``"error" in prep`` is a meaningful presence + # check (matches the TS counterpart which only returns the key on failure). + return {"prompt": prompt, "is_update": is_update, "prior": prior} + + +def build_a2ui_envelope( + *, + args: dict[str, Any], + is_update: bool, + target_surface_id: Optional[str], + prior: Optional[PriorSurface], + default_surface_id: str = DEFAULT_SURFACE_ID, + default_catalog_id: str = BASIC_CATALOG_ID, +) -> str: + """Turn the subagent's structured output into the final operations envelope. + + Catalog ownership stays with the host: the subagent never picks a catalog, + so the id comes from the prior surface (update) or the configured default + (create) — never from the model's args. + """ + # Treat empty-string defaults as unset (mirror the TS guard). Without this, + # a misconfigured host passing ``""`` for default_surface_id / + # default_catalog_id would propagate the empty string into the emitted ops + # and surface as "Catalog not found: " / blank surface ids at render time, + # hiding the real cause. + safe_default_surface_id = default_surface_id or DEFAULT_SURFACE_ID + safe_default_catalog_id = default_catalog_id or BASIC_CATALOG_ID + + # Narrow args["surfaceId"] to a non-empty STRING — the model is untrusted + # and may return ``null``, a number, a list, or an empty string. Without + # this, those values propagate into ``createSurface.surfaceId`` and the + # renderer either crashes or silently mounts to an unreachable surface + # id. Mirrors the TS narrow (``typeof === "string" && length > 0``). + raw_arg_surface_id = args.get("surfaceId") + arg_surface_id = ( + raw_arg_surface_id + if isinstance(raw_arg_surface_id, str) and len(raw_arg_surface_id) > 0 + else "" + ) + if is_update: + surface_id = target_surface_id or safe_default_surface_id + else: + surface_id = arg_surface_id or safe_default_surface_id + catalog_id = (prior or {}).get("catalogId") or safe_default_catalog_id + # Narrow to the documented shapes — the model's args are untrusted. + raw_components = args.get("components") + components = raw_components if isinstance(raw_components, list) else [] + raw_data = args.get("data") + data = raw_data if isinstance(raw_data, dict) else {} + + ops = assemble_ops( + intent="update" if is_update else "create", + surface_id=surface_id, + catalog_id=catalog_id, + components=components, + data=data, + ) + + return wrap_as_operations_envelope(ops) diff --git a/sdks/python/a2ui_toolkit/ag_ui_a2ui_toolkit/recovery.py b/sdks/python/a2ui_toolkit/ag_ui_a2ui_toolkit/recovery.py new file mode 100644 index 0000000000..348232f0aa --- /dev/null +++ b/sdks/python/a2ui_toolkit/ag_ui_a2ui_toolkit/recovery.py @@ -0,0 +1,100 @@ +"""A2UI error-recovery loop (OSS-162) — Python port of ``recovery.ts``. + +Synchronous to match the synchronous LangGraph tool. The toolkit cannot +bind/invoke a model, so the adapter supplies ``invoke_subagent`` (its model call) +and ``build_envelope`` (its prepared create/update context); this module owns the +validate→retry loop using the SAME ``validate_a2ui_components`` the middleware +uses, so the tool's retry decision and the middleware's paint decision agree. +""" + +from __future__ import annotations + +import json +from typing import Any, Callable, Optional + +from .validate import validate_a2ui_components + +# Default attempt cap (initial try + retries). Configurable per call. +MAX_A2UI_ATTEMPTS = 3 + +# Activity type the middleware/client use for the recovery status channel. +A2UI_RECOVERY_ACTIVITY_TYPE = "a2ui_recovery" + +_NO_TOOL_CALL_ERROR = { + "code": "empty_components", + "path": "components", + "message": "Sub-agent did not call render_a2ui", +} + + +def format_validation_errors(errors: list[dict[str, str]]) -> str: + """Render structured errors as a compact, model-readable list.""" + return "\n".join(f"- [{e['code']}] {e['path']}: {e['message']}" for e in errors) + + +def augment_prompt_with_validation_errors(prompt: str, errors: list[dict[str, str]]) -> str: + """Append a fix-it block describing the prior attempt's errors. No-op when empty.""" + if not errors: + return prompt + return ( + f"{prompt}\n\n## Previous attempt was invalid — fix these and regenerate:\n" + f"{format_validation_errors(errors)}\n" + ) + + +def _wrap_recovery_exhausted_envelope(max_attempts: int, attempts: list[dict[str, Any]]) -> str: + return json.dumps( + { + "error": f"Failed to generate valid A2UI after {max_attempts} attempt(s)", + "code": "a2ui_recovery_exhausted", + "attempts": attempts, + } + ) + + +def run_a2ui_generation_with_recovery( + *, + base_prompt: str, + invoke_subagent: Callable[[str, int], Optional[dict[str, Any]]], + build_envelope: Callable[[dict[str, Any]], str], + catalog: Optional[dict[str, Any]] = None, + config: Optional[dict[str, Any]] = None, + on_attempt: Optional[Callable[[dict[str, Any]], None]] = None, +) -> dict[str, Any]: + """Drive the validate→retry loop. + + Returns ``{"envelope", "attempts", "ok"}``: the validated operations envelope + on success, or a structured ``a2ui_recovery_exhausted`` envelope once the cap + is hit. Never retries an attempt whose components validated. + """ + max_attempts = (config or {}).get("maxAttempts", MAX_A2UI_ATTEMPTS) + attempts: list[dict[str, Any]] = [] + last_errors: list[dict[str, str]] = [] + + for attempt in range(1, max_attempts + 1): + prompt = augment_prompt_with_validation_errors(base_prompt, last_errors) + args = invoke_subagent(prompt, attempt) + + if not args: + record = {"attempt": attempt, "ok": False, "errors": [_NO_TOOL_CALL_ERROR]} + attempts.append(record) + if on_attempt: + on_attempt(record) + last_errors = record["errors"] + continue + + raw_components = args.get("components") + components = raw_components if isinstance(raw_components, list) else [] + raw_data = args.get("data") + data = raw_data if isinstance(raw_data, dict) else {} + result = validate_a2ui_components(components=components, data=data, catalog=catalog) + record = {"attempt": attempt, "ok": result["valid"], "errors": result["errors"]} + attempts.append(record) + if on_attempt: + on_attempt(record) + + if result["valid"]: + return {"envelope": build_envelope(args), "attempts": attempts, "ok": True} + last_errors = result["errors"] + + return {"envelope": _wrap_recovery_exhausted_envelope(max_attempts, attempts), "attempts": attempts, "ok": False} diff --git a/sdks/python/a2ui_toolkit/ag_ui_a2ui_toolkit/validate.py b/sdks/python/a2ui_toolkit/ag_ui_a2ui_toolkit/validate.py new file mode 100644 index 0000000000..4e670d08a3 --- /dev/null +++ b/sdks/python/a2ui_toolkit/ag_ui_a2ui_toolkit/validate.py @@ -0,0 +1,294 @@ +"""Semantic validation of A2UI v0.9 component trees (OSS-162). + +Python port of ``a2ui-toolkit/src/validate.ts`` — kept behaviorally identical so +the framework adapters and the middleware agree on what "valid" means. Adds the +semantic checks (catalog membership, required props, child refs, binding +resolution) whose failures otherwise blow up at render time, turning them into +machine-readable errors the recovery loop can feed back to the sub-agent. +""" + +from __future__ import annotations + +from typing import Any, Optional + +# A validation error is a plain dict: {"code", "path", "message"} — JSON-friendly +# so it can ride straight into a prompt / event payload. +A2UIValidationError = dict[str, str] +ValidateA2UIResult = dict[str, Any] # {"valid": bool, "errors": list[A2UIValidationError]} + + +def _is_object(v: Any) -> bool: + return isinstance(v, dict) + + +def _absolute_path_resolves(path: str, data: Any) -> bool: + segments = [s for s in path.split("/") if s] + cursor: Any = data + for seg in segments: + if cursor is None or not isinstance(cursor, (dict, list)): + return False + if isinstance(cursor, list): + try: + idx = int(seg) + except ValueError: + return False + if idx < 0 or idx >= len(cursor): + return False + cursor = cursor[idx] + else: + if seg not in cursor: + return False + cursor = cursor[seg] + return True + + +def _collect_child_refs(children: Any) -> list[str]: + refs: list[str] = [] + + def push(v: Any) -> None: + if isinstance(v, str): + refs.append(v) + elif _is_object(v) and isinstance(v.get("componentId"), str): + refs.append(v["componentId"]) + + if isinstance(children, list): + for v in children: + push(v) + else: + # A single ``{componentId,...}`` template or a bare string id (the singular + # ``child`` shape Card/Button use); ``push`` ignores anything else. + push(children) + return refs + + +def _collect_component_ref_edges(comp: dict, schema: Optional[dict]) -> list[tuple[str, str]]: + """Collect ``(path_suffix, ref_id)`` pairs for every child reference a component makes (#1948). + + The implicit ``child`` (single) and ``children`` (list) fields are ALWAYS ref + fields, even with no catalog — this preserves the #1944 / catalog-free + behaviour. Other fields are refs ONLY when the component's catalog schema + marks the property ``"format": "componentRef"`` (single) or + ``"componentRefList"`` (list). For an array-typed property whose ``items`` is + an object schema, marked sub-properties are honoured per element (this finds + Tabs ``tabItems[].child`` — derived, never hard-coded). An unmarked property + is data, never a ref: a bare data string and a bare ref string are otherwise + indistinguishable, so shape-based detection is unsafe. + + Path grammar (byte-aligned with the TS/.NET siblings): + single-ref field -> ```` + list-ref field (array) -> ``[k]`` + list-ref field (single tmpl) -> ```` + nested array-of-object ref -> ``[k].`` (and ``[j]`` if that sub-field is a list) + """ + edges: list[tuple[str, str]] = [] + + def push_single(field: str, value: Any) -> None: + for ref in _collect_child_refs(value): + edges.append((field, ref)) + + def push_list(field: str, value: Any) -> None: + if isinstance(value, list): + for k, item in enumerate(value): + for ref in _collect_child_refs(item): + edges.append((f"{field}[{k}]", ref)) + else: + for ref in _collect_child_refs(value): + edges.append((field, ref)) + + # Implicit refs — always, regardless of catalog. + push_single("child", comp.get("child")) + push_list("children", comp.get("children")) + + # Explicit catalog-marked refs. + props = schema.get("properties") if _is_object(schema) else None + if _is_object(props): + for field, prop_schema in props.items(): + if field in ("child", "children") or not _is_object(prop_schema): + continue + fmt = prop_schema.get("format") + if fmt == "componentRef": + push_single(field, comp.get(field)) + elif fmt == "componentRefList": + push_list(field, comp.get(field)) + elif prop_schema.get("type") == "array" and _is_object(prop_schema.get("items")): + item_props = prop_schema["items"].get("properties") + arr_val = comp.get(field) + if _is_object(item_props) and isinstance(arr_val, list): + for k, item in enumerate(arr_val): + if not _is_object(item): + continue + for sub, sub_schema in item_props.items(): + if not _is_object(sub_schema): + continue + sub_fmt = sub_schema.get("format") + if sub_fmt == "componentRef": + for ref in _collect_child_refs(item.get(sub)): + edges.append((f"{field}[{k}].{sub}", ref)) + elif sub_fmt == "componentRefList": + sub_val = item.get(sub) + if isinstance(sub_val, list): + for j, sv in enumerate(sub_val): + for ref in _collect_child_refs(sv): + edges.append((f"{field}[{k}].{sub}[{j}]", ref)) + else: + for ref in _collect_child_refs(sub_val): + edges.append((f"{field}[{k}].{sub}", ref)) + return edges + + +def _child_adjacency(components: list, catalog: Optional[dict] = None) -> dict[str, list[str]]: + """id -> ordered child-id references, derived per component via ``_collect_component_ref_edges``.""" + catalog_components = (catalog or {}).get("components", {}) if catalog else {} + adj: dict[str, list[str]] = {} + for comp in components: + if _is_object(comp) and isinstance(comp.get("id"), str): + ctype = comp.get("component") + schema = catalog_components.get(ctype) if isinstance(ctype, str) else None + adj[comp["id"]] = [ref for _, ref in _collect_component_ref_edges(comp, schema)] + return adj + + +def _find_child_cycles(components: list, catalog: Optional[dict] = None) -> list[list[str]]: + """Find unique child-reference cycles (self-references and longer loops) via DFS. + + Each cycle is canonicalised — rotated so the lexicographically smallest id + leads — so the same loop reached from different entry points collapses to one + finding, and the reported chain stays byte-identical across the sibling + toolkits. + + The DFS is iterative (explicit frame stack, not call recursion): the validator + runs on untrusted model output, so a pathologically deep child chain must not + raise ``RecursionError`` (and the .NET sibling must not overflow its stack). + """ + adj = _child_adjacency(components, catalog) + color: dict[str, int] = {} # absent/0 = unvisited, 1 = on stack, 2 = done + cycles: dict[str, list[str]] = {} + + def canonical(nodes: list[str]) -> list[str]: + m = min(range(len(nodes)), key=lambda i: nodes[i]) + return nodes[m:] + nodes[:m] + + for root in adj: + if color.get(root, 0) != 0: + continue + # ``frames`` is the explicit DFS stack ([node, next-neighbor-index]); + # ``path`` mirrors the on-stack (gray) nodes in entry order, so + # ``path.index(v)`` recovers the cycle slice on a back edge. + frames: list[list] = [[root, 0]] + path: list[str] = [root] + color[root] = 1 + while frames: + node, i = frames[-1][0], frames[-1][1] + neighbors = adj.get(node, []) + if i >= len(neighbors): + color[node] = 2 + frames.pop() + path.pop() + continue + frames[-1][1] += 1 + v = neighbors[i] + c = color.get(v, 0) + if c == 0: + color[v] = 1 + path.append(v) + frames.append([v, 0]) + elif c == 1: + cyc = canonical(path[path.index(v):]) + key = " ".join(cyc) + if key not in cycles: + cycles[key] = cyc + return list(cycles.values()) + + +def _collect_absolute_binding_paths(node: Any, acc: list[str]) -> list[str]: + if isinstance(node, list): + for v in node: + _collect_absolute_binding_paths(v, acc) + elif _is_object(node): + p = node.get("path") + if isinstance(p, str) and p.startswith("/"): + acc.append(p) + for k, v in node.items(): + if k == "path": + continue + _collect_absolute_binding_paths(v, acc) + return acc + + +def validate_a2ui_components( + *, + components: Any, + data: Optional[dict[str, Any]] = None, + catalog: Optional[dict[str, Any]] = None, + validate_bindings: bool = True, +) -> ValidateA2UIResult: + """Validate a flat A2UI v0.9 component array. + + Structural checks always run. Catalog membership + required-prop checks run + only when ``catalog`` is supplied. Absolute binding paths (``/foo``) resolve + against ``data``; relative template paths (``name``) are left alone — they + resolve per-item inside a repeated template and flagging them would produce + false positives (and spurious retries). + """ + errors: list[A2UIValidationError] = [] + + # Fail loud on a non-list / empty payload. + if not isinstance(components, list) or len(components) == 0: + return { + "valid": False, + "errors": [{"code": "empty_components", "path": "components", "message": "A2UI components must be a non-empty array"}], + } + + ids: set[str] = set() + seen: set[str] = set() + for comp in components: + cid = comp.get("id") if _is_object(comp) else None + if isinstance(cid, str): + if cid in seen: + errors.append({"code": "duplicate_id", "path": f"components[id={cid}]", "message": f"Duplicate component id '{cid}'"}) + seen.add(cid) + ids.add(cid) + + catalog_components = (catalog or {}).get("components", {}) if catalog else {} + + for i, comp in enumerate(components): + cid = comp.get("id") if _is_object(comp) else None + ctype = comp.get("component") if _is_object(comp) else None + + if not isinstance(cid, str) or len(cid) == 0: + errors.append({"code": "missing_id", "path": f"components[{i}].id", "message": f"Component at index {i} is missing a string 'id'"}) + if not isinstance(ctype, str) or len(ctype) == 0: + errors.append({"code": "missing_component_type", "path": f"components[{i}].component", "message": f"Component at index {i} is missing a string 'component' type"}) + + if catalog and isinstance(ctype, str): + schema = catalog_components.get(ctype) + if schema is None: + errors.append({"code": "unknown_component", "path": f"components[{i}].component", "message": f"Component type '{ctype}' is not in the catalog"}) + else: + for req in schema.get("required", []) or []: + if not _is_object(comp) or req not in comp: + errors.append({"code": "missing_required_prop", "path": f"components[{i}].{req}", "message": f"Component '{ctype}' (index {i}) is missing required prop '{req}'"}) + + if _is_object(comp): + # Implicit ``child``/``children`` are always checked; catalog-marked + # ref-fields (Modal ``trigger``/``content``, Tabs ``tabItems[].child``, + # ...) are checked too when a catalog is supplied. A dangling reference + # in any of them feeds the recovery loop. See ``_collect_component_ref_edges``. + schema = catalog_components.get(ctype) if isinstance(ctype, str) else None + for ref_path, ref in _collect_component_ref_edges(comp, schema): + if ref not in ids: + errors.append({"code": "unresolved_child", "path": f"components[{i}].{ref_path}", "message": f"Child reference '{ref}' does not match any component id"}) + for p in (_collect_absolute_binding_paths(comp, []) if validate_bindings else []): + if not _absolute_path_resolves(p, data or {}): + errors.append({"code": "unresolved_binding", "path": f"components[{i}]", "message": f"Binding path '{p}' does not resolve in the data model"}) + + # The child reference tree must be a DAG — a component that (transitively) + # references itself never terminates at render time. Report each cycle once. + for cycle in _find_child_cycles(components, catalog): + chain = " -> ".join(cycle + [cycle[0]]) + errors.append({"code": "child_cycle", "path": f"components[id={cycle[0]}]", "message": f"Child reference cycle detected: {chain}"}) + + if not any(_is_object(c) and c.get("id") == "root" for c in components): + errors.append({"code": "no_root", "path": "components", "message": "No component has id 'root'"}) + + return {"valid": len(errors) == 0, "errors": errors} diff --git a/sdks/python/a2ui_toolkit/pyproject.toml b/sdks/python/a2ui_toolkit/pyproject.toml new file mode 100644 index 0000000000..8f4e0eb8a8 --- /dev/null +++ b/sdks/python/a2ui_toolkit/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "ag-ui-a2ui-toolkit" +version = "0.0.4" +description = "Framework-agnostic helpers for building A2UI subagent tools — op builders, prompt assembly, history walkers, and request/envelope orchestration shared across framework adapters." +license-files = ["LICENSE"] +authors = [ + { name = "Ran Shem Tov", email = "ran@copilotkit.ai" } +] +readme = "README.md" +license = "MIT" +requires-python = ">=3.10,<3.15" +dependencies = [] + +[build-system] +requires = ["uv_build>=0.8.0,<0.9"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-root = "" +module-name = "ag_ui_a2ui_toolkit" + +[tool.ag-ui.scripts] +test = "python -m unittest discover tests" diff --git a/sdks/python/a2ui_toolkit/tests/__init__.py b/sdks/python/a2ui_toolkit/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdks/python/a2ui_toolkit/tests/test_recovery.py b/sdks/python/a2ui_toolkit/tests/test_recovery.py new file mode 100644 index 0000000000..0ebc93c22f --- /dev/null +++ b/sdks/python/a2ui_toolkit/tests/test_recovery.py @@ -0,0 +1,115 @@ +"""Unit tests for ag_ui_a2ui_toolkit.recovery. + +Mirrors ``a2ui-toolkit/src/__tests__/recovery.test.ts`` (OSS-162). The Python +loop is synchronous to match the synchronous LangGraph tool. +""" + +from __future__ import annotations + +import json +import unittest + +from ag_ui_a2ui_toolkit import ( + MAX_A2UI_ATTEMPTS, + A2UI_RECOVERY_ACTIVITY_TYPE, + augment_prompt_with_validation_errors, + format_validation_errors, + run_a2ui_generation_with_recovery, +) + +CATALOG = {"components": {"Row": {"required": ["children"]}, "HotelCard": {"required": ["name", "rating"]}}} + +ROOT = {"id": "root", "component": "Row", "children": {"componentId": "card", "path": "/items"}} +GOOD_CARD = {"id": "card", "component": "HotelCard", "name": {"path": "name"}, "rating": {"path": "rating"}} +BAD_CARD = {"id": "card", "component": "HotelCard", "name": {"path": "name"}} # missing required `rating` + +GOOD_ARGS = {"surfaceId": "s1", "components": [ROOT, GOOD_CARD], "data": {"items": [{"name": "Ritz", "rating": 4.8}]}} +BAD_ARGS = {"surfaceId": "s1", "components": [ROOT, BAD_CARD], "data": {"items": [{"name": "Ritz", "rating": 4.8}]}} + + +def build_envelope(args): + return json.dumps({"a2ui_operations": args["components"]}) + + +class TestConstants(unittest.TestCase): + def test_defaults(self): + self.assertEqual(MAX_A2UI_ATTEMPTS, 3) + self.assertEqual(A2UI_RECOVERY_ACTIVITY_TYPE, "a2ui_recovery") + + +class TestAugment(unittest.TestCase): + errors = [{"code": "missing_required_prop", "path": "components[1].rating", "message": "missing required prop 'rating'"}] + + def test_no_errors_unchanged(self): + self.assertEqual(augment_prompt_with_validation_errors("BASE", []), "BASE") + + def test_appends_fix_block(self): + out = augment_prompt_with_validation_errors("BASE", self.errors) + self.assertIn("BASE", out) + self.assertIn("rating", out) + self.assertIn(format_validation_errors(self.errors), out) + + +class TestRecoveryLoop(unittest.TestCase): + def test_valid_first_attempt(self): + calls = [] + def invoke(prompt, attempt): + calls.append(attempt) + return GOOD_ARGS + res = run_a2ui_generation_with_recovery(base_prompt="P", catalog=CATALOG, invoke_subagent=invoke, build_envelope=build_envelope) + self.assertTrue(res["ok"]) + self.assertEqual(len(res["attempts"]), 1) + self.assertEqual(len(calls), 1) + self.assertIn("a2ui_operations", json.loads(res["envelope"])) + + def test_recovers_second_attempt_with_feedback(self): + prompts = [] + def invoke(prompt, attempt): + prompts.append(prompt) + return BAD_ARGS if attempt == 1 else GOOD_ARGS + res = run_a2ui_generation_with_recovery(base_prompt="P", catalog=CATALOG, invoke_subagent=invoke, build_envelope=build_envelope) + self.assertTrue(res["ok"]) + self.assertEqual(len(res["attempts"]), 2) + self.assertFalse(res["attempts"][0]["ok"]) + self.assertTrue(res["attempts"][1]["ok"]) + self.assertIn("rating", prompts[1]) + + def test_exhaustion_hard_failure(self): + seen = [] + res = run_a2ui_generation_with_recovery( + base_prompt="P", catalog=CATALOG, + invoke_subagent=lambda p, a: BAD_ARGS, + build_envelope=build_envelope, + on_attempt=lambda rec: seen.append(rec), + ) + self.assertFalse(res["ok"]) + self.assertEqual(len(res["attempts"]), MAX_A2UI_ATTEMPTS) + self.assertEqual(len(seen), MAX_A2UI_ATTEMPTS) + parsed = json.loads(res["envelope"]) + self.assertEqual(parsed["code"], "a2ui_recovery_exhausted") + self.assertTrue(parsed["error"]) + self.assertIsInstance(parsed["attempts"], list) + + def test_max_attempts_override(self): + calls = [] + res = run_a2ui_generation_with_recovery( + base_prompt="P", catalog=CATALOG, config={"maxAttempts": 2}, + invoke_subagent=lambda p, a: (calls.append(a), BAD_ARGS)[1], + build_envelope=build_envelope, + ) + self.assertFalse(res["ok"]) + self.assertEqual(len(calls), 2) + + def test_missing_tool_call_is_retryable(self): + res = run_a2ui_generation_with_recovery( + base_prompt="P", catalog=CATALOG, + invoke_subagent=lambda p, a: None if a == 1 else GOOD_ARGS, + build_envelope=build_envelope, + ) + self.assertTrue(res["ok"]) + self.assertEqual(len(res["attempts"]), 2) + self.assertFalse(res["attempts"][0]["ok"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdks/python/a2ui_toolkit/tests/test_toolkit.py b/sdks/python/a2ui_toolkit/tests/test_toolkit.py new file mode 100644 index 0000000000..a14273f904 --- /dev/null +++ b/sdks/python/a2ui_toolkit/tests/test_toolkit.py @@ -0,0 +1,882 @@ +"""Unit tests for ag_ui_a2ui_toolkit's pure helpers. + +Mirrors the TypeScript ``a2ui-toolkit/src/__tests__/toolkit.test.ts`` suite +so both languages stay aligned on expected behavior. +""" + +from __future__ import annotations + +import json +import unittest + +from ag_ui_a2ui_toolkit import ( + A2UI_OPERATIONS_KEY, + A2UI_SCHEMA_CONTEXT_DESCRIPTION, + BASIC_CATALOG_ID, + DEFAULT_DESIGN_GUIDELINES, + DEFAULT_GENERATION_GUIDELINES, + DEFAULT_SURFACE_ID, + GENERATE_A2UI_TOOL_DESCRIPTION, + GENERATE_A2UI_TOOL_NAME, + RENDER_A2UI_TOOL_DEF, + assemble_ops, + build_a2ui_envelope, + build_context_prompt, + build_subagent_prompt, + create_surface, + find_prior_surface, + prepare_a2ui_request, + resolve_a2ui_catalog, + resolve_a2ui_tool_params, + split_a2ui_schema_context, + update_components, + update_data_model, + wrap_as_operations_envelope, + wrap_error_envelope, +) + + +class TestConstants(unittest.TestCase): + def test_operations_key(self): + self.assertEqual(A2UI_OPERATIONS_KEY, "a2ui_operations") + + def test_basic_catalog_id(self): + self.assertEqual( + BASIC_CATALOG_ID, + "https://a2ui.org/specification/v0_9/basic_catalog.json", + ) + + +class TestRenderToolDef(unittest.TestCase): + def test_shape(self): + self.assertEqual(RENDER_A2UI_TOOL_DEF["type"], "function") + self.assertEqual(RENDER_A2UI_TOOL_DEF["function"]["name"], "render_a2ui") + + def test_required_fields(self): + self.assertEqual( + RENDER_A2UI_TOOL_DEF["function"]["parameters"]["required"], + ["surfaceId", "components"], + ) + + def test_parameter_keys(self): + self.assertEqual( + list(RENDER_A2UI_TOOL_DEF["function"]["parameters"]["properties"].keys()), + ["surfaceId", "components", "data"], + ) + + +class TestOpBuilders(unittest.TestCase): + def test_create_surface(self): + self.assertEqual( + create_surface("s1", "c1"), + { + "version": "v0.9", + "createSurface": {"surfaceId": "s1", "catalogId": "c1"}, + }, + ) + + def test_update_components(self): + comps = [{"id": "root", "component": "Row"}] + self.assertEqual( + update_components("s1", comps), + { + "version": "v0.9", + "updateComponents": {"surfaceId": "s1", "components": comps}, + }, + ) + + def test_update_data_model_defaults(self): + self.assertEqual( + update_data_model("s1", {"items": []}), + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "s1", + "path": "/", + "value": {"items": []}, + }, + }, + ) + + def test_update_data_model_custom_path(self): + self.assertEqual( + update_data_model("s1", "hello", "/title"), + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "s1", + "path": "/title", + "value": "hello", + }, + }, + ) + + +class TestBuildContextPrompt(unittest.TestCase): + def test_empty_state(self): + self.assertEqual(build_context_prompt({}), "") + + def test_described_entry(self): + prompt = build_context_prompt( + { + "ag-ui": { + "context": [ + {"description": "Style guide", "value": "use cards"} + ], + } + } + ) + self.assertIn("## Style guide", prompt) + self.assertIn("use cards", prompt) + + def test_value_only_entry(self): + prompt = build_context_prompt( + {"ag-ui": {"context": [{"value": "free-form note"}]}} + ) + self.assertIn("free-form note", prompt) + self.assertNotIn("##", prompt) + + def test_catalog_section(self): + prompt = build_context_prompt({"ag-ui": {"a2ui_schema": ""}}) + self.assertIn("## Available Components", prompt) + self.assertIn("", prompt) + + def test_empty_entries_dropped(self): + prompt = build_context_prompt({"ag-ui": {"context": [{}]}}) + self.assertEqual(prompt, "") + + +class TestSplitA2UISchemaContext(unittest.TestCase): + def test_splits_schema_from_regular(self): + ctx = [ + {"description": "Style guide", "value": "use cards"}, + {"description": A2UI_SCHEMA_CONTEXT_DESCRIPTION, "value": ""}, + ] + schema_value, regular = split_a2ui_schema_context(ctx) + self.assertEqual(schema_value, "") + self.assertEqual(len(regular), 1) + self.assertEqual(regular[0]["description"], "Style guide") + + def test_none_when_no_schema_entry(self): + schema_value, regular = split_a2ui_schema_context( + [{"description": "Style guide", "value": "use cards"}] + ) + self.assertIsNone(schema_value) + self.assertEqual(len(regular), 1) + + def test_handles_none_and_objects(self): + self.assertEqual(split_a2ui_schema_context(None), (None, [])) + + class _Entry: + def __init__(self, description, value): + self.description = description + self.value = value + + schema_value, regular = split_a2ui_schema_context( + [_Entry(A2UI_SCHEMA_CONTEXT_DESCRIPTION, "obj-catalog")] + ) + self.assertEqual(schema_value, "obj-catalog") + self.assertEqual(regular, []) + + def test_roundtrips_into_build_context_prompt(self): + ctx = [ + {"description": "App context", "value": "on dashboard"}, + {"description": A2UI_SCHEMA_CONTEXT_DESCRIPTION, "value": ""}, + ] + schema_value, regular = split_a2ui_schema_context(ctx) + prompt = build_context_prompt( + {"ag-ui": {"context": regular, "a2ui_schema": schema_value}} + ) + self.assertIn("## Available Components", prompt) + self.assertIn("", prompt) + self.assertIn("## App context", prompt) + self.assertNotIn(A2UI_SCHEMA_CONTEXT_DESCRIPTION, prompt) + + +class TestResolveA2UICatalog(unittest.TestCase): + def test_native_ag_ui_schema_path(self): + state = { + "ag-ui": { + "a2ui_schema": json.dumps( + {"catalogId": "my-catalog", "components": []} + ) + } + } + schema, catalog_id = resolve_a2ui_catalog(state) + # Native path: toolkit reads a2ui_schema from state for the prompt, so + # only the id is surfaced (schema None). + self.assertIsNone(schema) + self.assertEqual(catalog_id, "my-catalog") + + def test_native_schema_already_parsed_dict(self): + state = {"ag-ui": {"a2ui_schema": {"catalogId": "parsed-cat"}}} + _, catalog_id = resolve_a2ui_catalog(state) + self.assertEqual(catalog_id, "parsed-cat") + + def test_native_malformed_json_yields_no_id(self): + state = {"ag-ui": {"a2ui_schema": "{not json"}} + schema, catalog_id = resolve_a2ui_catalog(state) + self.assertIsNone(schema) + self.assertIsNone(catalog_id) + + def test_ag_ui_context_path(self): + # Canonical key — what a plain AG-UI adapter (e.g. Strands) has; no + # "copilotkit" alias present. + state = { + "ag-ui": { + "context": [ + {"description": "Registered A2UI catalog", "value": "- ag-ui-cat"} + ] + } + } + schema, catalog_id = resolve_a2ui_catalog(state) + self.assertEqual(catalog_id, "ag-ui-cat") + self.assertIn("ag-ui-cat", schema) + + def test_context_path_picks_first_listed_catalog(self): + state = { + "ag-ui": { + "context": [ + {"description": "unrelated", "value": "x"}, + { + "description": "Registered A2UI catalog", + "value": "- custom-cat\n- basic", + }, + ] + } + } + schema, catalog_id = resolve_a2ui_catalog(state) + self.assertEqual(catalog_id, "custom-cat") + self.assertIn("custom-cat", schema) + + def test_schema_entry_takes_precedence_over_context(self): + state = { + "ag-ui": { + "a2ui_schema": json.dumps({"catalogId": "native-cat"}), + "context": [ + {"description": "A2UI catalog", "value": "- ctx-cat"} + ], + }, + } + _, catalog_id = resolve_a2ui_catalog(state) + self.assertEqual(catalog_id, "native-cat") + + def test_no_catalog_returns_none(self): + self.assertIsNone(resolve_a2ui_catalog({})) + self.assertIsNone(resolve_a2ui_catalog({"ag-ui": {"context": []}})) + + +class _ToolMessage: + """Minimal stand-in for langchain's ToolMessage (or similar) — exposes + ``type`` and ``content`` as attributes so the role-detection path works.""" + + def __init__(self, content: str, role: str = "tool"): + self.type = role + self.content = content + + +class TestFindPriorSurface(unittest.TestCase): + @staticmethod + def _tool(content): + return _ToolMessage(json.dumps(content)) + + def test_returns_none_when_missing(self): + messages = [self._tool({A2UI_OPERATIONS_KEY: []})] + self.assertIsNone(find_prior_surface(messages, "missing")) + + def test_reconstructs_state(self): + messages = [ + self._tool( + { + A2UI_OPERATIONS_KEY: [ + create_surface("s1", "cat://x"), + update_components("s1", [{"id": "root", "component": "Row"}]), + update_data_model("s1", {"items": [1, 2]}), + ] + } + ) + ] + prior = find_prior_surface(messages, "s1") + self.assertEqual(prior["components"], [{"id": "root", "component": "Row"}]) + self.assertEqual(prior["data"], {"items": [1, 2]}) + self.assertEqual(prior["catalogId"], "cat://x") + + def test_prefers_latest(self): + messages = [ + self._tool( + { + A2UI_OPERATIONS_KEY: [ + create_surface("s1", "old-cat"), + update_components("s1", [{"id": "root", "component": "Row"}]), + ] + } + ), + self._tool( + { + A2UI_OPERATIONS_KEY: [ + update_components("s1", [{"id": "root", "component": "Column"}]), + update_data_model("s1", {"changed": True}), + ] + } + ), + ] + prior = find_prior_surface(messages, "s1") + self.assertEqual(prior["components"], [{"id": "root", "component": "Column"}]) + self.assertEqual(prior["data"], {"changed": True}) + + def test_ignores_non_tool(self): + messages = [ + _ToolMessage("not a tool", role="assistant"), + _ToolMessage("not json", role="tool"), + self._tool({"unrelated": "payload"}), + ] + self.assertIsNone(find_prior_surface(messages, "s1")) + + def test_accepts_dict_style_messages(self): + # Plain-dict messages (the shape LangChain produces after a JSON + # round-trip) must be honored — the walker can't silently skip them. + msg = { + "type": "tool", + "content": json.dumps( + { + A2UI_OPERATIONS_KEY: [ + create_surface("s1", "c"), + update_components( + "s1", [{"id": "root", "component": "Row"}] + ), + ] + } + ), + } + prior = find_prior_surface([msg], "s1") + self.assertIsNotNone(prior) + self.assertEqual(prior["catalogId"], "c") + self.assertEqual( + prior["components"], [{"id": "root", "component": "Row"}] + ) + + def test_within_message_last_op_wins(self): + # One envelope emits multiple ops for the same surface. The renderer + # applies them in order, so the surface ends at layout-b / {v:2} / cat-B. + msg = self._tool( + { + A2UI_OPERATIONS_KEY: [ + create_surface("s1", "cat-A"), + update_components("s1", [{"id": "root", "component": "Row"}]), + update_data_model("s1", {"v": 1}), + create_surface("s1", "cat-B"), + update_components( + "s1", [{"id": "root", "component": "Column"}] + ), + update_data_model("s1", {"v": 2}), + ] + } + ) + prior = find_prior_surface([msg], "s1") + self.assertEqual( + prior, + { + "components": [{"id": "root", "component": "Column"}], + "data": {"v": 2}, + "catalogId": "cat-B", + }, + ) + + def test_accumulates_fields_across_walk(self): + # Turn 1: full create + components + initial data. + # Turn 2: only updateDataModel. + # The walker must surface the components + catalogId from turn 1 plus + # the updated data from turn 2 — not blank components because the most + # recent message happened to omit them. + msg1 = self._tool( + { + A2UI_OPERATIONS_KEY: [ + create_surface("s1", "cat://x"), + update_components("s1", [{"id": "root", "component": "Row"}]), + update_data_model("s1", {"items": [1]}), + ] + } + ) + msg2 = self._tool( + {A2UI_OPERATIONS_KEY: [update_data_model("s1", {"items": [1, 2, 3]})]} + ) + prior = find_prior_surface([msg1, msg2], "s1") + self.assertEqual( + prior, + { + "components": [{"id": "root", "component": "Row"}], + "data": {"items": [1, 2, 3]}, + "catalogId": "cat://x", + }, + ) + + def test_newest_delete_surface_returns_none(self): + # Older message populated the surface; newer message deletes it. + # The renderer no longer shows it, so find_prior_surface must NOT + # resurrect the stale state from the older ops. + msg1 = self._tool( + { + A2UI_OPERATIONS_KEY: [ + create_surface("s1", "cat://x"), + update_components("s1", [{"id": "root", "component": "Row"}]), + update_data_model("s1", {"items": [1, 2]}), + ] + } + ) + msg2 = self._tool( + { + A2UI_OPERATIONS_KEY: [ + {"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}} + ] + } + ) + self.assertIsNone(find_prior_surface([msg1, msg2], "s1")) + + def test_older_delete_surface_overridden_by_newer_create(self): + # Older message deleted the surface; newer message recreates it. The + # newer state must be returned — the older delete is dead history. + msg1 = self._tool( + { + A2UI_OPERATIONS_KEY: [ + {"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}} + ] + } + ) + msg2 = self._tool( + { + A2UI_OPERATIONS_KEY: [ + create_surface("s1", "cat://new"), + update_components( + "s1", [{"id": "root", "component": "Column"}] + ), + update_data_model("s1", {"items": [9]}), + ] + } + ) + prior = find_prior_surface([msg1, msg2], "s1") + self.assertEqual( + prior, + { + "components": [{"id": "root", "component": "Column"}], + "data": {"items": [9]}, + "catalogId": "cat://new", + }, + ) + + def test_intra_message_delete_then_create_returns_recreated(self): + # Within one message, ops apply in order. Delete then create → surface + # exists with recreated content at end of message. + msg = self._tool( + { + A2UI_OPERATIONS_KEY: [ + {"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}}, + create_surface("s1", "cat-recreated"), + update_components("s1", [{"id": "root", "component": "Row"}]), + ] + } + ) + prior = find_prior_surface([msg], "s1") + self.assertEqual( + prior, + { + "components": [{"id": "root", "component": "Row"}], + "data": None, + "catalogId": "cat-recreated", + }, + ) + + def test_intra_message_create_then_delete_returns_none(self): + # Within one message, the surface is created then deleted — end state + # is deleted, regardless of older accumulated state in prior messages. + msg1 = self._tool( + { + A2UI_OPERATIONS_KEY: [ + create_surface("s1", "older-cat"), + update_components("s1", [{"id": "root", "component": "Row"}]), + ] + } + ) + msg2 = self._tool( + { + A2UI_OPERATIONS_KEY: [ + create_surface("s1", "transient"), + {"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}}, + ] + } + ) + self.assertIsNone(find_prior_surface([msg1, msg2], "s1")) + + +class TestBuildSubagentPrompt(unittest.TestCase): + # Suppress both built-in default blocks so the structural tests below can + # assert exact output without the (large) DEFAULT_* text. Empty string is + # the documented escape hatch (None → default; "" → block omitted). + SUPPRESS = {"generation_guidelines": "", "design_guidelines": ""} + + def test_defaults_applied_when_unset(self): + # No guidelines → both built-in blocks land in the prompt, with the + # design block under its "## Design Guidelines" header (OSS-248). + prompt = build_subagent_prompt(context_prompt="ctx") + self.assertIn(DEFAULT_GENERATION_GUIDELINES, prompt) + self.assertIn("## Design Guidelines", prompt) + self.assertIn(DEFAULT_DESIGN_GUIDELINES, prompt) + self.assertIn("ctx", prompt) + + def test_section_order(self): + # generation → design → context → composition. + prompt = build_subagent_prompt( + context_prompt="CTXMARK", + guidelines={ + "generation_guidelines": "GENMARK", + "design_guidelines": "DESMARK", + "composition_guide": "COMPMARK", + }, + ) + self.assertLess(prompt.index("GENMARK"), prompt.index("DESMARK")) + self.assertLess(prompt.index("DESMARK"), prompt.index("CTXMARK")) + self.assertLess(prompt.index("CTXMARK"), prompt.index("COMPMARK")) + + def test_per_field_override_keeps_other_default(self): + # Override generation only → design still falls back to its default. + prompt = build_subagent_prompt( + context_prompt="ctx", + guidelines={"generation_guidelines": "CUSTOM_GEN"}, + ) + self.assertIn("CUSTOM_GEN", prompt) + self.assertNotIn(DEFAULT_GENERATION_GUIDELINES, prompt) + self.assertIn(DEFAULT_DESIGN_GUIDELINES, prompt) + + def test_empty_string_suppresses_block(self): + prompt = build_subagent_prompt( + context_prompt="ctx", guidelines=self.SUPPRESS + ) + self.assertNotIn(DEFAULT_GENERATION_GUIDELINES, prompt) + self.assertNotIn(DEFAULT_DESIGN_GUIDELINES, prompt) + self.assertNotIn("## Design Guidelines", prompt) + + def test_context_only(self): + self.assertEqual( + build_subagent_prompt(context_prompt="ctx", guidelines=self.SUPPRESS), + "ctx", + ) + + def test_appends_composition_guide(self): + prompt = build_subagent_prompt( + context_prompt="ctx", + guidelines={**self.SUPPRESS, "composition_guide": "guide"}, + ) + self.assertEqual(prompt, "ctx\nguide") + + def test_edit_block(self): + prompt = build_subagent_prompt( + context_prompt="ctx", + guidelines=self.SUPPRESS, + edit_context={ + "surfaceId": "s1", + "prior": { + "components": [{"id": "root", "component": "Row"}], + "data": {"x": 1}, + }, + "changes": "make the title bigger", + }, + ) + self.assertIn("Editing an existing surface", prompt) + self.assertIn("'s1'", prompt) + self.assertIn('"id": "root"', prompt) + self.assertIn('"x": 1', prompt) + self.assertIn("Requested changes", prompt) + self.assertIn("make the title bigger", prompt) + + def test_omits_requested_changes_when_none(self): + prompt = build_subagent_prompt( + context_prompt="ctx", + guidelines=self.SUPPRESS, + edit_context={"surfaceId": "s1", "prior": {"components": [], "data": None}}, + ) + self.assertNotIn("Requested changes", prompt) + + def test_empty_everything_returns_empty(self): + # Empty context AND both default blocks suppressed → empty prompt. + self.assertEqual( + build_subagent_prompt(context_prompt="", guidelines=self.SUPPRESS), "" + ) + + +class TestAssembleOps(unittest.TestCase): + def test_create_intent_full_envelope(self): + ops = assemble_ops( + intent="create", + surface_id="s1", + catalog_id="cat://x", + components=[{"id": "root", "component": "Row"}], + data={"items": ["a"]}, + ) + self.assertEqual(len(ops), 3) + self.assertIn("createSurface", ops[0]) + self.assertIn("updateComponents", ops[1]) + self.assertIn("updateDataModel", ops[2]) + + def test_update_intent_skips_create_surface(self): + ops = assemble_ops( + intent="update", + surface_id="s1", + catalog_id="cat://x", + components=[{"id": "root", "component": "Row"}], + data={"items": ["a"]}, + ) + self.assertEqual(len(ops), 2) + self.assertIn("updateComponents", ops[0]) + self.assertIn("updateDataModel", ops[1]) + + def test_no_data_omits_data_model_op(self): + ops = assemble_ops( + intent="create", + surface_id="s1", + catalog_id="cat://x", + components=[{"id": "root", "component": "Row"}], + ) + self.assertEqual(len(ops), 2) + self.assertIn("createSurface", ops[0]) + self.assertIn("updateComponents", ops[1]) + + def test_empty_data_omits_data_model_op(self): + ops = assemble_ops( + intent="create", + surface_id="s1", + catalog_id="cat://x", + components=[{"id": "root", "component": "Row"}], + data={}, + ) + self.assertEqual(len(ops), 2) + + +class TestWrapAsOperationsEnvelope(unittest.TestCase): + def test_serializes_under_key(self): + ops = [create_surface("s1", "c")] + envelope = json.loads(wrap_as_operations_envelope(ops)) + self.assertEqual(envelope, {A2UI_OPERATIONS_KEY: ops}) + + def test_empty_ops(self): + envelope = json.loads(wrap_as_operations_envelope([])) + self.assertEqual(envelope, {A2UI_OPERATIONS_KEY: []}) + + +class TestWrapErrorEnvelope(unittest.TestCase): + def test_wraps_message(self): + self.assertEqual(json.loads(wrap_error_envelope("boom")), {"error": "boom"}) + + +def _prior_surface_message(surface_id: str): + """A prior surface encoded the way it appears in conversation history.""" + + class _Tool: + def __init__(self, content: str): + self.type = "tool" + self.content = content + + return _Tool( + wrap_as_operations_envelope( + [ + create_surface(surface_id, "cat://x"), + update_components(surface_id, [{"id": "root", "component": "Row"}]), + update_data_model(surface_id, {"items": [1, 2]}), + ] + ) + ) + + +class TestPrepareA2UIRequest(unittest.TestCase): + def test_create_builds_prompt_no_prior(self): + prep = prepare_a2ui_request( + intent="create", + target_surface_id=None, + changes=None, + messages=[], + state={"ag-ui": {"context": [{"value": "ctx"}]}}, + guidelines={"composition_guide": "guide"}, + ) + self.assertIsNone(prep.get("error")) + self.assertFalse(prep["is_update"]) + self.assertIsNone(prep["prior"]) + self.assertIn("ctx", prep["prompt"]) + self.assertIn("guide", prep["prompt"]) + + def test_missing_intent_defaults_to_create(self): + prep = prepare_a2ui_request( + intent=None, target_surface_id=None, changes=None, messages=[], state={} + ) + self.assertFalse(prep["is_update"]) + self.assertIsNone(prep.get("error")) + + def test_update_with_matching_prior(self): + prep = prepare_a2ui_request( + intent="update", + target_surface_id="s1", + changes="make it red", + messages=[_prior_surface_message("s1")], + state={}, + ) + self.assertIsNone(prep.get("error")) + self.assertTrue(prep["is_update"]) + self.assertEqual(prep["prior"]["catalogId"], "cat://x") + self.assertIn("Editing an existing surface", prep["prompt"]) + self.assertIn("make it red", prep["prompt"]) + + def test_update_without_prior_errors(self): + prep = prepare_a2ui_request( + intent="update", + target_surface_id="missing", + changes=None, + messages=[_prior_surface_message("s1")], + state={}, + ) + self.assertEqual(prep["prompt"], "") + self.assertIn("missing", prep["error"]) + self.assertIn("no prior render", prep["error"]) + + +class TestBuildA2UIEnvelope(unittest.TestCase): + def test_create_uses_configured_catalog_not_args(self): + env = json.loads( + build_a2ui_envelope( + args={ + "surfaceId": "from-args", + "components": [{"id": "root", "component": "Row"}], + "data": {"items": [1]}, + }, + is_update=False, + target_surface_id=None, + prior=None, + default_catalog_id="cat://configured", + ) + ) + ops = env[A2UI_OPERATIONS_KEY] + self.assertEqual( + ops[0]["createSurface"], + {"surfaceId": "from-args", "catalogId": "cat://configured"}, + ) + self.assertEqual( + ops[1]["updateComponents"]["components"], + [{"id": "root", "component": "Row"}], + ) + self.assertEqual(ops[2]["updateDataModel"]["value"], {"items": [1]}) + + def test_create_falls_back_to_default_surface_id(self): + env = json.loads( + build_a2ui_envelope( + args={"components": []}, + is_update=False, + target_surface_id=None, + prior=None, + ) + ) + self.assertEqual( + env[A2UI_OPERATIONS_KEY][0]["createSurface"]["surfaceId"], + DEFAULT_SURFACE_ID, + ) + + def test_empty_string_defaults_fall_back_to_canonical(self): + # Misconfigured host: both default_surface_id and default_catalog_id are + # the empty string. Must NOT propagate "" into the emitted ops — the + # renderer would surface as "Catalog not found: " / blank surface id. + env = json.loads( + build_a2ui_envelope( + args={"components": [{"id": "root", "component": "Row"}]}, + is_update=False, + target_surface_id=None, + prior=None, + default_surface_id="", + default_catalog_id="", + ) + ) + ops = env[A2UI_OPERATIONS_KEY] + cs = next(op["createSurface"] for op in ops if "createSurface" in op) + self.assertNotEqual(cs["surfaceId"], "") + self.assertNotEqual(cs["catalogId"], "") + self.assertEqual(cs["surfaceId"], DEFAULT_SURFACE_ID) + self.assertEqual(cs["catalogId"], BASIC_CATALOG_ID) + + def test_non_string_arg_surface_id_falls_back_to_default(self): + # The model is untrusted — `args["surfaceId"]` may come back as a + # number, list, or null. Without narrowing, a non-string value + # propagates into createSurface.surfaceId and the renderer crashes + # (the renderer expects a string id). The toolkit must coerce to the + # default in that case. Mirror of the TS narrow. + for bad in [42, ["x"], None, {"a": 1}, True]: + env = json.loads( + build_a2ui_envelope( + args={"surfaceId": bad, "components": []}, + is_update=False, + target_surface_id=None, + prior=None, + ) + ) + cs = next(op["createSurface"] for op in env[A2UI_OPERATIONS_KEY] if "createSurface" in op) + self.assertEqual(cs["surfaceId"], DEFAULT_SURFACE_ID) + self.assertIsInstance(cs["surfaceId"], str) + + def test_update_with_empty_target_surface_id_falls_back_to_default(self): + # Direct callers of build_a2ui_envelope (bypassing prepare_a2ui_request) + # may pass `target_surface_id=""` on the update path. Empty strings + # must NOT propagate into updateComponents.surfaceId. + env = json.loads( + build_a2ui_envelope( + args={"components": [{"id": "root", "component": "Row"}]}, + is_update=True, + target_surface_id="", + prior={"components": [], "data": None, "catalogId": "cat://prior"}, + ) + ) + ops = env[A2UI_OPERATIONS_KEY] + uc = next(op["updateComponents"] for op in ops if "updateComponents" in op) + self.assertEqual(uc["surfaceId"], DEFAULT_SURFACE_ID) + self.assertNotEqual(uc["surfaceId"], "") + + def test_update_skips_create_surface_and_keeps_target(self): + env = json.loads( + build_a2ui_envelope( + args={ + "surfaceId": "ignored", + "components": [{"id": "root", "component": "Column"}], + }, + is_update=True, + target_surface_id="s1", + prior={"components": [], "data": None, "catalogId": "cat://prior"}, + ) + ) + ops = env[A2UI_OPERATIONS_KEY] + self.assertFalse(any("createSurface" in o for o in ops)) + self.assertEqual(ops[0]["updateComponents"]["surfaceId"], "s1") + + +class TestResolveA2UIToolParams(unittest.TestCase): + def test_fills_canonical_defaults(self): + r = resolve_a2ui_tool_params({"model": "M"}) + self.assertEqual(r["model"], "M") + self.assertEqual(r["default_surface_id"], DEFAULT_SURFACE_ID) + self.assertEqual(r["default_catalog_id"], BASIC_CATALOG_ID) + self.assertEqual(r["tool_name"], GENERATE_A2UI_TOOL_NAME) + self.assertEqual(r["tool_description"], GENERATE_A2UI_TOOL_DESCRIPTION) + self.assertIsNone(r["guidelines"]) + + def test_empty_string_override_falls_back_to_default(self): + r = resolve_a2ui_tool_params( + {"model": "M", "tool_name": "", "default_catalog_id": ""} + ) + self.assertEqual(r["tool_name"], GENERATE_A2UI_TOOL_NAME) + self.assertEqual(r["default_catalog_id"], BASIC_CATALOG_ID) + + def test_overrides_pass_through(self): + r = resolve_a2ui_tool_params( + { + "model": "M", + "tool_name": "custom_tool", + "guidelines": {"composition_guide": "g"}, + } + ) + self.assertEqual(r["tool_name"], "custom_tool") + self.assertEqual(r["guidelines"], {"composition_guide": "g"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdks/python/a2ui_toolkit/tests/test_validate.py b/sdks/python/a2ui_toolkit/tests/test_validate.py new file mode 100644 index 0000000000..0cc30dfae5 --- /dev/null +++ b/sdks/python/a2ui_toolkit/tests/test_validate.py @@ -0,0 +1,271 @@ +"""Unit tests for ag_ui_a2ui_toolkit.validate. + +Mirrors the TypeScript ``a2ui-toolkit/src/__tests__/validate.test.ts`` so both +languages stay aligned on what counts as a valid A2UI surface (OSS-162). +""" + +from __future__ import annotations + +import unittest + +from ag_ui_a2ui_toolkit import validate_a2ui_components + +CATALOG = { + "components": { + "Row": {"type": "object", "required": ["children"]}, + "HotelCard": { + "type": "object", + "required": ["name", "location", "rating", "pricePerNight"], + }, + } +} + + +def valid_components(): + return [ + {"id": "root", "component": "Row", "children": {"componentId": "card", "path": "/items"}}, + { + "id": "card", + "component": "HotelCard", + "name": {"path": "name"}, + "location": {"path": "location"}, + "rating": {"path": "rating"}, + "pricePerNight": {"path": "pricePerNight"}, + }, + ] + + +VALID_DATA = {"items": [{"name": "Ritz", "location": "NYC", "rating": 4.8, "pricePerNight": "$450"}]} + + +def codes(result): + return {e["code"] for e in result["errors"]} + + +class TestHappyPath(unittest.TestCase): + def test_accepts_well_formed_surface(self): + r = validate_a2ui_components(components=valid_components(), data=VALID_DATA, catalog=CATALOG) + self.assertTrue(r["valid"]) + self.assertEqual(r["errors"], []) + + +class TestStructural(unittest.TestCase): + def test_missing_root(self): + comps = [{**c, "id": "container"} if c["id"] == "root" else c for c in valid_components()] + r = validate_a2ui_components(components=comps, data=VALID_DATA, catalog=CATALOG) + self.assertFalse(r["valid"]) + self.assertIn("no_root", codes(r)) + + def test_missing_id(self): + r = validate_a2ui_components(components=[{"component": "Row", "children": []}]) + self.assertIn("missing_id", codes(r)) + + def test_missing_component_type(self): + r = validate_a2ui_components(components=[{"id": "root"}]) + self.assertIn("missing_component_type", codes(r)) + + def test_duplicate_id(self): + comps = [ + {"id": "root", "component": "Row", "children": ["x"]}, + {"id": "x", "component": "Row", "children": []}, + {"id": "x", "component": "Row", "children": []}, + ] + self.assertIn("duplicate_id", codes(validate_a2ui_components(components=comps))) + + def test_empty_or_non_list_fails_loud(self): + self.assertFalse(validate_a2ui_components(components=[])["valid"]) + self.assertFalse(validate_a2ui_components(components=None)["valid"]) + + +class TestCatalogSemantics(unittest.TestCase): + def test_unknown_component(self): + comps = [{**c, "component": "MysteryCard"} if c["id"] == "card" else c for c in valid_components()] + r = validate_a2ui_components(components=comps, data=VALID_DATA, catalog=CATALOG) + self.assertIn("unknown_component", codes(r)) + + def test_missing_required_prop(self): + comps = [] + for c in valid_components(): + if c["id"] == "card": + c = {k: v for k, v in c.items() if k != "pricePerNight"} + comps.append(c) + r = validate_a2ui_components(components=comps, data=VALID_DATA, catalog=CATALOG) + self.assertTrue(any(e["code"] == "missing_required_prop" and "pricePerNight" in e["message"] for e in r["errors"])) + + def test_structural_only_without_catalog(self): + comps = [{**c, "component": "MysteryCard"} if c["id"] == "card" else c for c in valid_components()] + r = validate_a2ui_components(components=comps, data=VALID_DATA) + self.assertNotIn("unknown_component", codes(r)) + self.assertTrue(r["valid"]) + + +class TestChildRefs(unittest.TestCase): + def test_structural_child_unresolved(self): + comps = [{"id": "root", "component": "Row", "children": {"componentId": "ghost", "path": "/items"}}] + r = validate_a2ui_components(components=comps, data=VALID_DATA, catalog=CATALOG) + self.assertTrue(any(e["code"] == "unresolved_child" and "ghost" in e["message"] for e in r["errors"])) + + def test_array_child_unresolved(self): + comps = [{"id": "root", "component": "Row", "children": ["missing-1"]}] + r = validate_a2ui_components(components=comps) + self.assertTrue(any(e["code"] == "unresolved_child" and "missing-1" in e["message"] for e in r["errors"])) + + def test_singular_child_unresolved(self): + # One-child containers (Card/Button) use the singular `child`, which the + # default generation prompt emits — a dangling ref there must be caught too. + comps = [{"id": "root", "component": "Card", "child": "ghost"}] + r = validate_a2ui_components(components=comps) + self.assertTrue( + any(e["code"] == "unresolved_child" and e["path"] == "components[0].child" and "ghost" in e["message"] for e in r["errors"]) + ) + + def test_singular_child_resolved(self): + comps = [ + {"id": "root", "component": "Card", "child": "label"}, + {"id": "label", "component": "Text"}, + ] + r = validate_a2ui_components(components=comps) + self.assertNotIn("unresolved_child", codes(r)) + + +class TestChildCycles(unittest.TestCase): + def test_self_referential_child(self): + comps = [{"id": "avatar", "component": "Card", "child": "avatar"}] + r = validate_a2ui_components(components=comps) + self.assertFalse(r["valid"]) + self.assertTrue(any(e["code"] == "child_cycle" and "avatar -> avatar" in e["message"] for e in r["errors"])) + + def test_multi_component_cycle_reported_once(self): + comps = [ + {"id": "root", "component": "Row", "children": ["a"]}, + {"id": "a", "component": "Row", "children": ["b"]}, + {"id": "b", "component": "Row", "children": ["a"]}, + ] + r = validate_a2ui_components(components=comps) + self.assertEqual(len([e for e in r["errors"] if e["code"] == "child_cycle"]), 1) + self.assertTrue(any(e["code"] == "child_cycle" and "a -> b -> a" in e["message"] for e in r["errors"])) + + def test_acyclic_graph_not_flagged(self): + comps = [ + {"id": "root", "component": "Row", "children": ["a", "b"]}, + {"id": "a", "component": "Text"}, + {"id": "b", "component": "Text"}, + ] + r = validate_a2ui_components(components=comps) + self.assertNotIn("child_cycle", codes(r)) + + def test_deep_chain_no_recursion_error(self): + # The cycle check runs on untrusted model output; a deep linear chain that + # would exceed CPython's recursion limit (~1000) must validate iteratively. + n = 5000 + comps = [{"id": "root", "component": "Row", "children": ["n0"]}] + comps += [ + {"id": f"n{i}", "component": "Row", "children": ([f"n{i + 1}"] if i + 1 < n else [])} + for i in range(n) + ] + r = validate_a2ui_components(components=comps) + self.assertNotIn("child_cycle", codes(r)) + + def test_deep_chain_closing_cycle_reported_once(self): + # Same deep chain, but the tail points back at root — one cycle, no overflow. + n = 5000 + comps = [{"id": "root", "component": "Row", "children": ["n0"]}] + comps += [ + {"id": f"n{i}", "component": "Row", "children": [f"n{i + 1}" if i + 1 < n else "root"]} + for i in range(n) + ] + r = validate_a2ui_components(components=comps) + self.assertEqual(len([e for e in r["errors"] if e["code"] == "child_cycle"]), 1) + + +# #1948 — ref-fields beyond child/children, derived from catalog `format` markers. +# A property is a child reference only when marked `componentRef` (single) or +# `componentRefList` (list); unmarked props stay data, so a data string is never +# mistaken for a dangling id. `tabItems[].child` is found via the array item schema. +REF_CATALOG = { + "components": { + "Modal": { + "type": "object", + "properties": { + "trigger": {"type": "string", "format": "componentRef"}, + "content": {"type": "string", "format": "componentRef"}, + "title": {"type": "string"}, # unmarked data prop + }, + }, + "Tabs": { + "type": "object", + "properties": { + "tabItems": { + "type": "array", + "items": {"type": "object", "properties": {"label": {"type": "string"}, "child": {"type": "string", "format": "componentRef"}}}, + }, + }, + }, + "Stack": {"type": "object", "properties": {"items": {"type": "array", "format": "componentRefList"}}}, + "Text": {"type": "object"}, + } +} + + +class TestCatalogDerivedRefFields(unittest.TestCase): + def test_modal_trigger_content_dangling(self): + comps = [{"id": "root", "component": "Modal", "trigger": "ghost-btn", "content": "ghost-body", "title": "Hi"}] + r = validate_a2ui_components(components=comps, catalog=REF_CATALOG) + self.assertTrue(any(e["code"] == "unresolved_child" and e["path"] == "components[0].trigger" and "ghost-btn" in e["message"] for e in r["errors"])) + self.assertTrue(any(e["code"] == "unresolved_child" and e["path"] == "components[0].content" and "ghost-body" in e["message"] for e in r["errors"])) + + def test_unmarked_data_string_not_a_ref(self): + comps = [ + {"id": "root", "component": "Modal", "trigger": "btn", "content": "body", "title": "not-an-id"}, + {"id": "btn", "component": "Text"}, + {"id": "body", "component": "Text"}, + ] + r = validate_a2ui_components(components=comps, catalog=REF_CATALOG) + self.assertNotIn("unresolved_child", codes(r)) + + def test_nested_tabitems_child_dangling_per_index_path(self): + comps = [ + {"id": "root", "component": "Tabs", "tabItems": [{"label": "A", "child": "panel-a"}, {"label": "B", "child": "ghost-panel"}]}, + {"id": "panel-a", "component": "Text"}, + ] + r = validate_a2ui_components(components=comps, catalog=REF_CATALOG) + self.assertTrue(any(e["code"] == "unresolved_child" and e["path"] == "components[0].tabItems[1].child" and "ghost-panel" in e["message"] for e in r["errors"])) + self.assertFalse(any(e["path"] == "components[0].tabItems[0].child" for e in r["errors"])) + + def test_cycle_through_marked_field(self): + comps = [ + {"id": "root", "component": "Modal", "content": "b"}, + {"id": "b", "component": "Card", "child": "root"}, + ] + r = validate_a2ui_components(components=comps, catalog=REF_CATALOG) + self.assertEqual(len([e for e in r["errors"] if e["code"] == "child_cycle"]), 1) + self.assertTrue(any(e["code"] == "child_cycle" and ("b -> root -> b" in e["message"] or "root -> b -> root" in e["message"]) for e in r["errors"])) + + def test_list_ref_array_per_index_path(self): + comps = [{"id": "root", "component": "Stack", "items": ["x", "ghost-1"]}, {"id": "x", "component": "Text"}] + r = validate_a2ui_components(components=comps, catalog=REF_CATALOG) + self.assertTrue(any(e["code"] == "unresolved_child" and e["path"] == "components[0].items[1]" and "ghost-1" in e["message"] for e in r["errors"])) + + def test_marked_fields_ignored_without_catalog(self): + comps = [{"id": "root", "component": "Modal", "trigger": "ghost-btn", "content": "ghost-body"}] + r = validate_a2ui_components(components=comps) + self.assertNotIn("unresolved_child", codes(r)) + + +class TestBindings(unittest.TestCase): + def test_absolute_binding_unresolved(self): + r = validate_a2ui_components(components=valid_components(), data={}, catalog=CATALOG) + self.assertTrue(any(e["code"] == "unresolved_binding" and "/items" in e["message"] for e in r["errors"])) + + def test_relative_bindings_lenient(self): + r = validate_a2ui_components(components=valid_components(), data=VALID_DATA, catalog=CATALOG) + self.assertNotIn("unresolved_binding", codes(r)) + + def test_defers_bindings_when_validate_bindings_false(self): + r = validate_a2ui_components(components=valid_components(), data={}, catalog=CATALOG, validate_bindings=False) + self.assertNotIn("unresolved_binding", codes(r)) + self.assertTrue(r["valid"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 3e74916414..ae2c1f7c97 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,7 +1,8 @@ [project] name = "ag-ui-protocol" -version = "0.1.18" +version = "0.1.19" description = "" +license-files = ["LICENSE"] authors = [ { name = "Markus Ecker", email = "markus.ecker@gmail.com" } ] diff --git a/sdks/typescript/packages/a2ui-toolkit/LICENSE b/sdks/typescript/packages/a2ui-toolkit/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdks/typescript/packages/a2ui-toolkit/README.md b/sdks/typescript/packages/a2ui-toolkit/README.md new file mode 100644 index 0000000000..45229fd2e6 --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/README.md @@ -0,0 +1,25 @@ +# @ag-ui/a2ui-toolkit + +Framework-agnostic helpers for building A2UI subagent tools. + +Each per-framework adapter (LangGraph, ADK, Mastra, …) composes these helpers +with its own framework-specific glue: tool decorator, runtime accessor, model +binding + invoke. Nothing in this package depends on any agent framework. + +## Surface + +- Constants: `A2UI_OPERATIONS_KEY`, `BASIC_CATALOG_ID`, `DEFAULT_SURFACE_ID`, + `GENERATE_A2UI_TOOL_NAME`, `GENERATE_A2UI_TOOL_DESCRIPTION`, + `GENERATE_A2UI_ARG_DESCRIPTIONS` +- Op builders: `createSurface`, `updateComponents`, `updateDataModel` +- `RENDER_A2UI_TOOL_DEF` — JSON schema for the inner structured-output tool +- State + history helpers: `buildContextPrompt`, `findPriorSurface` +- Prompt composer: `buildSubagentPrompt` +- High-level orchestration: `prepareA2UIRequest`, `buildA2UIEnvelope` +- Output wrappers: `assembleOps`, `wrapAsOperationsEnvelope`, `wrapErrorEnvelope` + +## See also + +The Python counterpart lives in +[`ag-ui-a2ui-toolkit`](../../../python/a2ui_toolkit) and exposes the same +surface in snake_case. diff --git a/sdks/typescript/packages/a2ui-toolkit/package.json b/sdks/typescript/packages/a2ui-toolkit/package.json new file mode 100644 index 0000000000..b71aee29d3 --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/package.json @@ -0,0 +1,51 @@ +{ + "name": "@ag-ui/a2ui-toolkit", + "version": "0.0.4", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ag-ui-protocol/ag-ui.git" + }, + "description": "Framework-agnostic helpers for building A2UI subagent tools — op builders, prompt assembly, history walkers, and request/envelope orchestration shared across framework adapters.", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "private": false, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/**", + "README.md" + ], + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "clean": "git clean -fdX --exclude=\"!.env\"", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest", + "test:exports": "publint --strict && attw --pack", + "link:global": "pnpm link --global", + "unlink:global": "pnpm unlink --global" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "^20.11.19", + "@vitest/coverage-istanbul": "^4.0.18", + "publint": "^0.3.12", + "@arethetypeswrong/cli": "^0.17.4", + "vitest": "^4.0.18", + "tsdown": "^0.20.1", + "typescript": "^5.3.3" + }, + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs" + }, + "./package.json": "./package.json" + } +} diff --git a/sdks/typescript/packages/a2ui-toolkit/src/__tests__/recovery.test.ts b/sdks/typescript/packages/a2ui-toolkit/src/__tests__/recovery.test.ts new file mode 100644 index 0000000000..c273e20b46 --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/src/__tests__/recovery.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from "vitest"; +import { + MAX_A2UI_ATTEMPTS, + A2UI_RECOVERY_ACTIVITY_TYPE, + augmentPromptWithValidationErrors, + formatValidationErrors, + runA2UIGenerationWithRecovery, +} from "../recovery"; +import type { A2UIValidationError } from "../validate"; + +const CATALOG = { + components: { + Row: { required: ["children"] }, + HotelCard: { required: ["name", "rating"] }, + }, +}; + +const root = { id: "root", component: "Row", children: { componentId: "card", path: "/items" } }; +const goodCard = { id: "card", component: "HotelCard", name: { path: "name" }, rating: { path: "rating" } }; +const badCard = { id: "card", component: "HotelCard", name: { path: "name" } }; // missing required `rating` + +const goodArgs = { surfaceId: "s1", components: [root, goodCard], data: { items: [{ name: "Ritz", rating: 4.8 }] } }; +const badArgs = { surfaceId: "s1", components: [root, badCard], data: { items: [{ name: "Ritz", rating: 4.8 }] } }; + +const buildEnvelope = (args: Record) => JSON.stringify({ a2ui_operations: args.components }); + +describe("constants", () => { + it("defaults the attempt cap to 3", () => { + expect(MAX_A2UI_ATTEMPTS).toBe(3); + }); + it("names the recovery activity type", () => { + expect(A2UI_RECOVERY_ACTIVITY_TYPE).toBe("a2ui_recovery"); + }); +}); + +describe("augmentPromptWithValidationErrors", () => { + const errors: A2UIValidationError[] = [ + { code: "missing_required_prop", path: "components[1].rating", message: "missing required prop 'rating'" }, + ]; + it("returns the base prompt unchanged when there are no errors", () => { + expect(augmentPromptWithValidationErrors("BASE", [])).toBe("BASE"); + }); + it("appends a fix-it block listing the structured errors", () => { + const out = augmentPromptWithValidationErrors("BASE", errors); + expect(out).toContain("BASE"); + expect(out).toContain("rating"); + expect(out).toContain(formatValidationErrors(errors)); + }); +}); + +describe("runA2UIGenerationWithRecovery", () => { + it("returns the valid envelope on the first attempt without retrying", async () => { + const invokeSubagent = vi.fn(async () => goodArgs); + const res = await runA2UIGenerationWithRecovery({ basePrompt: "P", catalog: CATALOG, invokeSubagent, buildEnvelope }); + expect(res.ok).toBe(true); + expect(res.attempts).toHaveLength(1); + expect(invokeSubagent).toHaveBeenCalledTimes(1); + expect(JSON.parse(res.envelope).a2ui_operations).toBeDefined(); + }); + + it("feeds errors back and recovers on the second attempt", async () => { + const prompts: string[] = []; + const invokeSubagent = vi.fn(async (prompt: string, attempt: number) => { + prompts.push(prompt); + return attempt === 1 ? badArgs : goodArgs; + }); + const res = await runA2UIGenerationWithRecovery({ basePrompt: "P", catalog: CATALOG, invokeSubagent, buildEnvelope }); + expect(res.ok).toBe(true); + expect(res.attempts).toHaveLength(2); + expect(res.attempts[0].ok).toBe(false); + expect(res.attempts[1].ok).toBe(true); + // The retry prompt carried the validation errors back to the sub-agent. + expect(prompts[1]).toContain("rating"); + }); + + it("exhausts after maxAttempts and returns a structured hard-failure envelope", async () => { + const onAttempt = vi.fn(); + const invokeSubagent = vi.fn(async () => badArgs); + const res = await runA2UIGenerationWithRecovery({ basePrompt: "P", catalog: CATALOG, invokeSubagent, buildEnvelope, onAttempt }); + expect(res.ok).toBe(false); + expect(res.attempts).toHaveLength(MAX_A2UI_ATTEMPTS); + expect(invokeSubagent).toHaveBeenCalledTimes(MAX_A2UI_ATTEMPTS); + expect(onAttempt).toHaveBeenCalledTimes(MAX_A2UI_ATTEMPTS); + const parsed = JSON.parse(res.envelope); + expect(parsed.code).toBe("a2ui_recovery_exhausted"); + expect(parsed.error).toBeTruthy(); + expect(Array.isArray(parsed.attempts)).toBe(true); + }); + + it("honors a configured maxAttempts override", async () => { + const invokeSubagent = vi.fn(async () => badArgs); + const res = await runA2UIGenerationWithRecovery({ + basePrompt: "P", + catalog: CATALOG, + config: { maxAttempts: 2 }, + invokeSubagent, + buildEnvelope, + }); + expect(res.ok).toBe(false); + expect(invokeSubagent).toHaveBeenCalledTimes(2); + }); + + it("treats a missing tool call (null) as a failed, retryable attempt", async () => { + const invokeSubagent = vi.fn(async (_p: string, attempt: number) => (attempt === 1 ? null : goodArgs)); + const res = await runA2UIGenerationWithRecovery({ basePrompt: "P", catalog: CATALOG, invokeSubagent, buildEnvelope }); + expect(res.ok).toBe(true); + expect(res.attempts).toHaveLength(2); + expect(res.attempts[0].ok).toBe(false); + }); +}); diff --git a/sdks/typescript/packages/a2ui-toolkit/src/__tests__/toolkit.test.ts b/sdks/typescript/packages/a2ui-toolkit/src/__tests__/toolkit.test.ts new file mode 100644 index 0000000000..f145b76ffa --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/src/__tests__/toolkit.test.ts @@ -0,0 +1,765 @@ +import { describe, it, expect } from "vitest"; +import { + A2UI_OPERATIONS_KEY, + A2UI_SCHEMA_CONTEXT_DESCRIPTION, + BASIC_CATALOG_ID, + DEFAULT_DESIGN_GUIDELINES, + DEFAULT_GENERATION_GUIDELINES, + DEFAULT_SURFACE_ID, + GENERATE_A2UI_TOOL_DESCRIPTION, + GENERATE_A2UI_TOOL_NAME, + RENDER_A2UI_TOOL_DEF, + resolveA2UIToolParams, + assembleOps, + buildA2UIEnvelope, + buildContextPrompt, + buildSubagentPrompt, + createSurface, + findPriorSurface, + prepareA2UIRequest, + resolveA2UICatalog, + splitA2UISchemaContext, + updateComponents, + updateDataModel, + wrapAsOperationsEnvelope, + wrapErrorEnvelope, +} from "../index"; + +describe("constants", () => { + it("A2UI_OPERATIONS_KEY is the wire key the middleware looks for", () => { + expect(A2UI_OPERATIONS_KEY).toBe("a2ui_operations"); + }); + + it("BASIC_CATALOG_ID points at the v0.9 basic catalog", () => { + expect(BASIC_CATALOG_ID).toBe("https://a2ui.org/specification/v0_9/basic_catalog.json"); + }); +}); + +describe("RENDER_A2UI_TOOL_DEF", () => { + it("is shaped as an OpenAI function-call tool definition", () => { + expect(RENDER_A2UI_TOOL_DEF.type).toBe("function"); + expect(RENDER_A2UI_TOOL_DEF.function.name).toBe("render_a2ui"); + }); + + it("requires surfaceId and components", () => { + expect(RENDER_A2UI_TOOL_DEF.function.parameters.required).toEqual(["surfaceId", "components"]); + }); + + it("declares the three expected parameter slots", () => { + expect(Object.keys(RENDER_A2UI_TOOL_DEF.function.parameters.properties)).toEqual([ + "surfaceId", + "components", + "data", + ]); + }); +}); + +describe("op builders", () => { + it("createSurface emits a v0.9 createSurface op", () => { + expect(createSurface("s1", "c1")).toEqual({ + version: "v0.9", + createSurface: { surfaceId: "s1", catalogId: "c1" }, + }); + }); + + it("updateComponents wraps the component array verbatim", () => { + const comps = [{ id: "root", component: "Row" }]; + expect(updateComponents("s1", comps)).toEqual({ + version: "v0.9", + updateComponents: { surfaceId: "s1", components: comps }, + }); + }); + + it("updateDataModel defaults path to /", () => { + expect(updateDataModel("s1", { items: [] })).toEqual({ + version: "v0.9", + updateDataModel: { surfaceId: "s1", path: "/", value: { items: [] } }, + }); + }); + + it("updateDataModel honors a custom path", () => { + expect(updateDataModel("s1", "hello", "/title")).toEqual({ + version: "v0.9", + updateDataModel: { surfaceId: "s1", path: "/title", value: "hello" }, + }); + }); +}); + +describe("buildContextPrompt", () => { + it("returns empty when state has no ag-ui slot", () => { + expect(buildContextPrompt({})).toBe(""); + }); + + it("emits described context entries as markdown sections", () => { + const prompt = buildContextPrompt({ + "ag-ui": { + context: [{ description: "Style guide", value: "use cards" }], + }, + }); + expect(prompt).toContain("## Style guide"); + expect(prompt).toContain("use cards"); + }); + + it("includes value-only entries without a heading", () => { + const prompt = buildContextPrompt({ + "ag-ui": { context: [{ value: "free-form note" }] }, + }); + expect(prompt).toContain("free-form note"); + expect(prompt).not.toContain("##"); + }); + + it("appends the a2ui component catalog under Available Components", () => { + const prompt = buildContextPrompt({ + "ag-ui": { a2ui_schema: "" }, + }); + expect(prompt).toContain("## Available Components"); + expect(prompt).toContain(""); + }); + + it("ignores entries without description or value", () => { + const prompt = buildContextPrompt({ + "ag-ui": { context: [{}] }, + }); + expect(prompt).toBe(""); + }); +}); + +describe("splitA2UISchemaContext", () => { + it("splits the schema entry from regular context", () => { + const [schema, regular] = splitA2UISchemaContext([ + { description: "Style guide", value: "use cards" }, + { description: A2UI_SCHEMA_CONTEXT_DESCRIPTION, value: "" }, + ]); + expect(schema).toBe(""); + expect(regular).toHaveLength(1); + expect(regular[0].description).toBe("Style guide"); + }); + + it("returns undefined schema when no schema entry is present", () => { + const [schema, regular] = splitA2UISchemaContext([ + { description: "Style guide", value: "use cards" }, + ]); + expect(schema).toBeUndefined(); + expect(regular).toHaveLength(1); + }); + + it("handles null/undefined context", () => { + expect(splitA2UISchemaContext(undefined)).toEqual([undefined, []]); + expect(splitA2UISchemaContext(null)).toEqual([undefined, []]); + }); + + it("round-trips into buildContextPrompt", () => { + const [schema, regular] = splitA2UISchemaContext([ + { description: "App context", value: "on dashboard" }, + { description: A2UI_SCHEMA_CONTEXT_DESCRIPTION, value: "" }, + ]); + const prompt = buildContextPrompt({ + "ag-ui": { context: regular, a2ui_schema: schema }, + }); + expect(prompt).toContain("## Available Components"); + expect(prompt).toContain(""); + expect(prompt).toContain("## App context"); + expect(prompt).not.toContain(A2UI_SCHEMA_CONTEXT_DESCRIPTION); + }); +}); + +describe("resolveA2UICatalog", () => { + it("reads catalogId from the native ag-ui a2ui_schema (schema undefined)", () => { + const resolved = resolveA2UICatalog({ + "ag-ui": { + a2ui_schema: JSON.stringify({ catalogId: "my-catalog", components: [] }), + }, + }); + expect(resolved).toEqual([undefined, "my-catalog"]); + }); + + it("accepts an already-parsed a2ui_schema object", () => { + const resolved = resolveA2UICatalog({ + "ag-ui": { a2ui_schema: { catalogId: "parsed-cat" } }, + }); + expect(resolved?.[1]).toBe("parsed-cat"); + }); + + it("degrades to no id on unparseable schema", () => { + const resolved = resolveA2UICatalog({ "ag-ui": { a2ui_schema: "{not json" } }); + expect(resolved).toEqual([undefined, undefined]); + }); + + it("reads the catalog from an 'A2UI catalog' context entry (first listed)", () => { + const resolved = resolveA2UICatalog({ + "ag-ui": { + context: [ + { description: "unrelated", value: "x" }, + { description: "Registered A2UI catalog", value: "- custom-cat\n- basic" }, + ], + }, + }); + expect(resolved?.[1]).toBe("custom-cat"); + expect(resolved?.[0]).toContain("custom-cat"); + }); + + it("prefers the schema entry over the context entry", () => { + const resolved = resolveA2UICatalog({ + "ag-ui": { + a2ui_schema: JSON.stringify({ catalogId: "native-cat" }), + context: [{ description: "A2UI catalog", value: "- ctx-cat" }], + }, + }); + expect(resolved?.[1]).toBe("native-cat"); + }); + + it("returns undefined when no catalog is present", () => { + expect(resolveA2UICatalog({})).toBeUndefined(); + expect(resolveA2UICatalog({ "ag-ui": { context: [] } })).toBeUndefined(); + }); +}); + +describe("findPriorSurface", () => { + function toolMsg(content: unknown) { + return { role: "tool", content: JSON.stringify(content) }; + } + + it("returns undefined when the surface is not present", () => { + const messages = [toolMsg({ [A2UI_OPERATIONS_KEY]: [] })]; + expect(findPriorSurface(messages, "missing")).toBeUndefined(); + }); + + it("returns the most recent rendered state when found", () => { + const messages = [ + toolMsg({ + [A2UI_OPERATIONS_KEY]: [ + createSurface("s1", "cat://x"), + updateComponents("s1", [{ id: "root", component: "Row" }]), + updateDataModel("s1", { items: [1, 2] }), + ], + }), + ]; + expect(findPriorSurface(messages, "s1")).toEqual({ + components: [{ id: "root", component: "Row" }], + data: { items: [1, 2] }, + catalogId: "cat://x", + }); + }); + + it("prefers the latest matching tool result when multiple exist", () => { + const messages = [ + toolMsg({ + [A2UI_OPERATIONS_KEY]: [ + createSurface("s1", "old-cat"), + updateComponents("s1", [{ id: "root", component: "Row" }]), + ], + }), + toolMsg({ + [A2UI_OPERATIONS_KEY]: [ + updateComponents("s1", [{ id: "root", component: "Column" }]), + updateDataModel("s1", { changed: true }), + ], + }), + ]; + const prior = findPriorSurface(messages, "s1"); + expect(prior?.components).toEqual([{ id: "root", component: "Column" }]); + expect(prior?.data).toEqual({ changed: true }); + }); + + it("within a single message, the last op for each field wins (renderer-apply order)", () => { + // One envelope emits multiple ops for the same surface. The renderer + // applies them in order, so the surface ends at layout-b / {v:2} / cat-B. + const messages = [ + toolMsg({ + [A2UI_OPERATIONS_KEY]: [ + createSurface("s1", "cat-A"), + updateComponents("s1", [{ id: "root", component: "Row" }]), + updateDataModel("s1", { v: 1 }), + createSurface("s1", "cat-B"), + updateComponents("s1", [{ id: "root", component: "Column" }]), + updateDataModel("s1", { v: 2 }), + ], + }), + ]; + const prior = findPriorSurface(messages, "s1"); + expect(prior).toEqual({ + components: [{ id: "root", component: "Column" }], + data: { v: 2 }, + catalogId: "cat-B", + }); + }); + + it("accumulates fields across the walk when a later turn omits some", () => { + // Turn 1: full create + components + initial data. + // Turn 2: only updateDataModel (e.g. a quick data refresh without re-emitting the layout). + // The walker must still surface the components + catalogId from turn 1. + const messages = [ + toolMsg({ + [A2UI_OPERATIONS_KEY]: [ + createSurface("s1", "cat://x"), + updateComponents("s1", [{ id: "root", component: "Row" }]), + updateDataModel("s1", { items: [1] }), + ], + }), + toolMsg({ + [A2UI_OPERATIONS_KEY]: [updateDataModel("s1", { items: [1, 2, 3] })], + }), + ]; + const prior = findPriorSurface(messages, "s1"); + expect(prior).toEqual({ + components: [{ id: "root", component: "Row" }], + data: { items: [1, 2, 3] }, + catalogId: "cat://x", + }); + }); + + it("ignores non-tool messages and unparseable content", () => { + const messages = [ + { role: "assistant", content: "not a tool" }, + { role: "tool", content: "not json" }, + toolMsg({ unrelated: "payload" }), + ]; + expect(findPriorSurface(messages, "s1")).toBeUndefined(); + }); + + it("accepts ToolMessage's `type` field as well as `role`", () => { + const messages = [ + { + type: "tool", + content: JSON.stringify({ + [A2UI_OPERATIONS_KEY]: [ + createSurface("s1", "c"), + updateComponents("s1", [{ id: "root", component: "Row" }]), + ], + }), + }, + ]; + expect(findPriorSurface(messages, "s1")?.catalogId).toBe("c"); + }); + + it("returns undefined when the newest mention of the surface is a deleteSurface", () => { + // Older message created + populated the surface; newer message deletes it. + // The renderer no longer shows the surface, so the toolkit must NOT + // resurrect its stale state from the older create/update ops. + const messages = [ + toolMsg({ + [A2UI_OPERATIONS_KEY]: [ + createSurface("s1", "cat://x"), + updateComponents("s1", [{ id: "root", component: "Row" }]), + updateDataModel("s1", { items: [1, 2] }), + ], + }), + toolMsg({ + [A2UI_OPERATIONS_KEY]: [{ version: "v0.9", deleteSurface: { surfaceId: "s1" } }], + }), + ]; + expect(findPriorSurface(messages, "s1")).toBeUndefined(); + }); + + it("ignores an older deleteSurface that a newer message resurrects", () => { + // Newest message creates + populates the surface; older message had + // deleted it. The newer state is authoritative — must not be wiped. + const messages = [ + toolMsg({ + [A2UI_OPERATIONS_KEY]: [{ version: "v0.9", deleteSurface: { surfaceId: "s1" } }], + }), + toolMsg({ + [A2UI_OPERATIONS_KEY]: [ + createSurface("s1", "cat://new"), + updateComponents("s1", [{ id: "root", component: "Column" }]), + updateDataModel("s1", { items: [9] }), + ], + }), + ]; + expect(findPriorSurface(messages, "s1")).toEqual({ + components: [{ id: "root", component: "Column" }], + data: { items: [9] }, + catalogId: "cat://new", + }); + }); + + it("intra-message delete followed by create yields the recreated state", () => { + // Within one message, ops apply in order. Delete then create → surface + // exists with the recreated content at end of message. + const messages = [ + toolMsg({ + [A2UI_OPERATIONS_KEY]: [ + { version: "v0.9", deleteSurface: { surfaceId: "s1" } }, + createSurface("s1", "cat-recreated"), + updateComponents("s1", [{ id: "root", component: "Row" }]), + ], + }), + ]; + expect(findPriorSurface(messages, "s1")).toEqual({ + components: [{ id: "root", component: "Row" }], + data: undefined, + catalogId: "cat-recreated", + }); + }); + + it("intra-message create followed by delete yields undefined", () => { + // Within one message, the surface is created then deleted — end state is + // deleted regardless of the older accumulated state in prior messages. + const messages = [ + toolMsg({ + [A2UI_OPERATIONS_KEY]: [ + createSurface("s1", "older-cat"), + updateComponents("s1", [{ id: "root", component: "Row" }]), + ], + }), + toolMsg({ + [A2UI_OPERATIONS_KEY]: [ + createSurface("s1", "transient"), + { version: "v0.9", deleteSurface: { surfaceId: "s1" } }, + ], + }), + ]; + expect(findPriorSurface(messages, "s1")).toBeUndefined(); + }); +}); + +describe("buildSubagentPrompt", () => { + // Suppress both built-in default blocks so structural tests can assert exact + // output without the (large) DEFAULT_* text. Empty string is the documented + // escape hatch (undefined → default; "" → block omitted). + const SUPPRESS = { generationGuidelines: "", designGuidelines: "" }; + + it("applies the built-in defaults when no guidelines are given (OSS-248)", () => { + const prompt = buildSubagentPrompt({ contextPrompt: "ctx" }); + expect(prompt).toContain(DEFAULT_GENERATION_GUIDELINES); + expect(prompt).toContain("## Design Guidelines"); + expect(prompt).toContain(DEFAULT_DESIGN_GUIDELINES); + expect(prompt).toContain("ctx"); + }); + + it("orders generation → design → context → composition", () => { + const prompt = buildSubagentPrompt({ + contextPrompt: "CTXMARK", + guidelines: { + generationGuidelines: "GENMARK", + designGuidelines: "DESMARK", + compositionGuide: "COMPMARK", + }, + }); + expect(prompt.indexOf("GENMARK")).toBeLessThan(prompt.indexOf("DESMARK")); + expect(prompt.indexOf("DESMARK")).toBeLessThan(prompt.indexOf("CTXMARK")); + expect(prompt.indexOf("CTXMARK")).toBeLessThan(prompt.indexOf("COMPMARK")); + }); + + it("overrides one block per-field, keeping the other default", () => { + const prompt = buildSubagentPrompt({ + contextPrompt: "ctx", + guidelines: { generationGuidelines: "CUSTOM_GEN" }, + }); + expect(prompt).toContain("CUSTOM_GEN"); + expect(prompt).not.toContain(DEFAULT_GENERATION_GUIDELINES); + expect(prompt).toContain(DEFAULT_DESIGN_GUIDELINES); + }); + + it("suppresses a block when passed an empty string", () => { + const prompt = buildSubagentPrompt({ contextPrompt: "ctx", guidelines: SUPPRESS }); + expect(prompt).not.toContain(DEFAULT_GENERATION_GUIDELINES); + expect(prompt).not.toContain(DEFAULT_DESIGN_GUIDELINES); + expect(prompt).not.toContain("## Design Guidelines"); + }); + + it("returns the context prompt verbatim when no extras", () => { + expect(buildSubagentPrompt({ contextPrompt: "ctx", guidelines: SUPPRESS })).toBe("ctx"); + }); + + it("appends composition guide after the context prompt", () => { + const prompt = buildSubagentPrompt({ + contextPrompt: "ctx", + guidelines: { ...SUPPRESS, compositionGuide: "guide" }, + }); + expect(prompt).toBe("ctx\nguide"); + }); + + it("emits an edit block carrying the prior surface state", () => { + const prompt = buildSubagentPrompt({ + contextPrompt: "ctx", + guidelines: SUPPRESS, + editContext: { + surfaceId: "s1", + prior: { components: [{ id: "root", component: "Row" }], data: { x: 1 } }, + changes: "make the title bigger", + }, + }); + expect(prompt).toContain("Editing an existing surface"); + expect(prompt).toContain("'s1'"); + expect(prompt).toContain('"id": "root"'); + expect(prompt).toContain('"x": 1'); + expect(prompt).toContain("Requested changes"); + expect(prompt).toContain("make the title bigger"); + }); + + it("omits the requested-changes section when changes is missing", () => { + const prompt = buildSubagentPrompt({ + contextPrompt: "ctx", + guidelines: SUPPRESS, + editContext: { + surfaceId: "s1", + prior: { components: [], data: null }, + }, + }); + expect(prompt).not.toContain("Requested changes"); + }); + + it("drops empty parts from the join", () => { + expect(buildSubagentPrompt({ contextPrompt: "", guidelines: SUPPRESS })).toBe(""); + }); +}); + +describe("assembleOps", () => { + it("create intent emits createSurface + updateComponents + updateDataModel", () => { + const ops = assembleOps({ + intent: "create", + surfaceId: "s1", + catalogId: "cat://x", + components: [{ id: "root", component: "Row" }], + data: { items: ["a"] }, + }); + expect(ops).toHaveLength(3); + expect(ops[0]).toHaveProperty("createSurface"); + expect(ops[1]).toHaveProperty("updateComponents"); + expect(ops[2]).toHaveProperty("updateDataModel"); + }); + + it("update intent skips createSurface so the frontend reconciles in place", () => { + const ops = assembleOps({ + intent: "update", + surfaceId: "s1", + catalogId: "cat://x", + components: [{ id: "root", component: "Row" }], + data: { items: ["a"] }, + }); + expect(ops).toHaveLength(2); + expect(ops[0]).toHaveProperty("updateComponents"); + expect(ops[1]).toHaveProperty("updateDataModel"); + }); + + it("omits updateDataModel when no data is provided", () => { + const ops = assembleOps({ + intent: "create", + surfaceId: "s1", + catalogId: "cat://x", + components: [{ id: "root", component: "Row" }], + }); + expect(ops).toHaveLength(2); + expect(ops[0]).toHaveProperty("createSurface"); + expect(ops[1]).toHaveProperty("updateComponents"); + }); + + it("omits updateDataModel when data is an empty object", () => { + const ops = assembleOps({ + intent: "create", + surfaceId: "s1", + catalogId: "cat://x", + components: [{ id: "root", component: "Row" }], + data: {}, + }); + expect(ops).toHaveLength(2); + }); +}); + +describe("wrapAsOperationsEnvelope", () => { + it("serializes ops under the A2UI_OPERATIONS_KEY", () => { + const ops = [createSurface("s1", "c")]; + const envelope = JSON.parse(wrapAsOperationsEnvelope(ops)); + expect(envelope).toEqual({ [A2UI_OPERATIONS_KEY]: ops }); + }); + + it("handles an empty ops list", () => { + expect(JSON.parse(wrapAsOperationsEnvelope([]))).toEqual({ + [A2UI_OPERATIONS_KEY]: [], + }); + }); +}); + +describe("wrapErrorEnvelope", () => { + it("wraps a message under the error key", () => { + expect(JSON.parse(wrapErrorEnvelope("boom"))).toEqual({ error: "boom" }); + }); +}); + +// A prior surface encoded the way it appears in conversation history. +function priorSurfaceMessage(surfaceId: string) { + return { + type: "tool", + content: wrapAsOperationsEnvelope([ + createSurface(surfaceId, "cat://x"), + updateComponents(surfaceId, [{ id: "root", component: "Row" }]), + updateDataModel(surfaceId, { items: [1, 2] }), + ]), + }; +} + +describe("prepareA2UIRequest", () => { + it("create: builds a prompt, no prior, not an update", () => { + const prep = prepareA2UIRequest({ + intent: "create", + messages: [], + state: { "ag-ui": { context: [{ value: "ctx" }] } }, + guidelines: { compositionGuide: "guide" }, + }); + expect(prep.error).toBeUndefined(); + expect(prep.isUpdate).toBe(false); + expect(prep.prior).toBeUndefined(); + expect(prep.prompt).toContain("ctx"); + expect(prep.prompt).toContain("guide"); + }); + + it("defaults a missing intent to create", () => { + const prep = prepareA2UIRequest({ messages: [], state: {} }); + expect(prep.isUpdate).toBe(false); + expect(prep.error).toBeUndefined(); + }); + + it("update with a matching prior surface: edit prompt + prior populated", () => { + const prep = prepareA2UIRequest({ + intent: "update", + targetSurfaceId: "s1", + changes: "make it red", + messages: [priorSurfaceMessage("s1")], + state: {}, + }); + expect(prep.error).toBeUndefined(); + expect(prep.isUpdate).toBe(true); + expect(prep.prior?.catalogId).toBe("cat://x"); + expect(prep.prompt).toContain("Editing an existing surface"); + expect(prep.prompt).toContain("make it red"); + }); + + it("update with no matching prior: returns an error, no prompt", () => { + const prep = prepareA2UIRequest({ + intent: "update", + targetSurfaceId: "missing", + messages: [priorSurfaceMessage("s1")], + state: {}, + }); + expect(prep.prompt).toBe(""); + expect(prep.error).toContain("missing"); + expect(prep.error).toContain("no prior render"); + }); +}); + +describe("buildA2UIEnvelope", () => { + it("create: createSurface uses the configured default catalog, not the args", () => { + const env = JSON.parse( + buildA2UIEnvelope({ + args: { + surfaceId: "from-args", + components: [{ id: "root", component: "Row" }], + data: { items: [1] }, + }, + isUpdate: false, + defaultCatalogId: "cat://configured", + }), + ); + const ops = env[A2UI_OPERATIONS_KEY]; + expect(ops[0].createSurface).toEqual({ surfaceId: "from-args", catalogId: "cat://configured" }); + expect(ops[1].updateComponents.components).toEqual([{ id: "root", component: "Row" }]); + expect(ops[2].updateDataModel.value).toEqual({ items: [1] }); + }); + + it("create: falls back to DEFAULT_SURFACE_ID when args omit surfaceId", () => { + const env = JSON.parse(buildA2UIEnvelope({ args: { components: [] }, isUpdate: false })); + expect(env[A2UI_OPERATIONS_KEY][0].createSurface.surfaceId).toBe(DEFAULT_SURFACE_ID); + }); + + it("create: empty-string defaultSurfaceId / defaultCatalogId fall back to canonical", () => { + // Misconfigured host: both defaults are the empty string. Must NOT + // propagate "" into the emitted ops — the renderer would surface as + // "Catalog not found: " / blank surface id. Mirror of the Python + // test_empty_string_defaults_fall_back_to_canonical for cross-language + // parity. + const env = JSON.parse( + buildA2UIEnvelope({ + args: { components: [{ id: "root", component: "Row" }] }, + isUpdate: false, + defaultSurfaceId: "", + defaultCatalogId: "", + }), + ); + const ops = env[A2UI_OPERATIONS_KEY]; + const cs = ops.find((o: any) => o.createSurface).createSurface; + expect(cs.surfaceId).not.toBe(""); + expect(cs.catalogId).not.toBe(""); + expect(cs.surfaceId).toBe(DEFAULT_SURFACE_ID); + expect(cs.catalogId).toBe(BASIC_CATALOG_ID); + }); + + it("create: non-string args.surfaceId falls back to DEFAULT_SURFACE_ID", () => { + // The model is untrusted — `args.surfaceId` may come back as a number, + // array, null, object, or boolean. Without the typeof-string narrow, + // a non-string value propagates into createSurface.surfaceId and the + // renderer crashes (it expects a string id). Mirror of the Python + // test_non_string_arg_surface_id_falls_back_to_default. + for (const bad of [42, ["x"], null, { a: 1 }, true]) { + const env = JSON.parse( + buildA2UIEnvelope({ + args: { surfaceId: bad as any, components: [] }, + isUpdate: false, + }), + ); + const cs = env[A2UI_OPERATIONS_KEY].find((o: any) => o.createSurface).createSurface; + expect(cs.surfaceId).toBe(DEFAULT_SURFACE_ID); + expect(typeof cs.surfaceId).toBe("string"); + } + }); + + it("update: empty-string targetSurfaceId falls back to DEFAULT_SURFACE_ID", () => { + // Direct callers of buildA2UIEnvelope (bypassing prepareA2UIRequest) may + // pass `targetSurfaceId: ""` on the update path. Empty strings must NOT + // propagate into updateComponents.surfaceId. + const env = JSON.parse( + buildA2UIEnvelope({ + args: { components: [{ id: "root", component: "Row" }] }, + isUpdate: true, + targetSurfaceId: "", + prior: { components: [], data: null, catalogId: "cat://prior" }, + }), + ); + const ops = env[A2UI_OPERATIONS_KEY]; + const uc = ops.find((o: any) => o.updateComponents).updateComponents; + expect(uc.surfaceId).toBe(DEFAULT_SURFACE_ID); + expect(uc.surfaceId).not.toBe(""); + }); + + it("update: skips createSurface, keeps target id + prior catalog", () => { + const env = JSON.parse( + buildA2UIEnvelope({ + args: { surfaceId: "ignored", components: [{ id: "root", component: "Column" }] }, + isUpdate: true, + targetSurfaceId: "s1", + prior: { components: [], data: null, catalogId: "cat://prior" }, + }), + ); + const ops = env[A2UI_OPERATIONS_KEY]; + expect(ops.some((o: any) => o.createSurface)).toBe(false); + expect(ops[0].updateComponents.surfaceId).toBe("s1"); + }); +}); + +describe("resolveA2UIToolParams", () => { + it("fills the canonical defaults", () => { + const r = resolveA2UIToolParams({ model: "M" }); + expect(r.model).toBe("M"); + expect(r.defaultSurfaceId).toBe(DEFAULT_SURFACE_ID); + expect(r.defaultCatalogId).toBe(BASIC_CATALOG_ID); + expect(r.toolName).toBe(GENERATE_A2UI_TOOL_NAME); + expect(r.toolDescription).toBe(GENERATE_A2UI_TOOL_DESCRIPTION); + expect(r.guidelines).toBeUndefined(); + }); + + it("falls back to defaults on empty-string overrides", () => { + const r = resolveA2UIToolParams({ model: "M", toolName: "", defaultCatalogId: "" }); + expect(r.toolName).toBe(GENERATE_A2UI_TOOL_NAME); + expect(r.defaultCatalogId).toBe(BASIC_CATALOG_ID); + }); + + it("passes overrides through", () => { + const r = resolveA2UIToolParams({ + model: "M", + toolName: "custom_tool", + guidelines: { compositionGuide: "g" }, + }); + expect(r.toolName).toBe("custom_tool"); + expect(r.guidelines).toEqual({ compositionGuide: "g" }); + }); +}); diff --git a/sdks/typescript/packages/a2ui-toolkit/src/__tests__/validate.test.ts b/sdks/typescript/packages/a2ui-toolkit/src/__tests__/validate.test.ts new file mode 100644 index 0000000000..7e65130274 --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/src/__tests__/validate.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect } from "vitest"; +import { validateA2UIComponents } from "../validate"; + +// A minimal inline JSON-Schema catalog mirroring the middleware's +// A2UIInlineCatalogSchema: components keyed by name, each a JSON Schema whose +// `required` lists mandatory props. +const CATALOG = { + components: { + Row: { + type: "object", + properties: { gap: { type: "number" }, children: {} }, + required: ["children"], + }, + HotelCard: { + type: "object", + properties: { + name: {}, + location: {}, + rating: {}, + pricePerNight: {}, + action: {}, + }, + required: ["name", "location", "rating", "pricePerNight"], + }, + }, +}; + +// A well-formed dynamic surface: Row root repeating a HotelCard over /items. +function validComponents() { + return [ + { + id: "root", + component: "Row", + children: { componentId: "card", path: "/items" }, + }, + { + id: "card", + component: "HotelCard", + name: { path: "name" }, + location: { path: "location" }, + rating: { path: "rating" }, + pricePerNight: { path: "pricePerNight" }, + }, + ]; +} +const VALID_DATA = { items: [{ name: "Ritz", location: "NYC", rating: 4.8, pricePerNight: "$450" }] }; + +describe("validateA2UIComponents — happy path", () => { + it("accepts a well-formed surface against its catalog", () => { + const r = validateA2UIComponents({ components: validComponents(), data: VALID_DATA, catalog: CATALOG }); + expect(r.valid).toBe(true); + expect(r.errors).toEqual([]); + }); +}); + +describe("validateA2UIComponents — structural (no catalog needed)", () => { + it("flags a missing root component", () => { + const comps = validComponents().map((c) => (c.id === "root" ? { ...c, id: "container" } : c)); + const r = validateA2UIComponents({ components: comps, data: VALID_DATA, catalog: CATALOG }); + expect(r.valid).toBe(false); + expect(r.errors.some((e) => e.code === "no_root")).toBe(true); + }); + + it("flags a component missing a string id", () => { + const comps: Array> = [{ component: "Row", children: [] }]; + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.some((e) => e.code === "missing_id" && e.path === "components[0].id")).toBe(true); + }); + + it("flags a component missing a string component type", () => { + const comps: Array> = [{ id: "root" }]; + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.some((e) => e.code === "missing_component_type")).toBe(true); + }); + + it("flags duplicate ids", () => { + const comps = [ + { id: "root", component: "Row", children: ["x"] }, + { id: "x", component: "Row", children: [] }, + { id: "x", component: "Row", children: [] }, + ]; + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.some((e) => e.code === "duplicate_id")).toBe(true); + }); + + it("fails loud on a non-array / empty components payload", () => { + expect(validateA2UIComponents({ components: [] }).valid).toBe(false); + // @ts-expect-error — exercising the untrusted-input guard + expect(validateA2UIComponents({ components: null }).valid).toBe(false); + }); +}); + +describe("validateA2UIComponents — catalog semantics (only when a catalog is supplied)", () => { + it("flags a component type not in the catalog", () => { + const comps = validComponents().map((c) => (c.id === "card" ? { ...c, component: "MysteryCard" } : c)); + const r = validateA2UIComponents({ components: comps, data: VALID_DATA, catalog: CATALOG }); + expect(r.errors.some((e) => e.code === "unknown_component" && e.path === "components[1].component")).toBe(true); + }); + + it("flags a missing required prop per the catalog schema", () => { + const comps = validComponents().map((c) => { + if (c.id !== "card") return c; + const { pricePerNight, ...rest } = c as Record; + return rest; + }); + const r = validateA2UIComponents({ components: comps, data: VALID_DATA, catalog: CATALOG }); + expect(r.errors.some((e) => e.code === "missing_required_prop" && /pricePerNight/.test(e.message))).toBe(true); + }); + + it("skips catalog checks entirely when no catalog is supplied (structural-only)", () => { + const comps = validComponents().map((c) => (c.id === "card" ? { ...c, component: "MysteryCard" } : c)); + const r = validateA2UIComponents({ components: comps, data: VALID_DATA }); + expect(r.errors.some((e) => e.code === "unknown_component")).toBe(false); + expect(r.valid).toBe(true); + }); +}); + +describe("validateA2UIComponents — child references", () => { + it("flags a structural child referencing a non-existent component id", () => { + const comps = [ + { id: "root", component: "Row", children: { componentId: "ghost", path: "/items" } }, + ]; + const r = validateA2UIComponents({ components: comps, data: VALID_DATA, catalog: CATALOG }); + expect(r.errors.some((e) => e.code === "unresolved_child" && /ghost/.test(e.message))).toBe(true); + }); + + it("flags an array child id that does not resolve", () => { + const comps = [ + { id: "root", component: "Row", children: ["missing-1"] }, + ]; + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.some((e) => e.code === "unresolved_child" && /missing-1/.test(e.message))).toBe(true); + }); + + it("flags a singular `child` referencing a non-existent component id", () => { + // One-child containers (Card/Button) use the singular `child`, which the + // default generation prompt emits — a dangling ref there must be caught too. + const comps = [{ id: "root", component: "Card", child: "ghost" }]; + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.some((e) => e.code === "unresolved_child" && e.path === "components[0].child" && /ghost/.test(e.message))).toBe(true); + }); + + it("accepts a singular `child` pointing at a real component id", () => { + const comps = [ + { id: "root", component: "Card", child: "label" }, + { id: "label", component: "Text" }, + ]; + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.some((e) => e.code === "unresolved_child")).toBe(false); + }); +}); + +describe("validateA2UIComponents — child cycles", () => { + it("flags a self-referential singular `child`", () => { + const comps = [{ id: "avatar", component: "Card", child: "avatar" }]; + const r = validateA2UIComponents({ components: comps }); + expect(r.valid).toBe(false); + expect(r.errors.some((e) => e.code === "child_cycle" && /avatar -> avatar/.test(e.message))).toBe(true); + }); + + it("flags a multi-component cycle and reports it once", () => { + const comps = [ + { id: "root", component: "Row", children: ["a"] }, + { id: "a", component: "Row", children: ["b"] }, + { id: "b", component: "Row", children: ["a"] }, + ]; + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.filter((e) => e.code === "child_cycle").length).toBe(1); + expect(r.errors.some((e) => e.code === "child_cycle" && /a -> b -> a/.test(e.message))).toBe(true); + }); + + it("does not flag an acyclic child graph", () => { + const comps = [ + { id: "root", component: "Row", children: ["a", "b"] }, + { id: "a", component: "Text" }, + { id: "b", component: "Text" }, + ]; + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.some((e) => e.code === "child_cycle")).toBe(false); + }); + + it( + "handles a pathologically deep child chain without overflowing the stack", + () => { + // The cycle check runs on untrusted model output; a deep linear chain that + // would blow a recursive DFS's call stack must validate iteratively. 20k deep + // is >2x V8's recursion overflow depth (~8.8k for a trivial frame, lower for + // a real DFS frame), so it still proves the walk is iterative — but allocates + // far less than 50k, keeping GC pressure (and thus the chance of a CI-runner + // stall) low. The explicit-stack walk itself is linear (~25ms for this N). + const N = 20000; + const comps: Array> = [{ id: "root", component: "Row", children: ["n0"] }]; + for (let i = 0; i < N; i++) comps.push({ id: `n${i}`, component: "Row", children: i + 1 < N ? [`n${i + 1}`] : [] }); + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.some((e) => e.code === "child_cycle")).toBe(false); + }, + // The work is ~25ms; the default 5s timeout flaked under parallel-fork GC + // contention on shared CI runners. A generous explicit budget decouples the + // (provably fast) assertion from runner scheduling jitter. + 30000, + ); + + it( + "detects a cycle that closes at the end of a deep chain", + () => { + // Same deep chain, but the tail points back at root — one cycle, no overflow. + const N = 20000; + const comps: Array> = [{ id: "root", component: "Row", children: ["n0"] }]; + for (let i = 0; i < N; i++) comps.push({ id: `n${i}`, component: "Row", children: [i + 1 < N ? `n${i + 1}` : "root"] }); + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.filter((e) => e.code === "child_cycle").length).toBe(1); + }, + 30000, + ); +}); + +// #1948 — ref-fields beyond child/children, derived from catalog `format` markers. +// A property is a child reference only when its schema marks it `componentRef` +// (single) or `componentRefList` (list); unmarked props stay data, so a data +// string is never mistaken for a dangling id. `tabItems[].child` is found by +// honouring markers on an array property's item sub-schema. +const REF_CATALOG = { + components: { + Modal: { + type: "object", + properties: { + trigger: { type: "string", format: "componentRef" }, + content: { type: "string", format: "componentRef" }, + title: { type: "string" }, // unmarked data prop + }, + }, + Tabs: { + type: "object", + properties: { + tabItems: { + type: "array", + items: { type: "object", properties: { label: { type: "string" }, child: { type: "string", format: "componentRef" } } }, + }, + }, + }, + Stack: { + type: "object", + properties: { items: { type: "array", format: "componentRefList" } }, + }, + Text: { type: "object" }, + }, +}; + +describe("validateA2UIComponents — catalog-derived ref-fields (#1948)", () => { + it("flags a dangling Modal `trigger`/`content` ref via the catalog marker", () => { + const comps = [{ id: "root", component: "Modal", trigger: "ghost-btn", content: "ghost-body", title: "Hi" }]; + const r = validateA2UIComponents({ components: comps, catalog: REF_CATALOG }); + expect(r.errors.some((e) => e.code === "unresolved_child" && e.path === "components[0].trigger" && /ghost-btn/.test(e.message))).toBe(true); + expect(r.errors.some((e) => e.code === "unresolved_child" && e.path === "components[0].content" && /ghost-body/.test(e.message))).toBe(true); + }); + + it("does not treat an unmarked data string as a child reference", () => { + // `title` is a plain string prop; its value must never be flagged as a dangling id. + const comps = [ + { id: "root", component: "Modal", trigger: "btn", content: "body", title: "not-an-id" }, + { id: "btn", component: "Text" }, + { id: "body", component: "Text" }, + ]; + const r = validateA2UIComponents({ components: comps, catalog: REF_CATALOG }); + expect(r.errors.some((e) => e.code === "unresolved_child")).toBe(false); + }); + + it("flags a dangling nested `tabItems[k].child` with a per-index path", () => { + const comps = [ + { + id: "root", + component: "Tabs", + tabItems: [ + { label: "A", child: "panel-a" }, + { label: "B", child: "ghost-panel" }, + ], + }, + { id: "panel-a", component: "Text" }, + ]; + const r = validateA2UIComponents({ components: comps, catalog: REF_CATALOG }); + expect(r.errors.some((e) => e.code === "unresolved_child" && e.path === "components[0].tabItems[1].child" && /ghost-panel/.test(e.message))).toBe(true); + expect(r.errors.some((e) => e.path === "components[0].tabItems[0].child")).toBe(false); + }); + + it("detects a cycle routed through a catalog-marked field", () => { + // root(Modal).content -> b(Card).child -> root : undetectable without the marker. + const comps = [ + { id: "root", component: "Modal", content: "b" }, + { id: "b", component: "Card", child: "root" }, + ]; + const r = validateA2UIComponents({ components: comps, catalog: REF_CATALOG }); + expect(r.errors.filter((e) => e.code === "child_cycle").length).toBe(1); + expect(r.errors.some((e) => e.code === "child_cycle" && /b -> root -> b|root -> b -> root/.test(e.message))).toBe(true); + }); + + it("emits per-index paths for list-ref array refs", () => { + const comps = [{ id: "root", component: "Stack", items: ["x", "ghost-1"] }, { id: "x", component: "Text" }]; + const r = validateA2UIComponents({ components: comps, catalog: REF_CATALOG }); + expect(r.errors.some((e) => e.code === "unresolved_child" && e.path === "components[0].items[1]" && /ghost-1/.test(e.message))).toBe(true); + }); + + it("ignores marked ref-fields when no catalog is supplied (structural child/children only)", () => { + const comps = [{ id: "root", component: "Modal", trigger: "ghost-btn", content: "ghost-body" }]; + const r = validateA2UIComponents({ components: comps }); + expect(r.errors.some((e) => e.code === "unresolved_child")).toBe(false); + }); +}); + +describe("validateA2UIComponents — data bindings", () => { + it("flags an absolute binding path absent from the data model", () => { + const r = validateA2UIComponents({ components: validComponents(), data: {}, catalog: CATALOG }); + expect(r.errors.some((e) => e.code === "unresolved_binding" && /\/items/.test(e.message))).toBe(true); + }); + + it("does not flag relative template bindings (resolved per-item, lenient)", () => { + // `name`/`location`/... are relative paths inside the repeated card template. + const r = validateA2UIComponents({ components: validComponents(), data: VALID_DATA, catalog: CATALOG }); + expect(r.errors.some((e) => e.code === "unresolved_binding")).toBe(false); + }); + + it("defers binding checks when validateBindings is false (streaming component-close boundary)", () => { + // At the streaming boundary the components array has closed but the data + // model has not streamed yet — binding resolution would false-positive. + const r = validateA2UIComponents({ + components: validComponents(), + data: {}, + catalog: CATALOG, + validateBindings: false, + }); + expect(r.errors.some((e) => e.code === "unresolved_binding")).toBe(false); + expect(r.valid).toBe(true); + }); +}); diff --git a/sdks/typescript/packages/a2ui-toolkit/src/index.ts b/sdks/typescript/packages/a2ui-toolkit/src/index.ts new file mode 100644 index 0000000000..71db3ad6e0 --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/src/index.ts @@ -0,0 +1,864 @@ +/** + * @ag-ui/a2ui-toolkit + * + * Framework-agnostic building blocks for A2UI subagent tools. Each per- + * framework adapter (LangGraph, ADK, Mastra, etc.) composes these helpers + * with its framework-specific glue (tool decorator, runtime accessor, model + * binding/invoke). Nothing in this package depends on any agent framework. + */ + +import type { A2UIRecoveryConfig, A2UIAttemptRecord } from "./recovery"; +import type { A2UIValidationCatalog } from "./validate"; + +/** Container key the A2UI middleware looks for in tool results. */ +export const A2UI_OPERATIONS_KEY = "a2ui_operations"; + +/** Default catalog id used when the subagent does not specify one. */ +export const BASIC_CATALOG_ID = "https://a2ui.org/specification/v0_9/basic_catalog.json"; + +/** A single A2UI v0.9 server-to-client operation. */ +export type A2UIOperation = Record; + +// --------------------------------------------------------------------------- +// Op builders +// --------------------------------------------------------------------------- + +export function createSurface(surfaceId: string, catalogId: string): A2UIOperation { + return { + version: "v0.9", + createSurface: { surfaceId, catalogId }, + }; +} + +export function updateComponents( + surfaceId: string, + components: Array>, +): A2UIOperation { + return { + version: "v0.9", + updateComponents: { surfaceId, components }, + }; +} + +export function updateDataModel( + surfaceId: string, + data: unknown, + path: string = "/", +): A2UIOperation { + return { + version: "v0.9", + updateDataModel: { surfaceId, path, value: data }, + }; +} + +// --------------------------------------------------------------------------- +// Inner render_a2ui tool definition +// --------------------------------------------------------------------------- + +/** + * JSON schema for the inner ``render_a2ui`` tool. Framework adapters bind + * this on the subagent's model with ``tool_choice="render_a2ui"`` so the + * structured-output call produces ``{surfaceId, components, data}``. The + * catalog id is owned by the factory, not the subagent — the subagent can't + * invent a catalog the host hasn't registered. + */ +export const RENDER_A2UI_TOOL_DEF = { + type: "function" as const, + function: { + name: "render_a2ui", + description: + "Render a dynamic A2UI v0.9 surface. The root component must have id 'root'. " + + "Use components from the available catalog only.", + parameters: { + type: "object", + properties: { + surfaceId: { + type: "string", + description: "Unique surface identifier.", + }, + components: { + type: "array", + description: + "A2UI v0.9 component array (flat format). The root component must have id 'root'.", + items: { type: "object" }, + }, + data: { + type: "object", + description: + "Optional initial data model for the surface (form values, list items, etc.).", + }, + }, + required: ["surfaceId", "components"], + }, + }, +}; + +// --------------------------------------------------------------------------- +// State helpers +// --------------------------------------------------------------------------- + +/** + * Build the prompt prefix from AG-UI state context entries + the A2UI + * component catalog. Framework integrations conventionally extract the + * catalog into ``state["ag-ui"]["a2ui_schema"]`` and forward other context + * entries (generation guidelines, design guidelines) under + * ``state["ag-ui"]["context"]``. + */ +export function buildContextPrompt(state: Record): string { + const agUi = (state["ag-ui"] as Record | undefined) ?? {}; + const parts: string[] = []; + + const contextEntries = (agUi.context as Array> | undefined) ?? []; + for (const entry of contextEntries) { + const desc = entry?.description as string | undefined; + const value = entry?.value as string | undefined; + if (desc) { + parts.push(`## ${desc}\n${value ?? ""}\n`); + } else if (value) { + parts.push(`${value}\n`); + } + } + + const schema = agUi.a2ui_schema as string | undefined; + if (schema) { + parts.push(`## Available Components\n${schema}\n`); + } + + return parts.join("\n"); +} + +/** + * Context-entry description the ``@ag-ui/a2ui-middleware`` stamps onto the A2UI + * component schema it injects into ``RunAgentInput.context``. Single home for + * the constant so every framework adapter splits on the same string. MUST stay + * byte-identical to ``A2UI_SCHEMA_CONTEXT_DESCRIPTION`` in + * ``@ag-ui/a2ui-middleware`` (this is a wire contract, not prose). + */ +export const A2UI_SCHEMA_CONTEXT_DESCRIPTION = + "A2UI Component Schema — available components for generating UI surfaces. " + + "Use these component names and properties when creating A2UI operations."; + +/** + * Split AG-UI context entries into the A2UI component-schema entry and the + * rest. The schema entry is the one whose ``description`` exactly equals + * ``A2UI_SCHEMA_CONTEXT_DESCRIPTION``. Returns ``[schemaValue, regularContext]``: + * adapters route ``schemaValue`` to ``state["ag-ui"]["a2ui_schema"]`` (rendered + * as ``## Available Components`` by ``buildContextPrompt``) and ``regularContext`` + * to ``state["ag-ui"]["context"]``. Entries are returned unchanged. + */ +export function splitA2UISchemaContext( + context: Array> | undefined | null, +): [string | undefined, Array>] { + let schemaValue: string | undefined; + const regular: Array> = []; + for (const entry of context ?? []) { + const description = entry?.description as string | undefined; + if (description === A2UI_SCHEMA_CONTEXT_DESCRIPTION) { + schemaValue = entry?.value as string | undefined; + } else { + regular.push(entry); + } + } + return [schemaValue, regular]; +} + +/** + * Find the frontend-registered A2UI catalog in run ``state``, returning + * ``[componentSchema, catalogId]`` or ``undefined`` when no catalog is present. + * Framework-agnostic, so every adapter resolves the catalog the same way. + * Both delivery shapes live under the canonical ``state["ag-ui"]`` key: + * - Schema entry: ``state["ag-ui"]["a2ui_schema"]``, a JSON string + * ``{"catalogId": ..., "components": [...]}`` (toolkit reads the schema from + * state for the prompt itself, so only the id is surfaced here). + * - Catalog context entry: an ``state["ag-ui"]["context"]`` entry whose + * description mentions ``"A2UI catalog"``; the value lists catalogs as + * ``"- "`` lines, the first being the custom catalog. + */ +export function resolveA2UICatalog( + state: Record, +): [string | undefined, string | undefined] | undefined { + const agUi = (state["ag-ui"] as Record | undefined) ?? {}; + const a2uiSchema = agUi.a2ui_schema; + if (a2uiSchema) { + let catalogId: string | undefined; + try { + const parsed = + typeof a2uiSchema === "string" ? JSON.parse(a2uiSchema) : a2uiSchema; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + catalogId = (parsed as Record).catalogId as + | string + | undefined; + } + } catch { + // Unparseable schema -> no id (degrade to the configured default). + } + return [undefined, catalogId]; + } + + const contextEntries = + (agUi.context as Array> | undefined) ?? []; + for (const entry of contextEntries) { + const description = (entry?.description as string | undefined) ?? ""; + const value = (entry?.value as string | undefined) ?? ""; + if (!description.includes("A2UI catalog") || !value) continue; + const match = value.match(/^\s*-\s+(\S+)/m); + return [value, match ? match[1] : undefined]; + } + + return undefined; +} + +// --------------------------------------------------------------------------- +// Prior surface lookup (used for intent="update") +// --------------------------------------------------------------------------- + +export interface PriorSurface { + components: Array>; + data: unknown; + catalogId?: string; +} + +/** + * Locate the most recent rendered state for ``surfaceId`` in message history. + * + * Walks backwards looking for a tool result whose content is a JSON string + * containing ``a2ui_operations`` for the given surface. Returns the + * reconstructed ``{components, data, catalogId}``, or ``undefined`` if no + * matching surface is found. + */ +export function findPriorSurface( + messages: Array, + surfaceId: string, +): PriorSurface | undefined { + // Accumulate the surface's state across the walk, newest-to-oldest. For each + // field, the FIRST occurrence we see (newest) wins; older messages only fill + // in fields the more recent ones omitted. + // + // Per-message end-state is computed FORWARD because the renderer applies ops + // in document order. The last op affecting the surface in a message + // determines that message's contribution — including `deleteSurface`, which + // wipes the surface. If the NEWEST message to mention the surface ends in + // delete, the surface is gone and we must return undefined; older + // create/update ops are stale and would resurrect a surface the renderer no + // longer shows. + let components: Array> | undefined; + let data: unknown; + let dataSeen = false; + let catalogId: string | undefined; + let matched = false; + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!msg) continue; + const role = msg.type ?? msg.role; + if (role !== "tool" && role !== "ToolMessage") continue; + const content = msg.content; + if (typeof content !== "string") continue; + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object") continue; + const ops = (parsed as Record)[A2UI_OPERATIONS_KEY]; + if (!Array.isArray(ops)) continue; + + // Compute this message's END STATE for surfaceId by walking ops forward. + // `deleteSurface` resets the per-message accumulator; subsequent create / + // update ops in the same message restore it. + let msgMentions = false; + let msgDeleted = false; + let msgCatalogId: string | undefined; + let msgComponents: Array> | undefined; + let msgData: unknown; + let msgDataSeen = false; + + for (const op of ops) { + if (!op || typeof op !== "object") continue; + const opObj = op as Record; + + const ds = opObj.deleteSurface as Record | undefined; + if (ds && ds.surfaceId === surfaceId) { + msgMentions = true; + msgDeleted = true; + msgCatalogId = undefined; + msgComponents = undefined; + msgData = undefined; + msgDataSeen = false; + continue; + } + + const cs = opObj.createSurface as Record | undefined; + if (cs && cs.surfaceId === surfaceId) { + msgMentions = true; + msgDeleted = false; + if (typeof cs.catalogId === "string") { + msgCatalogId = cs.catalogId; + } + } + const uc = opObj.updateComponents as Record | undefined; + if (uc && uc.surfaceId === surfaceId) { + msgMentions = true; + msgDeleted = false; + if (Array.isArray(uc.components)) { + msgComponents = uc.components as Array>; + } + } + const ud = opObj.updateDataModel as Record | undefined; + if (ud && ud.surfaceId === surfaceId) { + msgMentions = true; + msgDeleted = false; + msgData = ud.value; + msgDataSeen = true; + } + } + + if (!msgMentions) continue; + + if (!matched) { + // First (newest) message to mention the surface — its end state is the + // authoritative current state. + if (msgDeleted) return undefined; + matched = true; + catalogId = msgCatalogId; + components = msgComponents; + data = msgData; + dataSeen = msgDataSeen; + } else { + // Older message: only fill in fields not yet set. A delete here is + // overridden by the newer creation we already recorded. + if (msgDeleted) continue; + if (catalogId === undefined && msgCatalogId !== undefined) catalogId = msgCatalogId; + if (components === undefined && msgComponents !== undefined) components = msgComponents; + if (!dataSeen && msgDataSeen) { + data = msgData; + dataSeen = true; + } + } + + // Early-exit once every field has been populated — nothing older can + // override what we already have. + if (matched && components !== undefined && catalogId !== undefined && dataSeen) { + return { components, data, catalogId }; + } + } + + if (!matched) return undefined; + return { components: components ?? [], data, catalogId }; +} + +// --------------------------------------------------------------------------- +// Prompt assembly +// --------------------------------------------------------------------------- + +export interface EditContext { + surfaceId: string; + prior: PriorSurface; + changes?: string; +} + +// --------------------------------------------------------------------------- +// Subagent prompt guidelines (OSS-248) +// +// Re-enables the rich generation + design guidance the legacy +// `copilotkit.a2ui.a2ui_prompt` shipped. The two DEFAULT_* blocks are applied +// automatically (per-field) so subagent output is well-designed out of the box; +// a host overrides either block via `A2UIGuidelines`. Pass an empty string to +// suppress a block entirely. +// --------------------------------------------------------------------------- + +/** + * Default generation guidance (tool-call contract, id/path/data-binding rules). + * Applied when `A2UIGuidelines.generationGuidelines` is unset (`undefined`). + * Ported verbatim from the legacy `copilotkit.a2ui` defaults (OSS-248). + */ +export const DEFAULT_GENERATION_GUIDELINES = `\ +Generate A2UI v0.9 JSON. + +## A2UI Protocol Instructions + +A2UI (Agent to UI) is a protocol for rendering rich UI surfaces from agent responses. + +CRITICAL: You MUST call the render_a2ui tool with ALL of these arguments: +- surfaceId: A unique ID for the surface (e.g. "product-comparison") +- components: REQUIRED — the A2UI component array. NEVER omit this. Use a List with + children: { componentId: "card-id", path: "/items" } for repeating cards. +- data: OPTIONAL — a JSON object written to the root of the surface data model. + Use for pre-filling form values or providing data for path-bound components. +- every component must have the "component" field specifying the component type (e.g. "Text", "Image", "Row", "Column", "List", "Button", etc.) + +COMPONENT ID RULES: +- Every component ID must be unique within the surface. +- A component MUST NOT reference itself as child/children. This causes a + circular dependency error. For example, if a component has id="avatar", + its child must be a DIFFERENT id (e.g. "avatar-img"), never "avatar". +- The child/children tree must be a DAG — no cycles allowed. + +PATH RULES FOR TEMPLATES: +Components inside a repeating List use RELATIVE paths (no leading slash). +The path is resolved relative to each array item automatically. +If List has children: { componentId: "card", path: "/items" } and item has key "name", +use { "path": "name" } (NO leading slash — relative to item). +CRITICAL: Do NOT use "/name" (absolute) inside templates — use "name" (relative). +The List's own path ("/items") uses a leading slash (absolute), but all +components INSIDE the template card use paths WITHOUT leading slash. +Do NOT use "/items/0/name" or "/items/{@key}/name" — just "name". + +DATA MODEL: +The "data" key in the tool args is a plain JSON object that initializes the surface +data model. Components bound to paths (e.g. "value": { "path": "/form/name" }) +read from and write to this data model. Examples: + For forms: "data": { "form": { "name": "Alice", "email": "" } } + For lists: "data": { "items": [{"name": "Product A"}, {"name": "Product B"}] } + For mixed: "data": { "form": { "query": "" }, "results": [...] } + +FORMS AND TWO-WAY DATA BINDING: +To create editable forms, bind input components to data model paths using { "path": "..." }. +The client automatically writes user input back to the data model at the bound path. +CRITICAL: Using a literal value (e.g. "value": "") makes the field READ-ONLY. +You MUST use { "path": "..." } to make inputs editable. + +All input components use "value" as the binding property: +- TextField: "value": { "path": "/form/fieldName" } +- CheckBox: "value": { "path": "/form/isChecked" } +- Slider: "value": { "path": "/form/sliderVal" } +- DateTimeInput: "value": { "path": "/form/date" } +- ChoicePicker: "value": { "path": "/form/choices" } + +To retrieve form values when a button is clicked, include "context" with path references +in the button's action. Paths are resolved to their current values at click time: + "action": { "event": { "name": "submit", "context": { "userName": { "path": "/form/name" } } } } + +To pre-fill form values, pass initial data via the "data" tool argument: + "data": { "form": { "name": "Markus" } } + +FORM EXAMPLE (editable text field with pre-filled value + submit button): + "components": [ + { "id": "root", "component": "Card", "child": "form-col" }, + { "id": "form-col", "component": "Column", "children": ["name-field", "submit-row"] }, + { "id": "name-field", "component": "TextField", "label": "Name", "value": { "path": "/form/name" } }, + { "id": "submit-row", "component": "Row", "justify": "end", "children": ["submit-btn"] }, + { "id": "submit-btn", "component": "Button", "child": "btn-text", "variant": "primary", + "action": { "event": { "name": "submit", "context": { "userName": { "path": "/form/name" } } } } }, + { "id": "btn-text", "component": "Text", "text": "Submit" } + ], + "data": { "form": { "name": "Markus" } }`; + +/** + * Default design guidance (visual hierarchy, layout, imagery, action format). + * Applied when `A2UIGuidelines.designGuidelines` is unset (`undefined`). + * Ported verbatim from the legacy `copilotkit.a2ui` defaults (OSS-248). + */ +export const DEFAULT_DESIGN_GUIDELINES = `\ +Create polished, visually appealing interfaces: +- Always include a title heading (h2) for the surface, outside the List. + Wrap in a Column: [title, list] as root. +- For card templates, create clear visual hierarchy: + - h3 for primary text (names, titles) + - h2 for featured numbers (prices, scores) — makes them stand out + - caption for secondary info (ratings, categories, metadata) + - body for descriptions +- Use Divider between logical sections within cards. +- Use Row with justify="spaceBetween" for label-value pairs + (e.g. "Rating" on left, "4.5/5" on right). +- Include images when relevant (logos, icons, product photos): + - Use Image component with variant="smallFeature" or "avatar" + - Prefer company logos for branded products — Google favicons are reliable: + https://www.google.com/s2/favicons?domain=sony.com&sz=128 + https://www.google.com/s2/favicons?domain=bose.com&sz=128 + - For generic icons: https://placehold.co/128x128/EEE/999?text=🎧 + - Do NOT invent Unsplash photo-IDs — they will 404. Only use real, known URLs. +- Use horizontal List direction for side-by-side comparison cards. +- Keep cards clean — avoid clutter. Whitespace is good. +- Use consistent surfaceIds (lowercase, hyphenated). +- NEVER use the same ID for a component and its child — this creates a + circular dependency. E.g. if id="avatar", child must NOT be "avatar". +- Both Row and Column support "justify" and "align". +- Add Button for interactivity. Button needs child (Text ID) + action. + Action MUST use this exact nested format: + "action": { "event": { "name": "myAction", "context": { "key": "value" } } } + The "event" key holds an OBJECT with "name" (required) and "context" (optional). + Do NOT use a flat format like {"event": "name"} — "event" must be an object. + Use variant="primary" for main action buttons, variant="borderless" for links. +- For forms: wrap fields in a Card with a Column. Place the submit button in a + Row with justify="end". Every input MUST use path binding on the "value" property + (e.g. "value": { "path": "/form/name" }) to be editable. The submit button's action + context MUST reference the same paths to capture the user's input. + +Use the SAME surfaceId as the main surface. Match action names to Button action event names.`; + +/** + * Prompt knobs threaded from the host through the adapter into the subagent + * prompt. The toolkit owns this shape so a new knob is added here (and rendered + * in `buildSubagentPrompt`) without editing any framework adapter — each adapter + * forwards this bag verbatim. + * + * Per-field semantics (mirrors the legacy `a2ui_prompt` defaults): + * - key absent / `undefined` → the built-in `DEFAULT_*` block is used. + * - `""` (empty string) → that block is suppressed (no section emitted). + * - any other string → replaces the default for that block. + * + * `compositionGuide` has no default; it is appended only when provided. + */ +export interface A2UIGuidelines { + generationGuidelines?: string; + designGuidelines?: string; + compositionGuide?: string; +} + +export interface BuildSubagentPromptInput { + /** Output of ``buildContextPrompt(state)``. */ + contextPrompt: string; + /** Generation/design/composition prompt knobs (per-field defaults applied). */ + guidelines?: A2UIGuidelines; + /** When set, instructs the subagent to edit a prior surface in place. */ + editContext?: EditContext; +} + +/** + * Compose the full system prompt the subagent sees. + * + * Section order: generation guidelines → design guidelines → context + catalog + * (from ``contextPrompt``) → composition guide → edit-existing-surface block. + * Faithful to the legacy ``a2ui_prompt`` ordering (generation lead, design + * header, then available components). + * + * Generation and design fall back per-field to ``DEFAULT_GENERATION_GUIDELINES`` + * / ``DEFAULT_DESIGN_GUIDELINES`` when unset (``undefined``); an empty string + * suppresses the block. + */ +export function buildSubagentPrompt(input: BuildSubagentPromptInput): string { + // Per-field fallback: `undefined` → built-in default; `""` → host explicitly + // suppressed the block (`??` treats only null/undefined as missing, so an + // empty string is preserved as the escape hatch). + const generation = input.guidelines?.generationGuidelines ?? DEFAULT_GENERATION_GUIDELINES; + const design = input.guidelines?.designGuidelines ?? DEFAULT_DESIGN_GUIDELINES; + const compositionGuide = input.guidelines?.compositionGuide; + + const parts: string[] = []; + if (generation) parts.push(generation); + if (design) parts.push(`## Design Guidelines\n${design}`); + if (input.contextPrompt) parts.push(input.contextPrompt); + if (compositionGuide) parts.push(compositionGuide); + + if (input.editContext) { + const { surfaceId, prior, changes } = input.editContext; + let editBlock = + `## Editing an existing surface\n` + + `You are editing surface '${surfaceId}'. Produce the FULL ` + + `updated components array and data model — not just a diff. Preserve ` + + `component ids that the user has not asked to change so the renderer ` + + `can reconcile them. Reuse the same catalogId.\n\n` + + `### Previous components\n${JSON.stringify(prior.components, null, 2)}\n\n` + + `### Previous data\n${JSON.stringify(prior.data, null, 2)}\n`; + if (changes) { + editBlock += `\n### Requested changes\n${changes}\n`; + } + parts.push(editBlock); + } + + return parts.filter((p) => p && p.length > 0).join("\n"); +} + +// --------------------------------------------------------------------------- +// Operations envelope +// --------------------------------------------------------------------------- + +export interface AssembleOpsInput { + /** ``"create"`` to render a new surface, ``"update"`` to modify a prior one. */ + intent: "create" | "update"; + surfaceId: string; + catalogId: string; + components: Array>; + data?: Record; +} + +/** + * Produce the final A2UI v0.9 operation list for a render result. + * + * ``create`` emits ``[createSurface, updateComponents, updateDataModel?]``. + * ``update`` skips ``createSurface`` so the frontend reconciles the existing + * surface in place instead of erroring (per v0.9 spec, ``createSurface`` on + * an existing id is invalid). + */ +export function assembleOps(input: AssembleOpsInput): A2UIOperation[] { + const ops: A2UIOperation[] = []; + if (input.intent !== "update") { + ops.push(createSurface(input.surfaceId, input.catalogId)); + } + ops.push(updateComponents(input.surfaceId, input.components)); + if (input.data && Object.keys(input.data).length > 0) { + ops.push(updateDataModel(input.surfaceId, input.data)); + } + return ops; +} + +/** + * Wrap a list of A2UI operations as the JSON envelope the A2UI middleware + * looks for in tool results. + */ +export function wrapAsOperationsEnvelope(ops: A2UIOperation[]): string { + return JSON.stringify({ [A2UI_OPERATIONS_KEY]: ops }); +} + +/** + * Wrap an error as the JSON string a subagent tool returns when it can't + * produce a surface. Keeps the error shape consistent across frameworks. + */ +export function wrapErrorEnvelope(message: string): string { + return JSON.stringify({ error: message }); +} + +// --------------------------------------------------------------------------- +// Subagent-tool defaults (shared so every framework adapter advertises the +// same planner-facing surface and behaviour) +// --------------------------------------------------------------------------- + +/** Surface id used when the subagent omits ``surfaceId`` on a create. */ +export const DEFAULT_SURFACE_ID = "dynamic-surface"; + +/** Default name the outer A2UI tool is advertised under to the main planner. */ +export const GENERATE_A2UI_TOOL_NAME = "generate_a2ui"; + +/** Default description shown to the main agent's planner. */ +export const GENERATE_A2UI_TOOL_DESCRIPTION = + "Generate or update a dynamic A2UI surface based on the conversation. " + + "A secondary LLM designs the UI components and data. " + + "Use intent='create' (default) when the user requests new visual content " + + "(cards, forms, lists, dashboards, comparisons, etc.). " + + "Use intent='update' with target_surface_id to modify a surface you " + + "previously rendered (e.g. 'change the second card's price', " + + "'add a Buy button', 'use red instead of blue')."; + +/** Planner-facing descriptions for the outer tool's three arguments. */ +export const GENERATE_A2UI_ARG_DESCRIPTIONS = { + intent: + "'create' to render a new surface; 'update' to modify a surface previously rendered in this conversation. Defaults to 'create'.", + target_surface_id: "Required when intent='update'. The surface id of the prior render to modify.", + changes: "Optional natural-language description of the changes to apply when intent='update'.", +} as const; + +// --------------------------------------------------------------------------- +// Shared A2UI tool-factory params (OSS-248) +// +// One params shape, owned by the toolkit, consumed identically by every +// framework adapter. A framework's factory is always +// `getA2UITools(params: A2UIToolParams)` — only the body (tool +// decorator, runtime/state accessor, model bind+invoke) differs per framework. +// +// `model` is the single framework-specific field, so the type is generic over +// it. Adding a new knob = add a field here (+ apply its default in +// `resolveA2UIToolParams`) — NO adapter signature ever changes, and a brand-new +// framework adapter gets the knob for free on day one. +// --------------------------------------------------------------------------- + +export interface A2UIToolParams { + /** Chat model the subagent invokes for structured A2UI output. The one + * framework-specific field — typed per framework via the generic. */ + model: TModel; + /** Generation/design/composition prompt knobs (per-field defaults applied). */ + guidelines?: A2UIGuidelines; + /** Surface id used when the subagent omits `surfaceId`. */ + defaultSurfaceId?: string; + /** Catalog id assigned to every new surface this factory creates — the + * subagent never picks the catalog. Falls back to the basic v0.9 catalog. */ + defaultCatalogId?: string; + /** Name advertised to the main agent's planner. */ + toolName?: string; + /** Description shown to the main agent's planner. */ + toolDescription?: string; + /** Inline catalog enabling catalog-aware recovery. Pass the SAME catalog the + * host gives the middleware so retry decision + paint gate agree. */ + catalog?: A2UIValidationCatalog; + /** Recovery loop config: attempt cap, retry-UI threshold, debug exposure. */ + recovery?: A2UIRecoveryConfig; + /** Per-attempt hook for recovery status / dev logs (non-disruptive). */ + onA2UIAttempt?: (record: A2UIAttemptRecord) => void; +} + +/** `A2UIToolParams` with every optional field resolved to its effective value. + * Returned by `resolveA2UIToolParams` so adapters never re-implement defaults. */ +export interface ResolvedA2UIToolParams { + model: TModel; + guidelines?: A2UIGuidelines; + defaultSurfaceId: string; + defaultCatalogId: string; + toolName: string; + toolDescription: string; + catalog?: A2UIValidationCatalog; + recovery?: A2UIRecoveryConfig; + onA2UIAttempt?: (record: A2UIAttemptRecord) => void; +} + +/** + * Normalize an `A2UIToolParams` into a `ResolvedA2UIToolParams`, filling the + * canonical defaults so each framework adapter stops re-implementing + * `toolName || DEFAULT` / `catalogId || BASIC` lines. + * + * Uses `||` (not `??`) so an accidental empty-string override from a caller + * falls back to the canonical default rather than advertising a nameless / + * empty-description tool or emitting a blank surface/catalog id. + */ +export function resolveA2UIToolParams( + params: A2UIToolParams, +): ResolvedA2UIToolParams { + return { + model: params.model, + guidelines: params.guidelines, + defaultSurfaceId: params.defaultSurfaceId || DEFAULT_SURFACE_ID, + defaultCatalogId: params.defaultCatalogId || BASIC_CATALOG_ID, + toolName: params.toolName || GENERATE_A2UI_TOOL_NAME, + toolDescription: params.toolDescription || GENERATE_A2UI_TOOL_DESCRIPTION, + catalog: params.catalog, + recovery: params.recovery, + onA2UIAttempt: params.onA2UIAttempt, + }; +} + +// --------------------------------------------------------------------------- +// High-level orchestration +// +// These two functions hold the entire create/update decision + prompt prep + +// result-assembly logic so every framework adapter is reduced to pure glue +// (tool decorator, state access, model bind+invoke, tool-call read). +// --------------------------------------------------------------------------- + +export interface PrepareA2UIRequestInput { + /** Raw ``intent`` arg from the planner (defaults to ``"create"``). */ + intent?: string; + /** Raw ``target_surface_id`` arg from the planner. */ + targetSurfaceId?: string; + /** Raw ``changes`` arg from the planner. */ + changes?: string; + /** Conversation history with the current (unbalanced) tool call stripped. */ + messages: Array; + /** The agent's run state (read for context + catalog via buildContextPrompt). */ + state: Record; + /** + * Generation/design/composition prompt knobs, forwarded verbatim to + * ``buildSubagentPrompt``. The toolkit owns the shape so adapters never need + * editing when a knob is added. + */ + guidelines?: A2UIGuidelines; +} + +export interface PreparedA2UIRequest { + /** System prompt to feed the subagent. Empty string when ``error`` is set. */ + prompt: string; + /** Whether this is an in-place edit of a prior surface. */ + isUpdate: boolean; + /** The reconstructed prior surface, when editing. */ + prior?: PriorSurface; + /** Set when the request is invalid (e.g. update with no matching surface). */ + error?: string; +} + +/** + * Resolve the create/update decision, locate any prior surface, and build the + * subagent system prompt. Returns ``error`` instead of a prompt when the + * request is invalid (update referencing a surface not in history). + */ +export function prepareA2UIRequest(input: PrepareA2UIRequestInput): PreparedA2UIRequest { + const intent = input.intent ?? "create"; + const isUpdate = intent === "update" && Boolean(input.targetSurfaceId); + + const prior = isUpdate ? findPriorSurface(input.messages, input.targetSurfaceId!) : undefined; + + if (isUpdate && !prior) { + return { + prompt: "", + isUpdate, + error: + `intent='update' requested target_surface_id='${input.targetSurfaceId}' ` + + `but no prior render of that surface was found in conversation history`, + }; + } + + const prompt = buildSubagentPrompt({ + contextPrompt: buildContextPrompt(input.state), + guidelines: input.guidelines, + editContext: prior + ? { surfaceId: input.targetSurfaceId!, prior, changes: input.changes } + : undefined, + }); + + return { prompt, isUpdate, prior }; +} + +export interface BuildA2UIEnvelopeInput { + /** The subagent's ``render_a2ui`` structured-output args. */ + args: Record; + /** From ``prepareA2UIRequest``. */ + isUpdate: boolean; + /** The planner's ``target_surface_id`` (used as the surface id on update). */ + targetSurfaceId?: string; + /** The prior surface from ``prepareA2UIRequest`` (supplies the catalog id on update). */ + prior?: PriorSurface; + /** Surface id used when the subagent omits one on create. */ + defaultSurfaceId?: string; + /** Catalog id used when there's no prior surface to inherit one from. */ + defaultCatalogId?: string; +} + +/** + * Turn the subagent's structured output into the final operations envelope. + * + * Catalog ownership stays with the host: the subagent never picks a catalog, + * so the id comes from the prior surface (update) or the configured default + * (create) — never from the model's args. + */ +export function buildA2UIEnvelope(input: BuildA2UIEnvelopeInput): string { + // Treat empty-string defaults as unset. `??` alone would propagate "" into + // the emitted createSurface / updateComponents ops and surface as + // "Catalog not found: " / a blank surface id at render time — hiding the + // real cause (host misconfiguration). The middleware streaming path uses + // the same guard for symmetry. + const safeDefaultSurfaceId = + input.defaultSurfaceId && input.defaultSurfaceId.length > 0 + ? input.defaultSurfaceId + : DEFAULT_SURFACE_ID; + const safeDefaultCatalogId = + input.defaultCatalogId && input.defaultCatalogId.length > 0 + ? input.defaultCatalogId + : BASIC_CATALOG_ID; + + // Narrow ``args.surfaceId`` to a non-empty string before using it — the + // model's output is untrusted and could send a number / object / null. + const argSurfaceId = + typeof input.args.surfaceId === "string" && input.args.surfaceId.length > 0 + ? input.args.surfaceId + : ""; + const surfaceId = input.isUpdate + ? input.targetSurfaceId || safeDefaultSurfaceId + : argSurfaceId || safeDefaultSurfaceId; + + const catalogId = input.prior?.catalogId || safeDefaultCatalogId; + + const rawComponents = input.args.components; + const components: Array> = Array.isArray(rawComponents) + ? (rawComponents as Array>) + : []; + const rawData = input.args.data; + const data: Record = + rawData && typeof rawData === "object" && !Array.isArray(rawData) + ? (rawData as Record) + : {}; + + const ops = assembleOps({ + intent: input.isUpdate ? "update" : "create", + surfaceId, + catalogId, + components, + data, + }); + + return wrapAsOperationsEnvelope(ops); +} + +// --------------------------------------------------------------------------- +// Error-recovery loop (OSS-162) — semantic validation + validate→retry loop, +// shared so the middleware (paint gate) and adapters (retry driver) agree. +// --------------------------------------------------------------------------- +export * from "./validate"; +export * from "./recovery"; diff --git a/sdks/typescript/packages/a2ui-toolkit/src/recovery.ts b/sdks/typescript/packages/a2ui-toolkit/src/recovery.ts new file mode 100644 index 0000000000..4ad1af127a --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/src/recovery.ts @@ -0,0 +1,144 @@ +/** + * A2UI error-recovery loop (OSS-162). + * + * Framework-agnostic: the toolkit cannot bind/invoke a model, so the adapter + * supplies an `invokeSubagent` closure (its framework-specific model call) and a + * `buildEnvelope` closure (its prepared create/update context). This module owns + * the loop: invoke → validate (shared `validateA2UIComponents`) → on failure feed + * the structured errors back into the prompt and retry, up to `maxAttempts`. + * + * The SAME validator gates the middleware's paint decision, so the tool's retry + * decision and the middleware's suppress decision can never disagree. + */ +import { + validateA2UIComponents, + type A2UIValidationCatalog, + type A2UIValidationError, +} from "./validate"; + +/** Default attempt cap (initial try + retries). Configurable per call. */ +export const MAX_A2UI_ATTEMPTS = 3; + +/** Activity type the middleware/client use for the recovery status channel. */ +export const A2UI_RECOVERY_ACTIVITY_TYPE = "a2ui_recovery"; + +/** + * Developer-configurable recovery surface (Tyler's requirement). The threshold + * is behavioral, not a hardcoded number: `showRetryUIAfter` lets the host decide + * when the "Retrying…" status becomes perceptible enough to show. + */ +export interface A2UIRecoveryConfig { + /** Attempt cap (initial + retries). Default `MAX_A2UI_ATTEMPTS`. */ + maxAttempts?: number; + /** When the (client-side) "Retrying UI generation…" status may appear. */ + showRetryUIAfter?: { ms?: number; attempts?: number }; + // NOTE: debugExposure is NOT here — how much retry/error detail the renderer + // surfaces is a presentation concern configured server-side via the + // A2UIMiddleware's `recovery.debugExposure` (stamped into the a2ui_recovery + // activity), not on this generation-loop config. (OSS-162) +} + +/** One attempt's outcome — surfaced to the adapter via `onAttempt` for status + dev traces. */ +export interface A2UIAttemptRecord { + /** 1-based attempt number. */ + attempt: number; + ok: boolean; + errors: A2UIValidationError[]; +} + +export interface RunA2UIRecoveryInput { + /** The prepared sub-agent system prompt (output of `prepareA2UIRequest`). */ + basePrompt: string; + /** Inline catalog for semantic validation; omit for structural-only. */ + catalog?: A2UIValidationCatalog; + config?: A2UIRecoveryConfig; + /** + * Run the sub-agent once with `prompt` (already augmented with prior errors on + * retries) and return its `render_a2ui` args `{surfaceId, components, data}`, + * or `null` if the model produced no tool call. + */ + invokeSubagent: (prompt: string, attempt: number) => Promise | null>; + /** Turn validated `render_a2ui` args into the final operations envelope. */ + buildEnvelope: (args: Record) => string; + /** Per-attempt callback for emitting recovery status + dev logs. */ + onAttempt?: (record: A2UIAttemptRecord) => void; +} + +export interface RunA2UIRecoveryResult { + /** Either the validated operations envelope, or a structured hard-failure envelope. */ + envelope: string; + attempts: A2UIAttemptRecord[]; + ok: boolean; +} + +/** Render structured errors as a compact, model-readable list. */ +export function formatValidationErrors(errors: A2UIValidationError[]): string { + return errors.map((e) => `- [${e.code}] ${e.path}: ${e.message}`).join("\n"); +} + +/** Append a fix-it block describing the prior attempt's errors. No-op when there are none. */ +export function augmentPromptWithValidationErrors(prompt: string, errors: A2UIValidationError[]): string { + if (!errors.length) return prompt; + return ( + `${prompt}\n\n## Previous attempt was invalid — fix these and regenerate:\n` + + `${formatValidationErrors(errors)}\n` + ); +} + +const NO_TOOL_CALL_ERROR: A2UIValidationError = { + code: "empty_components", + path: "components", + message: "Sub-agent did not call render_a2ui", +}; + +/** Wrap an exhausted-recovery hard failure as the JSON envelope the middleware recognises. */ +function wrapRecoveryExhaustedEnvelope(maxAttempts: number, attempts: A2UIAttemptRecord[]): string { + return JSON.stringify({ + error: `Failed to generate valid A2UI after ${maxAttempts} attempt(s)`, + code: "a2ui_recovery_exhausted", + attempts, + }); +} + +/** + * Drive the validate→retry loop. Returns the validated envelope on success, or a + * structured `a2ui_recovery_exhausted` envelope once the cap is hit. Never retries + * an attempt whose components validated (the adapter must commit it). + */ +export async function runA2UIGenerationWithRecovery( + input: RunA2UIRecoveryInput, +): Promise { + const maxAttempts = input.config?.maxAttempts ?? MAX_A2UI_ATTEMPTS; + const attempts: A2UIAttemptRecord[] = []; + let lastErrors: A2UIValidationError[] = []; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const prompt = augmentPromptWithValidationErrors(input.basePrompt, lastErrors); + const args = await input.invokeSubagent(prompt, attempt); + + if (!args) { + const record: A2UIAttemptRecord = { attempt, ok: false, errors: [NO_TOOL_CALL_ERROR] }; + attempts.push(record); + input.onAttempt?.(record); + lastErrors = record.errors; + continue; + } + + const components = Array.isArray(args.components) ? (args.components as Array>) : []; + const data = + args.data && typeof args.data === "object" && !Array.isArray(args.data) + ? (args.data as Record) + : {}; + const result = validateA2UIComponents({ components, data, catalog: input.catalog }); + const record: A2UIAttemptRecord = { attempt, ok: result.valid, errors: result.errors }; + attempts.push(record); + input.onAttempt?.(record); + + if (result.valid) { + return { envelope: input.buildEnvelope(args), attempts, ok: true }; + } + lastErrors = result.errors; + } + + return { envelope: wrapRecoveryExhaustedEnvelope(maxAttempts, attempts), attempts, ok: false }; +} diff --git a/sdks/typescript/packages/a2ui-toolkit/src/validate.ts b/sdks/typescript/packages/a2ui-toolkit/src/validate.ts new file mode 100644 index 0000000000..84a2b0e67b --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/src/validate.ts @@ -0,0 +1,389 @@ +/** + * Semantic validation of A2UI v0.9 component trees (OSS-162). + * + * The middleware's streaming path only checks *structural* completeness (array + * closed, each item has a `component` string). This module adds the *semantic* + * checks whose failures otherwise blow up at render time in `@a2ui/web_core` + * ("Component not found", "Catalog not found", unresolved bindings) — turning + * them into machine-readable errors the recovery loop can feed back to the + * sub-agent. + * + * Used by BOTH the adapter (to decide whether to retry) and the middleware (to + * decide whether to paint) so the two never disagree on what "valid" means. + */ + +/** A single, machine-readable validation failure. */ +export interface A2UIValidationError { + code: + | "empty_components" + | "missing_id" + | "missing_component_type" + | "duplicate_id" + | "no_root" + | "unknown_component" + | "missing_required_prop" + | "unresolved_child" + | "child_cycle" + | "unresolved_binding"; + /** A JSON-pointer-ish locator, e.g. `components[2].component`. */ + path: string; + /** Human/LLM-readable description (fed back to the sub-agent on retry). */ + message: string; +} + +export interface ValidateA2UIResult { + valid: boolean; + errors: A2UIValidationError[]; +} + +/** + * Inline JSON-Schema catalog (mirrors the middleware's `A2UIInlineCatalogSchema`): + * component name → JSON Schema whose `required` lists mandatory props. + */ +export interface A2UIValidationCatalog { + components: Record; [k: string]: unknown }>; +} + +export interface ValidateA2UIInput { + components: Array>; + /** The surface's data model; used to resolve absolute binding paths. */ + data?: Record; + /** When omitted, catalog-dependent checks (membership, required props) are skipped. */ + catalog?: A2UIValidationCatalog; + /** + * Resolve absolute binding paths against `data`. Default `true`. Set `false` + * at the streaming component-close boundary, where the component tree has + * closed but the data model has not streamed yet — resolving bindings there + * would false-positive (and trigger spurious retries). The adapter re-runs + * full validation (bindings included) once the complete args arrive. + */ + validateBindings?: boolean; +} + +/** Does `path` (absolute, e.g. `/items/0/name`) resolve in `data`? */ +function absolutePathResolves(path: string, data: unknown): boolean { + const segments = path.split("/").filter((s) => s.length > 0); + let cursor: unknown = data; + for (const seg of segments) { + if (cursor == null || typeof cursor !== "object") return false; + if (Array.isArray(cursor)) { + const idx = Number(seg); + if (!Number.isInteger(idx) || idx < 0 || idx >= cursor.length) return false; + cursor = cursor[idx]; + } else { + if (!(seg in (cursor as Record))) return false; + cursor = (cursor as Record)[seg]; + } + } + return true; +} + +/** True for a plain (non-array) object. */ +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** + * Validate a flat A2UI v0.9 component array. + * + * Structural checks always run. Catalog membership + required-prop checks run + * only when `catalog` is supplied. Absolute binding paths (`/foo`) are resolved + * against `data`; relative template paths (`name`) are left alone — they resolve + * per-item inside a repeated template and flagging them would produce false + * positives (and spurious retries). + */ +export function validateA2UIComponents(input: ValidateA2UIInput): ValidateA2UIResult { + const { components, data, catalog } = input; + const validateBindings = input.validateBindings ?? true; + const errors: A2UIValidationError[] = []; + + // Fail loud on a non-array / empty payload — there is nothing to render and + // nothing meaningful to feed back, so the caller must not treat it as a + // recoverable surface silently. + if (!Array.isArray(components) || components.length === 0) { + return { + valid: false, + errors: [{ code: "empty_components", path: "components", message: "A2UI components must be a non-empty array" }], + }; + } + + const ids = new Set(); + const seen = new Set(); + for (const comp of components) { + const id = isObject(comp) ? comp.id : undefined; + if (typeof id === "string") { + if (seen.has(id)) { + errors.push({ code: "duplicate_id", path: `components[id=${id}]`, message: `Duplicate component id '${id}'` }); + } + seen.add(id); + ids.add(id); + } + } + + components.forEach((comp, i) => { + const id = isObject(comp) ? comp.id : undefined; + const type = isObject(comp) ? comp.component : undefined; + + if (typeof id !== "string" || id.length === 0) { + errors.push({ code: "missing_id", path: `components[${i}].id`, message: `Component at index ${i} is missing a string 'id'` }); + } + if (typeof type !== "string" || type.length === 0) { + errors.push({ + code: "missing_component_type", + path: `components[${i}].component`, + message: `Component at index ${i} is missing a string 'component' type`, + }); + } + + // Catalog membership + required props (only when a catalog is supplied). + if (catalog && typeof type === "string") { + const schema = catalog.components[type]; + if (!schema) { + errors.push({ + code: "unknown_component", + path: `components[${i}].component`, + message: `Component type '${type}' is not in the catalog`, + }); + } else { + for (const req of schema.required ?? []) { + if (!isObject(comp) || !(req in comp)) { + errors.push({ + code: "missing_required_prop", + path: `components[${i}].${req}`, + message: `Component '${type}' (index ${i}) is missing required prop '${req}'`, + }); + } + } + } + } + + // Child references must resolve to existing component ids. The implicit + // `child`/`children` fields are always checked; catalog-marked ref-fields + // (Modal `trigger`/`content`, Tabs `tabItems[].child`, …) are checked too + // when a catalog is supplied. A dangling reference in any of them is fed + // back to the recovery loop. See `collectComponentRefEdges`. + if (isObject(comp)) { + const schema = catalog && typeof type === "string" ? catalog.components[type] : undefined; + collectComponentRefEdges(comp, schema).forEach(({ path: refPath, ref }) => { + if (!ids.has(ref)) { + errors.push({ + code: "unresolved_child", + path: `components[${i}].${refPath}`, + message: `Child reference '${ref}' does not match any component id`, + }); + } + }); + + // Absolute binding paths must resolve against the data model (unless + // deferred — see `validateBindings`). + if (validateBindings) collectAbsoluteBindingPaths(comp).forEach((p) => { + if (!absolutePathResolves(p, data ?? {})) { + errors.push({ + code: "unresolved_binding", + path: `components[${i}]`, + message: `Binding path '${p}' does not resolve in the data model`, + }); + } + }); + } + }); + + // The child reference tree must be a DAG — a component that (transitively) + // references itself never terminates at render time. Report each cycle once. + findChildCycles(components, catalog).forEach((cycle) => { + errors.push({ + code: "child_cycle", + path: `components[id=${cycle[0]}]`, + message: `Child reference cycle detected: ${[...cycle, cycle[0]].join(" -> ")}`, + }); + }); + + if (!components.some((c) => isObject(c) && c.id === "root")) { + errors.push({ code: "no_root", path: "components", message: "No component has id 'root'" }); + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Pull child-id references out of a `child`/`children` value: an array of ids or + * `{componentId,...}` templates, a single `{componentId,...}` template, or a bare + * string id (the singular `child` shape Card/Button use). + */ +function collectChildRefs(children: unknown): string[] { + const refs: string[] = []; + const push = (v: unknown) => { + if (typeof v === "string") refs.push(v); + else if (isObject(v) && typeof v.componentId === "string") refs.push(v.componentId); + }; + if (Array.isArray(children)) children.forEach(push); + else push(children); + return refs; +} + +/** A single child reference and the field-path suffix it was found at (e.g. `children[0]`, `tabItems[1].child`). */ +interface RefEdge { + path: string; + ref: string; +} + +/** + * Collect every child reference a component makes, paired with its field-path + * suffix, by deriving ref-fields from the catalog (#1948). + * + * The implicit `child` (single) and `children` (list) fields are ALWAYS ref + * fields, even with no catalog — this preserves the #1944 / catalog-free + * behaviour. Other fields are refs ONLY when the component's catalog schema + * marks the property `"format": "componentRef"` (single) or + * `"componentRefList"` (list). For an array-typed property whose `items` is an + * object schema, marked sub-properties are honoured per element (this is how + * Tabs `tabItems[].child` is found — derived, never hard-coded). A property with + * no marker is treated as data, never a ref — a bare data string and a bare ref + * string are otherwise indistinguishable, so shape-based detection is unsafe. + * + * Path grammar (byte-aligned with the Python/.NET siblings): + * single-ref field → `` + * list-ref field (array) → `[k]` + * list-ref field (single tmpl) → `` + * nested array-of-object ref → `[k].` (and `[j]` if that sub-field is itself a list) + */ +function collectComponentRefEdges( + comp: Record, + schema: { properties?: Record; [k: string]: unknown } | undefined, +): RefEdge[] { + const edges: RefEdge[] = []; + + const pushSingle = (field: string, value: unknown) => { + collectChildRefs(value).forEach((ref) => edges.push({ path: field, ref })); + }; + const pushList = (field: string, value: unknown) => { + if (Array.isArray(value)) { + value.forEach((item, k) => collectChildRefs(item).forEach((ref) => edges.push({ path: `${field}[${k}]`, ref }))); + } else { + collectChildRefs(value).forEach((ref) => edges.push({ path: field, ref })); + } + }; + + // Implicit refs — always, regardless of catalog. + pushSingle("child", comp.child); + pushList("children", comp.children); + + // Explicit catalog-marked refs. + const props = schema?.properties; + if (isObject(props)) { + for (const [field, propSchema] of Object.entries(props)) { + if (field === "child" || field === "children" || !isObject(propSchema)) continue; + const fmt = propSchema.format; + if (fmt === "componentRef") { + pushSingle(field, comp[field]); + } else if (fmt === "componentRefList") { + pushList(field, comp[field]); + } else if (propSchema.type === "array" && isObject(propSchema.items)) { + const itemProps = (propSchema.items as Record).properties; + const arrVal = comp[field]; + if (isObject(itemProps) && Array.isArray(arrVal)) { + arrVal.forEach((item, k) => { + if (!isObject(item)) return; + for (const [sub, subSchema] of Object.entries(itemProps)) { + if (!isObject(subSchema)) continue; + if (subSchema.format === "componentRef") { + collectChildRefs(item[sub]).forEach((ref) => edges.push({ path: `${field}[${k}].${sub}`, ref })); + } else if (subSchema.format === "componentRefList") { + const subVal = item[sub]; + if (Array.isArray(subVal)) { + subVal.forEach((sv, j) => collectChildRefs(sv).forEach((ref) => edges.push({ path: `${field}[${k}].${sub}[${j}]`, ref }))); + } else { + collectChildRefs(subVal).forEach((ref) => edges.push({ path: `${field}[${k}].${sub}`, ref })); + } + } + } + }); + } + } + } + } + + return edges; +} + +/** id → ordered child-id references, derived per component via `collectComponentRefEdges`. */ +function childAdjacency(components: Array>, catalog?: A2UIValidationCatalog): Map { + const adj = new Map(); + for (const comp of components) { + if (isObject(comp) && typeof comp.id === "string") { + const type = typeof comp.component === "string" ? comp.component : undefined; + const schema = catalog && type ? catalog.components[type] : undefined; + adj.set( + comp.id, + collectComponentRefEdges(comp, schema).map((e) => e.ref), + ); + } + } + return adj; +} + +/** + * Find unique child-reference cycles (self-references and longer loops) over the + * child graph via a depth-first search. Each cycle is canonicalised — rotated so + * the lexicographically smallest id leads — so the same loop reached from + * different entry points collapses to one finding, and the reported chain stays + * byte-identical across the sibling toolkits. + */ +function findChildCycles(components: Array>, catalog?: A2UIValidationCatalog): string[][] { + const adj = childAdjacency(components, catalog); + const color = new Map(); // absent/0 = unvisited, 1 = on stack, 2 = done + const cycles = new Map(); + + const canonical = (nodes: string[]): string[] => { + let m = 0; + for (let i = 1; i < nodes.length; i++) if (nodes[i] < nodes[m]) m = i; + return [...nodes.slice(m), ...nodes.slice(0, m)]; + }; + + // Iterative DFS (explicit frame stack, not call recursion): the validator runs + // on untrusted model output, so a pathologically deep child chain must not + // overflow the native call stack. `path` mirrors the on-stack (gray) nodes in + // entry order, so `path.indexOf(v)` recovers the cycle slice on a back edge. + for (const root of adj.keys()) { + if ((color.get(root) ?? 0) !== 0) continue; + const frames: Array<{ node: string; i: number }> = [{ node: root, i: 0 }]; + const path: string[] = [root]; + color.set(root, 1); + while (frames.length > 0) { + const frame = frames[frames.length - 1]; + const neighbors = adj.get(frame.node) ?? []; + if (frame.i >= neighbors.length) { + color.set(frame.node, 2); + frames.pop(); + path.pop(); + continue; + } + const v = neighbors[frame.i++]; + const c = color.get(v) ?? 0; + if (c === 0) { + color.set(v, 1); + path.push(v); + frames.push({ node: v, i: 0 }); + } else if (c === 1) { + const cyc = canonical(path.slice(path.indexOf(v))); + const key = cyc.join(""); + if (!cycles.has(key)) cycles.set(key, cyc); + } + } + } + return [...cycles.values()]; +} + +/** Recursively collect absolute (`/…`) binding paths from a component's props. */ +function collectAbsoluteBindingPaths(node: unknown, acc: string[] = []): string[] { + if (Array.isArray(node)) { + node.forEach((v) => collectAbsoluteBindingPaths(v, acc)); + } else if (isObject(node)) { + if (typeof node.path === "string" && node.path.startsWith("/")) acc.push(node.path); + for (const [k, v] of Object.entries(node)) { + if (k === "path") continue; + collectAbsoluteBindingPaths(v, acc); + } + } + return acc; +} diff --git a/sdks/typescript/packages/a2ui-toolkit/tsconfig.json b/sdks/typescript/packages/a2ui-toolkit/tsconfig.json new file mode 100644 index 0000000000..497e191343 --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node", + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["vitest/globals"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/sdks/typescript/packages/a2ui-toolkit/tsdown.config.ts b/sdks/typescript/packages/a2ui-toolkit/tsdown.config.ts new file mode 100644 index 0000000000..8fff46e187 --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig((inlineConfig) => ({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + exports: true, + fixedExtension: false, + sourcemap: true, + clean: !inlineConfig.watch, +})); diff --git a/sdks/typescript/packages/a2ui-toolkit/vitest.config.ts b/sdks/typescript/packages/a2ui-toolkit/vitest.config.ts new file mode 100644 index 0000000000..3f824fb954 --- /dev/null +++ b/sdks/typescript/packages/a2ui-toolkit/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, +}); diff --git a/sdks/typescript/packages/cli/LICENSE b/sdks/typescript/packages/cli/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/sdks/typescript/packages/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdks/typescript/packages/cli/package.json b/sdks/typescript/packages/cli/package.json index db7a0fc760..527fcfa36c 100644 --- a/sdks/typescript/packages/cli/package.json +++ b/sdks/typescript/packages/cli/package.json @@ -1,7 +1,8 @@ { "name": "create-ag-ui-app", "author": "Markus Ecker ", - "version": "0.0.53", + "version": "0.0.58", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/sdks/typescript/packages/cli/src/build-args.test.ts b/sdks/typescript/packages/cli/src/build-args.test.ts new file mode 100644 index 0000000000..1862cfcd87 --- /dev/null +++ b/sdks/typescript/packages/cli/src/build-args.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "vitest"; +import { buildCopilotKitCreateArgs } from "./build-args"; + +test("uses copilotkit@latest and forwards name + --no-banner", () => { + const args = buildCopilotKitCreateArgs({ langgraphPy: true }, "my-app"); + expect(args).toEqual([ + "copilotkit@latest", + "create", + "--no-banner", + "-n", + "my-app", + "-f", + "langgraph-py", + ]); +}); + +test("maps --crewai-flows (crewaiFlows) to -f flows", () => { + const args = buildCopilotKitCreateArgs({ crewaiFlows: true }, "demo"); + expect(args).toContain("-f"); + expect(args).toContain("flows"); +}); + +test("emits no -f when no framework flag is set", () => { + const args = buildCopilotKitCreateArgs({}, "demo"); + expect(args).not.toContain("-f"); + expect(args).toEqual(["copilotkit@latest", "create", "--no-banner", "-n", "demo"]); +}); + +test("maps each framework flag to its canonical -f value", () => { + const cases: Array<[Record, string]> = [ + [{ langgraphJs: true }, "langgraph-js"], + [{ mastra: true }, "mastra"], + [{ ag2: true }, "ag2"], + [{ llamaindex: true }, "llamaindex"], + [{ agno: true }, "agno"], + [{ pydanticAi: true }, "pydantic-ai"], + [{ adk: true }, "adk"], + ]; + for (const [opts, expected] of cases) { + const args = buildCopilotKitCreateArgs(opts, "x"); + expect(args.slice(-2)).toEqual(["-f", expected]); + } +}); diff --git a/sdks/typescript/packages/cli/src/build-args.ts b/sdks/typescript/packages/cli/src/build-args.ts new file mode 100644 index 0000000000..7255cdaf4f --- /dev/null +++ b/sdks/typescript/packages/cli/src/build-args.ts @@ -0,0 +1,57 @@ +/** + * Version spec for the underlying CopilotKit CLI. Tracks `@latest` so + * `create-ag-ui-app` always scaffolds with the current CopilotKit CLI rather + * than freezing on a pinned major. + */ +export const COPILOTKIT_CLI_SPEC = "copilotkit@latest"; + +/** Framework flags as parsed by commander, in selection-priority order. */ +export interface FrameworkOptions { + langgraphPy?: boolean; + langgraphJs?: boolean; + crewaiFlows?: boolean; + mastra?: boolean; + ag2?: boolean; + llamaindex?: boolean; + agno?: boolean; + pydanticAi?: boolean; + adk?: boolean; +} + +/** + * Builds the argv passed to `npx` to invoke the CopilotKit CLI's `create` + * command. Pure and exported so the mapping (and the version spec) is unit + * tested without spawning a process. + * + * @param options - Parsed commander framework flags. + * @param projectName - Validated project name. + * @returns The argv array for `spawn("npx", ...)`. + */ +export function buildCopilotKitCreateArgs( + options: FrameworkOptions, + projectName: string, +): string[] { + const frameworkArgs: string[] = []; + + if (options.langgraphPy) { + frameworkArgs.push("-f", "langgraph-py"); + } else if (options.langgraphJs) { + frameworkArgs.push("-f", "langgraph-js"); + } else if (options.crewaiFlows) { + frameworkArgs.push("-f", "flows"); + } else if (options.mastra) { + frameworkArgs.push("-f", "mastra"); + } else if (options.ag2) { + frameworkArgs.push("-f", "ag2"); + } else if (options.llamaindex) { + frameworkArgs.push("-f", "llamaindex"); + } else if (options.agno) { + frameworkArgs.push("-f", "agno"); + } else if (options.pydanticAi) { + frameworkArgs.push("-f", "pydantic-ai"); + } else if (options.adk) { + frameworkArgs.push("-f", "adk"); + } + + return [COPILOTKIT_CLI_SPEC, "create", "--no-banner", "-n", projectName, ...frameworkArgs]; +} diff --git a/sdks/typescript/packages/cli/src/index.ts b/sdks/typescript/packages/cli/src/index.ts index 6ba14b53cb..071230c06c 100644 --- a/sdks/typescript/packages/cli/src/index.ts +++ b/sdks/typescript/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import inquirer from "inquirer"; import { spawn } from "child_process"; +import { buildCopilotKitCreateArgs } from "./build-args"; import fs from "fs"; import path from "path"; import { downloadTemplate } from "giget"; @@ -28,7 +29,7 @@ ${RESET} const description = ` Quickly scaffold AG-UI enabled applications for your favorite agent frameworks. -` +`; async function createProject() { displayBanner(); @@ -46,8 +47,8 @@ async function createProject() { "llamaindex", "pydanticAi", "agno", - "adk" - ].some(flag => options[flag]); + "adk", + ].some((flag) => options[flag]); if (isFrameworkDefined) { await handleCopilotKitNextJs(); @@ -85,7 +86,6 @@ async function createProject() { async function handleCopilotKitNextJs() { const options = program.opts(); - const frameworkArgs: string[] = []; const projectName = await inquirer.prompt([ { @@ -105,40 +105,10 @@ async function handleCopilotKitNextJs() { }, ]); - // Translate options to CopilotKit framework flags - if (options.langgraphPy) { - frameworkArgs.push("-f", "langgraph-py"); - } else if (options.langgraphJs) { - frameworkArgs.push("-f", "langgraph-js"); - } else if (options.crewiAiFlows) { - frameworkArgs.push("-f", "flows"); - } else if (options.mastra) { - frameworkArgs.push("-f", "mastra"); - } else if (options.ag2) { - frameworkArgs.push("-f", "ag2"); - } else if (options.llamaindex) { - frameworkArgs.push("-f", "llamaindex"); - } else if (options.agno) { - frameworkArgs.push("-f", "agno"); - } else if (options.pydanticAi) { - frameworkArgs.push("-f", "pydantic-ai"); - } else if (options.adk) { - frameworkArgs.push("-f", "adk"); - } - - const copilotkit = spawn("npx", - [ - "copilotkit@latest", - "create", - "--no-banner", - "-n", projectName.name, - ...frameworkArgs, - ], - { - stdio: "inherit", - shell: true, - }, - ); + const copilotkit = spawn("npx", buildCopilotKitCreateArgs(options, projectName.name), { + stdio: "inherit", + shell: true, + }); copilotkit.on("close", (code) => { if (code !== 0) { @@ -205,10 +175,7 @@ async function handleCliClient() { } // Metadata -program - .name("create-ag-ui-app") - .description(description) - .version("0.0.36"); +program.name("create-ag-ui-app").description(description).version("0.0.36"); // Add framework flags program @@ -220,7 +187,7 @@ program .option("--llamaindex", "Use the LlamaIndex framework") .option("--agno", "Use the Agno framework") .option("--ag2", "Use the AG2 framework") - .option("--adk", "Use the ADK framework") + .option("--adk", "Use the ADK framework"); program.action(async () => { await createProject(); diff --git a/sdks/typescript/packages/client/LICENSE b/sdks/typescript/packages/client/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/sdks/typescript/packages/client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdks/typescript/packages/client/package.json b/sdks/typescript/packages/client/package.json index 1dd21a130b..f5c2b8c6e3 100644 --- a/sdks/typescript/packages/client/package.json +++ b/sdks/typescript/packages/client/package.json @@ -1,7 +1,8 @@ { "name": "@ag-ui/client", "author": "Markus Ecker ", - "version": "0.0.53", + "version": "0.0.57", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/sdks/typescript/packages/client/src/agent/__tests__/http-fetch-binding.test.ts b/sdks/typescript/packages/client/src/agent/__tests__/http-fetch-binding.test.ts new file mode 100644 index 0000000000..a74bbe574f --- /dev/null +++ b/sdks/typescript/packages/client/src/agent/__tests__/http-fetch-binding.test.ts @@ -0,0 +1,69 @@ +import { HttpAgent } from "../http"; +import { runHttpRequest } from "@/run/http-request"; +import { RunAgentInput } from "@ag-ui/core"; +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from "vitest"; + +// Capture the fetch thunk passed to runHttpRequest without performing a real request. +vi.mock("@/run/http-request", () => ({ + runHttpRequest: vi.fn(() => ({ subscribe: () => ({ unsubscribe: () => {} }) })), +})); +vi.mock("@/transform/http", () => ({ + transformHttpEventStream: vi.fn((source$) => source$), +})); + +const minimalInput = (): RunAgentInput => + ({ + threadId: "t1", + runId: "r1", + tools: [], + context: [], + forwardedProps: {}, + state: {}, + messages: [], + }) as unknown as RunAgentInput; + +describe("HttpAgent fetch receiver binding", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + // Regression test for the browser "Illegal invocation" bug: when HttpAgent stores + // the global fetch unbound (`this.fetch = config.fetch ?? fetch`) and later calls + // it as a method (`this.fetch(...)`), the receiver becomes the agent instead of + // window. A browser's native fetch is a checked-receiver method and throws. Node's + // fetch tolerates it, so this only surfaces in the browser — exactly the dojo e2e + // failure. We simulate the browser's checked receiver here. + it("calls the default global fetch with a valid receiver (no Illegal invocation)", async () => { + const seen: Array<{ url: string }> = []; + const checkedReceiverFetch = function ( + this: unknown, + url: string, + _init?: RequestInit, + ) { + if (this !== globalThis && this !== undefined) { + throw new TypeError( + "Failed to execute 'fetch' on 'Window': Illegal invocation", + ); + } + seen.push({ url }); + return Promise.resolve(new Response("ok")); + }; + globalThis.fetch = checkedReceiverFetch as unknown as typeof globalThis.fetch; + + const agent = new HttpAgent({ url: "https://api.example.com/agent" }); + + agent.run(minimalInput()); + + const thunk = (runHttpRequest as Mock).mock.calls[0][0] as () => Promise; + await expect(thunk()).resolves.toBeInstanceOf(Response); + expect(seen).toHaveLength(1); + expect(seen[0].url).toBe("https://api.example.com/agent"); + }); +}); diff --git a/sdks/typescript/packages/client/src/agent/__tests__/subscriber.clone-cost.test.ts b/sdks/typescript/packages/client/src/agent/__tests__/subscriber.clone-cost.test.ts new file mode 100644 index 0000000000..1e327783db --- /dev/null +++ b/sdks/typescript/packages/client/src/agent/__tests__/subscriber.clone-cost.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Message, State } from "@ag-ui/core"; + +// Spy on the clone helper so we can COUNT how many full structuredClone_ calls +// runSubscribersWithMutation makes per invocation. This is the cost that, when +// paid on every streamed event over a large messages/state, exhausts the +// renderer heap (V8 fatal: "JavaScript heap out of memory" from structuredClone). +const { cloneSpy } = vi.hoisted(() => ({ + // Defer to the real native structuredClone so cyclic / non-JSON-safe values + // round-trip correctly (the production `structuredClone_` ultimately calls + // the native API). We only need the spy to count invocations, not change + // behavior. + cloneSpy: vi.fn((obj: any) => (obj === undefined ? undefined : structuredClone(obj))), +})); +vi.mock("@/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, structuredClone_: cloneSpy }; +}); + +import { type AgentSubscriber, runSubscribersWithMutation } from "../subscriber"; + +describe("runSubscribersWithMutation clone cost", () => { + beforeEach(() => cloneSpy.mockClear()); + + const noopSubscriber: AgentSubscriber = { onEvent: () => undefined }; + + const run = (messages: Message[], state: State) => + runSubscribersWithMutation([noopSubscriber], messages, state, (s, m, st) => + s.onEvent?.({ + messages: m, + state: st, + agent: {} as any, + input: {} as any, + event: { type: "RUN_STARTED" } as any, + }), + ); + + it("clones baseline messages+state for SMALL payloads (dev freeze guard active)", async () => { + await run([{ id: "m", role: "user", content: "hi" }], { counter: 1 }); + // Freeze path: baseline messages + baseline state are cloned. + expect(cloneSpy).toHaveBeenCalledTimes(2); + }); + + it("makes ZERO clones for a LARGE payload with no mutation (the fix)", async () => { + const bigArgs = "x".repeat(600_000); // > DEV_FREEZE_CHAR_LIMIT (512K) + const messages: Message[] = [ + { + id: "m", + role: "assistant", + toolCalls: [{ id: "tc", type: "function", function: { name: "write_file", arguments: bigArgs } }], + } as unknown as Message, + ]; + await run(messages, {}); + // Large payload skips the dev clone+freeze; no subscriber mutation ⇒ no clone. + // Before the fix this was 2 full clones of a ~600KB structure on EVERY event. + expect(cloneSpy).not.toHaveBeenCalled(); + }); + + it("still defensively clones a subscriber's returned mutation on the large path", async () => { + const bigArgs = "x".repeat(600_000); + const mutating: AgentSubscriber = { + onEvent: ({ messages }) => ({ messages: [...messages] as Message[] }), + }; + const messages: Message[] = [ + { + id: "m", + role: "assistant", + toolCalls: [{ id: "tc", type: "function", function: { name: "write_file", arguments: bigArgs } }], + } as unknown as Message, + ]; + const result = await runSubscribersWithMutation([mutating], messages, {}, (s, m, st) => + s.onEvent?.({ messages: m, state: st, agent: {} as any, input: {} as any, event: { type: "RUN_STARTED" } as any }), + ); + // Exactly one clone — the defensive copy of the returned mutation (isolation + // contract preserved), not a per-event baseline clone. + expect(cloneSpy).toHaveBeenCalledTimes(1); + expect(result.messages).toBeDefined(); + }); + + it("terminates when state contains a cyclic reference (no infinite loop in payloadExceeds)", async () => { + // Cyclic state — `State` is typed `any`, so user code is free to put a + // self-referencing object here. payloadExceeds must not loop forever on it. + // + // The DFS scan was the symptom: before the fix, a `for (const key in value)` + // walk with no visited-set kept re-pushing `cyclicState.self` and either + // hung (small repro) or blew the stack via RangeError on `Array.push` + // (large repro). Either way the dev guard never returned. + const cyclicState: any = { name: "root" }; + cyclicState.self = cyclicState; + cyclicState.nested = { back: cyclicState }; + + // Wrap in a short timeout so a hang surfaces as a clear test failure + // rather than the suite-level wallclock. With the fix payloadExceeds + // visits each object at most once and resolves quickly. + const result = await Promise.race([ + runSubscribersWithMutation([noopSubscriber], [], cyclicState, (s, m, st) => + s.onEvent?.({ + messages: m, + state: st, + agent: {} as any, + input: {} as any, + event: { type: "RUN_STARTED" } as any, + }), + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error("payloadExceeds hung on cyclic state")), 1000), + ), + ]); + expect(result).toBeDefined(); + }, 3000); + + it("does NOT deep-freeze a huge mutation returned by a subscriber when starting from a small payload", async () => { + // Start small so the dev freeze path is initially active (freezeInputs=true). + const smallMessages: Message[] = [{ id: "m", role: "user", content: "hi" } as Message]; + const smallState: State = { counter: 1 }; + + // Subscriber returns a mutation containing a >512K-char string nested in an + // object. Naive code would still deep-freeze this on the next loop iteration. + const bigString = "x".repeat(600_000); + const hugeNested = { payload: { huge: bigString } }; + const hugeMessages: Message[] = [ + { id: "m2", role: "assistant", content: bigString } as unknown as Message, + ]; + const growingSubscriber: AgentSubscriber = { + onEvent: () => ({ + messages: hugeMessages, + state: hugeNested as unknown as State, + }), + }; + // A second subscriber so the freeze path would be re-applied on a 2nd + // iteration (this is where the cost regression would re-emerge). + const observerSubscriber: AgentSubscriber = { onEvent: () => undefined }; + + const result = await runSubscribersWithMutation( + [growingSubscriber, observerSubscriber], + smallMessages, + smallState, + (s, m, st) => + s.onEvent?.({ + messages: m, + state: st, + agent: {} as any, + input: {} as any, + event: { type: "RUN_STARTED" } as any, + }), + ); + + // With the fix, after the growing subscriber's mutation, freezeInputs is + // re-probed and disabled (the new payload exceeds the limit), so the next + // subscriber iteration does NOT call deepFreeze on the huge structure, and + // the final return path does NOT need to clone-to-unfreeze either. + // + // Clone budget on the freeze path WITH the fix: + // - 2 baseline clones of the SMALL inputs (freeze path is initially on) + // - 2 defensive clones of the subscriber's returned mutation + // = 4 total. No extra clones on the second iteration or the return path. + // + // Pre-fix, deepFreeze on the huge mutation freezes it, then the return + // path adds 1–2 more structuredClone_ calls to unfreeze — i.e. > 4. + expect(cloneSpy).toHaveBeenCalledTimes(4); + + // And the returned huge structures must remain unfrozen (callers may + // mutate; the contract is mutable-out). + expect(result.state).toBeDefined(); + const resultState = result.state as any; + expect(Object.isFrozen(resultState)).toBe(false); + expect(Object.isFrozen(resultState.payload)).toBe(false); + expect(result.messages).toBeDefined(); + const resultMessages = result.messages as Message[]; + expect(Object.isFrozen(resultMessages)).toBe(false); + expect(Object.isFrozen(resultMessages[0])).toBe(false); + }); +}); diff --git a/sdks/typescript/packages/client/src/agent/http.ts b/sdks/typescript/packages/client/src/agent/http.ts index 87868f7f4c..e7aa2b4549 100644 --- a/sdks/typescript/packages/client/src/agent/http.ts +++ b/sdks/typescript/packages/client/src/agent/http.ts @@ -53,7 +53,12 @@ export class HttpAgent extends AbstractAgent { super(config); this.url = config.url; this.headers = structuredClone_(config.headers ?? {}); - this.fetch = config.fetch ?? fetch; + // Bind the default fetch to the global object. Storing the bare `fetch` + // and later invoking it as `this.fetch(...)` sets the receiver to the agent + // instance; a browser's native fetch is a checked-receiver method and throws + // "Illegal invocation" when not called with `window` as `this`. (Node's fetch + // tolerates any receiver, so this only surfaces in the browser.) + this.fetch = config.fetch ?? ((url, requestInit) => fetch(url, requestInit)); } run(input: RunAgentInput): Observable { diff --git a/sdks/typescript/packages/client/src/agent/subscriber.ts b/sdks/typescript/packages/client/src/agent/subscriber.ts index 99c1be893b..3d88b1e35f 100644 --- a/sdks/typescript/packages/client/src/agent/subscriber.ts +++ b/sdks/typescript/packages/client/src/agent/subscriber.ts @@ -225,6 +225,52 @@ function deepFreeze(obj: T): T { return obj; } +// Above this many string characters across messages+state, the dev-only +// clone+deepFreeze guard is skipped. That guard exists to surface accidental +// in-place mutation during development — it is NOT required for correctness. +// Paying a full recursive structuredClone + deepFreeze of the entire messages +// array AND state object on every streamed event is what exhausts the renderer +// heap when tool-call arguments stream large payloads (V8 fatal: +// "JavaScript heap out of memory" from structuredClone). +const DEV_FREEZE_CHAR_LIMIT = 512 * 1024; + +// Cheap, bounded size probe: returns true as soon as the combined string length +// of messages+state (counting both string values AND object key names, since +// keys also contribute to clone cost) exceeds `limit`. Does NOT recursively +// structuredClone or materialize copies — only a bounded iterative traversal +// stack plus a visited-set guard, so it is safe for arbitrarily nested or +// cyclic structures. (`State` is typed `any`, so cycles are possible.) +function payloadExceeds(messages: unknown, state: unknown, limit: number): boolean { + let chars = 0; + const stack: unknown[] = [messages, state]; + const seen = new WeakSet(); + while (stack.length > 0) { + const value = stack.pop(); + if (typeof value === "string") { + chars += value.length; + if (chars > limit) return true; + } else if (value !== null && typeof value === "object") { + if (seen.has(value as object)) continue; + seen.add(value as object); + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) stack.push(value[i]); + } else { + // Own enumerable keys only — avoids walking the prototype chain and + // triggering inherited getters (matches deepFreeze's Object.values). + const keys = Object.keys(value as Record); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + // Key names contribute to clone cost too; count them as we go. + chars += key.length; + if (chars > limit) return true; + stack.push((value as Record)[key]); + } + } + } + } + return false; +} + export async function runSubscribersWithMutation( subscribers: AgentSubscriber[], initialMessages: Message[], @@ -243,10 +289,21 @@ export async function runSubscribersWithMutation( (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test" || Boolean(process.env.VITEST_WORKER_ID)); - const baselineMessages = structuredClone_(initialMessages); - const baselineState = structuredClone_(initialState); - let messages: Message[] = baselineMessages; - let state: State = baselineState; + + // The dev-only clone+deepFreeze guard (which surfaces accidental in-place + // mutation) is the dominant per-event allocation. Skip it in production, and + // in dev when the payload is large — otherwise streaming large tool-call + // arguments deep-clones the whole messages+state on every event and exhausts + // the heap (V8 fatal: "JavaScript heap out of memory" from structuredClone). + let freezeInputs = isDev && !payloadExceeds(initialMessages, initialState, DEV_FREEZE_CHAR_LIMIT); + + // Only the freeze path needs an isolated baseline copy. Otherwise pass the + // inputs through and lazily clone only when a subscriber actually returns a + // mutation — so the common "no mutation" event costs zero clones. + let messages: Message[] = freezeInputs ? structuredClone_(initialMessages) : initialMessages; + let state: State = freezeInputs ? structuredClone_(initialState) : initialState; + let messagesMutated = false; + let stateMutated = false; let stopPropagation: boolean | undefined = undefined; @@ -254,9 +311,9 @@ export async function runSubscribersWithMutation( try { // Subscribers receive shared references and must not mutate them in-place. // Mutations should only be communicated via the return value. - // In dev/test mode only: deep-freeze inputs so accidental in-place mutations surface - // as TypeErrors immediately. In production, enforcement is type-level only. - if (isDev) { + // In dev/test mode (small payloads): deep-freeze inputs so accidental + // in-place mutations surface as TypeErrors immediately. + if (freezeInputs) { deepFreeze(messages); deepFreeze(state); } @@ -269,12 +326,27 @@ export async function runSubscribersWithMutation( // Replace with a defensive copy of the subscriber's mutation, // but skip if the subscriber returned the same reference (no-op). + let payloadChanged = false; if (mutation.messages !== undefined && mutation.messages !== messages) { messages = structuredClone_(mutation.messages); + messagesMutated = true; + payloadChanged = true; } if (mutation.state !== undefined && mutation.state !== state) { state = structuredClone_(mutation.state); + stateMutated = true; + payloadChanged = true; + } + + // If a subscriber's mutation has grown the payload past the limit, drop + // the freeze guard for the remaining iterations. Otherwise we'd pay a + // full deepFreeze + final unfreeze-clone of the now-huge structure on + // every later subscriber — exactly the cost this PR removes. + if (freezeInputs && payloadChanged) { + if (payloadExceeds(messages, state, DEV_FREEZE_CHAR_LIMIT)) { + freezeInputs = false; + } } stopPropagation = mutation.stopPropagation; @@ -304,15 +376,14 @@ export async function runSubscribersWithMutation( } } - // In dev/test mode, the canonical messages/state references may have been - // frozen in-place (for subscriber mutation detection). Clone them before - // returning so callers receive a mutable copy, not a frozen one. + // A mutated copy may have been frozen in-place on a later subscriber pass; + // clone it before returning so callers receive a mutable copy. return { - ...(messages !== baselineMessages - ? { messages: isDev && Object.isFrozen(messages) ? structuredClone_(messages) : messages } + ...(messagesMutated + ? { messages: Object.isFrozen(messages) ? structuredClone_(messages) : messages } : {}), - ...(state !== baselineState - ? { state: isDev && Object.isFrozen(state) ? structuredClone_(state) : state } + ...(stateMutated + ? { state: Object.isFrozen(state) ? structuredClone_(state) : state } : {}), ...(stopPropagation !== undefined ? { stopPropagation } : {}), }; diff --git a/sdks/typescript/packages/client/src/apply/__tests__/default.activity.test.ts b/sdks/typescript/packages/client/src/apply/__tests__/default.activity.test.ts index 9f95db0a0c..49466d270f 100644 --- a/sdks/typescript/packages/client/src/apply/__tests__/default.activity.test.ts +++ b/sdks/typescript/packages/client/src/apply/__tests__/default.activity.test.ts @@ -438,3 +438,111 @@ describe("MESSAGES_SNAPSHOT preserves client-only messages", () => { ]); }); }); + +describe("MESSAGES_SNAPSHOT with snapshot-supplied reasoning", () => { + // When the backend includes reasoning in the snapshot (e.g. LangGraph + // re-deriving it from checkpointed content blocks), the snapshot is the + // source of truth for reasoning: the streamed copy — which carries a + // different, locally-generated id — must be replaced, not kept alongside. + + it("replaces streamed reasoning with the snapshot's canonical copy when ids differ", async () => { + const msgs = await applySnapshot( + [ + { id: "m1", role: "user", content: "What is the best car to buy?" }, + { id: "uuid-a", role: "reasoning", content: "The user wants a car recommendation." }, + { id: "lc-1", role: "assistant", content: "Based on my analysis…" }, + ] as Message[], + [ + { id: "m1", role: "user", content: "What is the best car to buy?" }, + { id: "rs-1", role: "reasoning", content: "The user wants a car recommendation." }, + { id: "resp-1", role: "assistant", content: "Based on my analysis…" }, + ], + ); + + expect(msgs.filter((m) => m.role === "reasoning").length).toBe(1); + expect(msgs.map((m) => m.id)).toEqual(["m1", "rs-1", "resp-1"]); + }); + + it("replaces streamed reasoning when the snapshot arrives before the assistant streamed", async () => { + const msgs = await applySnapshot( + [ + { id: "m1", role: "user", content: "What is the best car to buy?" }, + { id: "uuid-a", role: "reasoning", content: "The user wants a car recommendation." }, + ] as Message[], + [ + { id: "m1", role: "user", content: "What is the best car to buy?" }, + { id: "rs-1", role: "reasoning", content: "The user wants a car recommendation." }, + { id: "resp-1", role: "assistant", content: "Based on my analysis…" }, + ], + ); + + expect(msgs.filter((m) => m.role === "reasoning").length).toBe(1); + expect(msgs.map((m) => m.id)).toEqual(["m1", "rs-1", "resp-1"]); + }); + + it("converges multi-turn reasoning to one message per turn", async () => { + // Models turn 2 of the real flow: turn 1 already converged to canonical + // ids via its own end-of-run snapshot; turn 2's streamed reasoning and + // assistant still carry locally-generated ids. + const msgs = await applySnapshot( + [ + { id: "u1", role: "user", content: "q1" }, + { id: "rs-1", role: "reasoning", content: "thinking about q1" }, + { id: "resp-1", role: "assistant", content: "a1" }, + { id: "u2", role: "user", content: "q2" }, + { id: "uuid-b", role: "reasoning", content: "thinking about q2" }, + { id: "lc-2", role: "assistant", content: "a2" }, + ] as Message[], + [ + { id: "u1", role: "user", content: "q1" }, + { id: "rs-1", role: "reasoning", content: "thinking about q1" }, + { id: "resp-1", role: "assistant", content: "a1" }, + { id: "u2", role: "user", content: "q2" }, + { id: "rs-2", role: "reasoning", content: "thinking about q2" }, + { id: "resp-2", role: "assistant", content: "a2" }, + ], + ); + + expect(msgs.filter((m) => m.role === "reasoning").length).toBe(2); + expect(msgs.map((m) => m.id)).toEqual(["u1", "rs-1", "resp-1", "u2", "rs-2", "resp-2"]); + }); + + it("still preserves activity messages when the snapshot carries reasoning", async () => { + const msgs = await applySnapshot( + [ + { id: "m1", role: "user", content: "hello" }, + { id: "act-1", role: "activity", activityType: "PLAN", content: { tasks: ["a"] } }, + { id: "uuid-a", role: "reasoning", content: "thinking" }, + { id: "lc-1", role: "assistant", content: "hi" }, + ] as Message[], + [ + { id: "m1", role: "user", content: "hello" }, + { id: "rs-1", role: "reasoning", content: "thinking" }, + { id: "resp-1", role: "assistant", content: "hi" }, + ], + ); + + expect(msgs.filter((m) => m.role === "activity").length).toBe(1); + expect(msgs.filter((m) => m.role === "reasoning").length).toBe(1); + expect(msgs.map((m) => m.id)).toEqual(["m1", "act-1", "rs-1", "resp-1"]); + }); + + it("updates an id-stable reasoning message with the snapshot version", async () => { + const msgs = await applySnapshot( + [ + { id: "m1", role: "user", content: "hello" }, + { id: "r1", role: "reasoning", content: "thinking" }, + { id: "a1", role: "assistant", content: "hi" }, + ] as Message[], + [ + { id: "m1", role: "user", content: "hello" }, + { id: "r1", role: "reasoning", content: "thinking", encryptedValue: "enc-1" } as Message, + { id: "a1", role: "assistant", content: "hi" }, + ], + ); + + expect(msgs.filter((m) => m.role === "reasoning").length).toBe(1); + const reasoning = msgs.find((m) => m.id === "r1")! as { encryptedValue?: string }; + expect(reasoning.encryptedValue).toBe("enc-1"); + }); +}); diff --git a/sdks/typescript/packages/client/src/apply/__tests__/default.tool-calls.test.ts b/sdks/typescript/packages/client/src/apply/__tests__/default.tool-calls.test.ts index 5502d5e8ce..0089c61e7f 100644 --- a/sdks/typescript/packages/client/src/apply/__tests__/default.tool-calls.test.ts +++ b/sdks/typescript/packages/client/src/apply/__tests__/default.tool-calls.test.ts @@ -20,7 +20,7 @@ const createAgent = (messages: Message[] = []) => ({ messages: messages.map((message) => ({ ...message })), state: {}, - } as unknown as AbstractAgent); + }) as unknown as AbstractAgent; describe("defaultApplyEvents with tool calls", () => { it("should handle a single tool call correctly", async () => { @@ -114,6 +114,78 @@ describe("defaultApplyEvents with tool calls", () => { ).toBe('{"query": "test search"}'); }); + it("places a tool result immediately after its tool call even when the result arrives after a trailing assistant text", async () => { + // Reproduces the chat -> tool -> chat ordering hazard: the follow-up + // assistant text streams before the tool result is recorded. Appending the + // result would yield assistant(tool_call) -> text -> tool, which violates the + // provider contract (assistant tool_call must be immediately followed by its + // tool result) and surfaces as a 400 on the next turn. + const events$ = new Subject(); + const initialState = { + messages: [], + state: {}, + threadId: "test-thread", + runId: "test-run", + tools: [], + context: [], + }; + + const agent = createAgent(initialState.messages); + const result$ = defaultApplyEvents(initialState, events$, agent, []); + const stateUpdatesPromise = firstValueFrom(result$.pipe(toArray())); + + events$.next({ type: EventType.RUN_STARTED } as RunStartedEvent); + // 1. assistant message with the tool call + events$.next({ + type: EventType.TOOL_CALL_START, + toolCallId: "tool1", + toolCallName: "get_weather", + } as ToolCallStartEvent); + events$.next({ + type: EventType.TOOL_CALL_END, + toolCallId: "tool1", + } as ToolCallEndEvent); + // 2. trailing assistant text streams BEFORE the result is recorded + events$.next({ + type: EventType.TEXT_MESSAGE_START, + messageId: "text1", + role: "assistant", + } as any); + events$.next({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: "text1", + delta: "Here is the weather.", + } as any); + events$.next({ + type: EventType.TEXT_MESSAGE_END, + messageId: "text1", + } as any); + // 3. tool result arrives last + events$.next({ + type: EventType.TOOL_CALL_RESULT, + messageId: "res1", + toolCallId: "tool1", + content: "sunny", + } as ToolCallResultEvent); + + await new Promise((resolve) => setTimeout(resolve, 10)); + events$.complete(); + + const stateUpdates = await stateUpdatesPromise; + const finalMessages = stateUpdates[stateUpdates.length - 1].messages ?? []; + + // Order must be assistant(tool_call) -> tool -> assistant(text) + expect(finalMessages.map((m) => m.role)).toEqual(["assistant", "tool", "assistant"]); + + const ownerIndex = finalMessages.findIndex((m) => + (m as AssistantMessage).toolCalls?.some((tc) => tc.id === "tool1"), + ); + expect(ownerIndex).toBe(0); + // tool result sits directly after its owning assistant message + expect(finalMessages[ownerIndex + 1]?.role).toBe("tool"); + expect((finalMessages[ownerIndex + 1] as any).toolCallId).toBe("tool1"); + }); + it("should handle multiple tool calls correctly", async () => { // Create a subject and state for events const events$ = new Subject(); @@ -502,7 +574,9 @@ describe("defaultApplyEvents with tool calls", () => { // Should have exactly 2 messages: one assistant (with both tool calls) and one tool result expect(finalState.messages?.length).toBe(2); - const assistantMsg = finalState.messages?.find((m) => m.role === "assistant") as AssistantMessage; + const assistantMsg = finalState.messages?.find( + (m) => m.role === "assistant", + ) as AssistantMessage; const toolMsg = finalState.messages?.find((m) => m.role === "tool"); expect(assistantMsg).toBeDefined(); @@ -594,7 +668,9 @@ describe("defaultApplyEvents with tool calls", () => { // Should have 3 messages: 1 assistant (with both tool calls) + 2 tool results expect(finalState.messages?.length).toBe(3); - const assistantMsg = finalState.messages?.find((m) => m.role === "assistant") as AssistantMessage; + const assistantMsg = finalState.messages?.find( + (m) => m.role === "assistant", + ) as AssistantMessage; expect(assistantMsg).toBeDefined(); expect(assistantMsg.id).toBe(parentMessageId); expect(assistantMsg.toolCalls?.length).toBe(2); @@ -633,7 +709,7 @@ describe("defaultApplyEvents with tool calls", () => { events$.next({ type: EventType.TOOL_CALL_ARGS, toolCallId: "tc-setup", - delta: '{}', + delta: "{}", } as ToolCallArgsEvent); events$.next({ type: EventType.TOOL_CALL_END, @@ -675,7 +751,9 @@ describe("defaultApplyEvents with tool calls", () => { expect(toolMsg?.id).toBe(collidingId); // The new assistant message should have fallen back to toolCallId, not collidingId - const assistantMsgs = finalState.messages?.filter((m) => m.role === "assistant") as AssistantMessage[]; + const assistantMsgs = finalState.messages?.filter( + (m) => m.role === "assistant", + ) as AssistantMessage[]; const collidingAssistant = assistantMsgs.find((m) => m.toolCalls?.some((tc) => tc.id === "tc-collide"), ); diff --git a/sdks/typescript/packages/client/src/apply/default.ts b/sdks/typescript/packages/client/src/apply/default.ts index 27aefdcc07..fac7f1e847 100644 --- a/sdks/typescript/packages/client/src/apply/default.ts +++ b/sdks/typescript/packages/client/src/apply/default.ts @@ -463,7 +463,30 @@ export const defaultApplyEvents = ( content: content, }; - messages.push(toolMessage); + // Place the tool result immediately after the assistant message that + // issued the matching tool call — not at the end. A result event can + // arrive after a trailing assistant text message (e.g. a + // chat -> tool -> chat loop streams the follow-up text before the + // result is recorded). Appending would leave the history as + // assistant(tool_call) -> text -> tool, which violates the provider + // contract that an assistant tool_call is immediately followed by its + // tool result and surfaces downstream as a 400. Skip past any tool + // results already recorded for the same assistant so parallel results + // keep their order. Fall back to append when no owner is found. + const ownerIndex = messages.findIndex( + (m) => + m.role === "assistant" && + (m as AssistantMessage).toolCalls?.some((tc) => tc.id === toolCallId), + ); + if (ownerIndex === -1) { + messages.push(toolMessage); + } else { + let insertAt = ownerIndex + 1; + while (insertAt < messages.length && messages[insertAt].role === "tool") { + insertAt++; + } + messages.splice(insertAt, 0, toolMessage); + } await Promise.all( subscribers.map((subscriber) => { @@ -568,18 +591,34 @@ export const defaultApplyEvents = ( const { messages: newMessages } = event as MessagesSnapshotEvent; // Edit-based merge: update existing messages with snapshot data while - // preserving activity and reasoning messages (which the backend - // doesn't include in the snapshot). + // preserving client-only messages the backend leaves out of the + // snapshot. const snapshotMap = new Map(newMessages.map((m) => [m.id, m])); - // Step 1 + 2: Keep activity/reasoning messages as-is, keep messages - // present in the snapshot (replaced with snapshot version), drop - // everything else. - const isClientOnlyRole = (role: string) => - role === "activity" || role === "reasoning"; + // `activity` messages are always client-only — backends never include + // them in MESSAGES_SNAPSHOT — so they are always preserved. + // + // `reasoning` messages are only sometimes client-only. Most backends + // never include reasoning in the snapshot (it exists purely as + // streamed REASONING_* events), so dropping local reasoning here + // would lose it. But a backend that round-trips reasoning (e.g. + // LangGraph re-deriving it from checkpointed content blocks) + // re-delivers the streamed reasoning under its own canonical id — + // message ids are generally NOT stable between streamed events and + // the snapshot. Preserving the streamed copy next to the snapshot + // copy would render the same reasoning twice. So when the snapshot + // itself carries reasoning, treat it as the source of truth for + // reasoning messages too and apply the normal replace semantics. + const snapshotHasReasoning = newMessages.some((m) => m.role === "reasoning"); + const isPreservedClientOnly = (m: Message) => + m.role === "activity" || (m.role === "reasoning" && !snapshotHasReasoning); + + // Step 1 + 2: Keep preserved client-only messages as-is, keep + // messages present in the snapshot (replaced with snapshot version), + // drop everything else. messages = messages - .filter((m) => isClientOnlyRole(m.role) || snapshotMap.has(m.id)) - .map((m) => (isClientOnlyRole(m.role) ? m : snapshotMap.get(m.id)!)); + .filter((m) => isPreservedClientOnly(m) || snapshotMap.has(m.id)) + .map((m) => (isPreservedClientOnly(m) ? m : snapshotMap.get(m.id)!)); // Step 3: Append messages from the snapshot that we don't have yet. const existingIds = new Set(messages.map((m) => m.id)); @@ -837,9 +876,7 @@ export const defaultApplyEvents = ( // can't mutate the agent's tracked state through array aliasing. if (mutation.stopPropagation !== true) { agent.pendingInterrupts = - finishedParams.outcome === "interrupt" - ? [...finishedParams.interrupts] - : []; + finishedParams.outcome === "interrupt" ? [...finishedParams.interrupts] : []; } return emitUpdates(); diff --git a/sdks/typescript/packages/core/LICENSE b/sdks/typescript/packages/core/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/sdks/typescript/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdks/typescript/packages/core/package.json b/sdks/typescript/packages/core/package.json index 87d3f5e84f..961a170db4 100644 --- a/sdks/typescript/packages/core/package.json +++ b/sdks/typescript/packages/core/package.json @@ -1,7 +1,8 @@ { "name": "@ag-ui/core", "author": "Markus Ecker ", - "version": "0.0.53", + "version": "0.0.57", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/sdks/typescript/packages/encoder/LICENSE b/sdks/typescript/packages/encoder/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/sdks/typescript/packages/encoder/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdks/typescript/packages/encoder/package.json b/sdks/typescript/packages/encoder/package.json index 50662f9043..94c52b20fc 100644 --- a/sdks/typescript/packages/encoder/package.json +++ b/sdks/typescript/packages/encoder/package.json @@ -1,7 +1,8 @@ { "name": "@ag-ui/encoder", "author": "Markus Ecker ", - "version": "0.0.53", + "version": "0.0.57", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git" diff --git a/sdks/typescript/packages/proto/LICENSE b/sdks/typescript/packages/proto/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/sdks/typescript/packages/proto/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdks/typescript/packages/proto/package.json b/sdks/typescript/packages/proto/package.json index c406267d49..ff5c65e44c 100644 --- a/sdks/typescript/packages/proto/package.json +++ b/sdks/typescript/packages/proto/package.json @@ -1,7 +1,8 @@ { "name": "@ag-ui/proto", "author": "Markus Ecker ", - "version": "0.0.53", + "version": "0.0.57", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/ag-ui-protocol/ag-ui.git"