diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index bbe03051fd4..ee7ae5d8922 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -17,6 +17,15 @@ # skips tag push + GH Release + Notion notification. name: release / publish +# This workflow handles two independent release lanes: +# +# 1. npm (TypeScript) — fires on merged release/publish/* PRs or manual dispatch. +# Build → publish via nx release + OIDC trusted publishers. +# +# 2. PyPI (Python SDK) — fires on any merged PR that bumps sdk-python/pyproject.toml. +# Detects version change vs PyPI registry, builds with poetry, publishes with uv. +# Ported from ag-ui's publish-release.yml Python lane. + on: pull_request: types: [closed] @@ -48,6 +57,11 @@ on: required: false default: false type: boolean + python_publish: + description: "Run the Python publish lane regardless of which files changed. Still no-ops if sdk-python's version already matches PyPI." + required: false + default: false + type: boolean permissions: contents: read @@ -371,3 +385,238 @@ jobs: echo "**Mode:** ${{ steps.meta.outputs.mode }}" echo "- Publish step was skipped; no npm publish, no git tag, no GitHub Release." } >> "$GITHUB_STEP_SUMMARY" + + # =========================================================================== + # Python SDK publish lane + # + # Fires independently of the npm lane. Detects whether sdk-python/pyproject.toml + # has a version newer than what's on PyPI, builds with poetry, publishes with uv. + # + # SECURITY: Same build/publish separation as the npm lane — PYPI_API_TOKEN is + # only available in the publish-python job, never where poetry install runs. + # =========================================================================== + + build-python: + # Fires when: + # 1. A PR merging to main touched sdk-python/pyproject.toml (version bump), OR + # 2. Manual dispatch with python_publish=true + if: > + (github.event_name == 'workflow_dispatch' && inputs.python_publish == true) || + (github.event_name == 'pull_request' && github.event.pull_request.merged == true) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + outputs: + should_publish: ${{ steps.detect.outputs.should_publish }} + version: ${{ steps.detect.outputs.version }} + name: ${{ steps.detect.outputs.name }} + steps: + - name: Checkout merged main + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + ref: main + persist-credentials: false + + # For PRs, skip early if this PR didn't touch pyproject.toml. Manual + # dispatch always continues (the user explicitly asked for it). + - name: Check if pyproject.toml changed in this PR + if: github.event_name == 'pull_request' + id: changed + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + set -euo pipefail + if [ -z "$PR_BASE_SHA" ]; then + echo "::error::PR_BASE_SHA is empty — cannot determine PR base for diff. Refusing to silently skip Python publish." + exit 1 + fi + if [ -z "$PR_HEAD_SHA" ]; then + echo "::error::PR_HEAD_SHA (merge_commit_sha) is empty — GitHub may not have computed the merge commit yet. Refusing to silently skip Python publish; rerun the workflow." + exit 1 + fi + # Capture diff FIRST so a git failure trips set -e and fails loudly, + # rather than producing an empty pipe that grep silently routes to + # "not changed" — that path masked real version bumps before. + CHANGED="$(git diff --name-only "$PR_BASE_SHA" "$PR_HEAD_SHA")" + # grep -q exits 1 on legitimate no-match; guard with `if` so set -e + # doesn't kill the step on that expected case. + if printf '%s\n' "$CHANGED" | grep -q '^sdk-python/pyproject.toml$'; then + echo "pyproject_changed=true" >> "$GITHUB_OUTPUT" + else + echo "pyproject_changed=false" >> "$GITHUB_OUTPUT" + echo "sdk-python/pyproject.toml not changed in this PR — skipping Python publish" + fi + + - name: Set up Python + if: github.event_name == 'workflow_dispatch' || steps.changed.outputs.pyproject_changed == 'true' + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Detect version change + if: github.event_name == 'workflow_dispatch' || steps.changed.outputs.pyproject_changed == 'true' + id: detect + run: ./scripts/release/detect-py-version-changes.sh + + - name: Install Poetry + if: steps.detect.outputs.should_publish == 'true' + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Build Python package + if: steps.detect.outputs.should_publish == 'true' + working-directory: sdk-python + run: poetry build + + - name: Upload Python build artifacts + if: steps.detect.outputs.should_publish == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: py-build-artifacts + path: sdk-python/dist/ + retention-days: 1 + + - name: Nothing to publish + if: steps.detect.outputs.should_publish != 'true' + run: | + { + echo "## Python SDK" + echo "" + echo "No version change detected — nothing to publish." + } >> "$GITHUB_STEP_SUMMARY" + + # WARNING: PyPI trusted-publisher binding pins to: + # repository: CopilotKit/CopilotKit + # workflow_file: publish-release.yml + # environment: pypi + # Renaming this file, changing this job's `environment:` value, or moving the + # publish step into another workflow breaks PyPI publishing with HTTP 422 + # until the Trusted Publisher record on pypi.org is updated to match. + publish-python: + needs: build-python + if: ${{ !cancelled() && needs.build-python.result == 'success' && needs.build-python.outputs.should_publish == 'true' && inputs.dry-run != true }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: pypi + permissions: + contents: write + id-token: write + steps: + - name: Checkout merged main + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + ref: main + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: ">=0.8.0" + + - name: Download Python build artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: py-build-artifacts + path: sdk-python/dist + + - name: Publish to PyPI (OIDC trusted publishing) + run: | + set -euo pipefail + shopt -s nullglob + files=(sdk-python/dist/*) + if [ ${#files[@]} -eq 0 ]; then + echo "::error::no build artifacts in sdk-python/dist — nothing to publish" + exit 1 + fi + uv publish --trusted-publishing always "${files[@]}" + + - name: Verify version is live on PyPI + env: + NAME: ${{ needs.build-python.outputs.name }} + VERSION: ${{ needs.build-python.outputs.version }} + run: | + set -euo pipefail + for i in $(seq 1 18); do + if curl -fsS "https://pypi.org/pypi/${NAME}/${VERSION}/json" >/dev/null 2>&1; then + echo "Confirmed ${NAME}==${VERSION} on PyPI"; exit 0 + fi + echo "Attempt ${i}: ${NAME}==${VERSION} not visible yet; retrying in 10s..." + sleep 10 + done + echo "::error::${NAME}==${VERSION} did not appear on PyPI within 180s" + echo "Last curl response:" + curl -sS "https://pypi.org/pypi/${NAME}/${VERSION}/json" 2>&1 | tail -n 5 || true + exit 1 + + - name: Configure git + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git config --local url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/" + + - name: Create and push git tag + id: tag + env: + PY_VERSION: ${{ needs.build-python.outputs.version }} + PY_NAME: ${{ needs.build-python.outputs.name }} + run: | + set -euo pipefail + TAG="python-sdk/v${PY_VERSION}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists — skipping" + else + git tag -a "$TAG" -m "Release ${PY_NAME} ${PY_VERSION}" + git push origin "$TAG" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + RELEASE_VERSION: ${{ needs.build-python.outputs.version }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const tag = process.env.RELEASE_TAG; + const version = process.env.RELEASE_VERSION; + const name = `python-sdk/v${version}`; + const body = `Python SDK release: copilotkit ${version}\n\nhttps://pypi.org/project/copilotkit/${version}/`; + + try { + const existing = await github.rest.repos.getReleaseByTag({ owner, repo, tag }); + await github.rest.repos.updateRelease({ + owner, repo, + release_id: existing.data.id, + tag_name: tag, name, body, + draft: false, prerelease: false, + }); + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.repos.createRelease({ + owner, repo, + tag_name: tag, name, body, + draft: false, prerelease: false, + }); + } + + - name: Release summary + env: + PY_VERSION: ${{ needs.build-python.outputs.version }} + run: | + { + echo "## Python SDK Published" + echo "" + echo "- \`copilotkit@${PY_VERSION}\`" + echo "- https://pypi.org/project/copilotkit/${PY_VERSION}/" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/packages/shared/src/utils/errors.ts b/packages/shared/src/utils/errors.ts index 43e3c04752d..2673918590c 100644 --- a/packages/shared/src/utils/errors.ts +++ b/packages/shared/src/utils/errors.ts @@ -63,37 +63,37 @@ const getSeeMoreMarkdown = (link: string) => `See more: [${link}](${link})`; export const ERROR_CONFIG = { [CopilotKitErrorCode.NETWORK_ERROR]: { statusCode: 503, - troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#i-am-getting-a-network-errors--api-not-found`, + troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#network-errors-api-not-found`, visibility: ErrorVisibility.BANNER, severity: Severity.CRITICAL, }, [CopilotKitErrorCode.NOT_FOUND]: { statusCode: 404, - troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#i-am-getting-a-network-errors--api-not-found`, + troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#network-errors-api-not-found`, visibility: ErrorVisibility.BANNER, severity: Severity.CRITICAL, }, [CopilotKitErrorCode.AGENT_NOT_FOUND]: { statusCode: 500, - troubleshootingUrl: `${BASE_URL}/coagents/troubleshooting/common-issues#i-am-getting-agent-not-found-error`, + troubleshootingUrl: `${BASE_URL}/langgraph-python/coagent-troubleshooting/common-coagent-issues#i-am-getting-agent-not-found-error`, visibility: ErrorVisibility.BANNER, severity: Severity.CRITICAL, }, [CopilotKitErrorCode.API_NOT_FOUND]: { statusCode: 404, - troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#i-am-getting-a-network-errors--api-not-found`, + troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#network-errors-api-not-found`, visibility: ErrorVisibility.BANNER, severity: Severity.CRITICAL, }, [CopilotKitErrorCode.REMOTE_ENDPOINT_NOT_FOUND]: { statusCode: 404, - troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#i-am-getting-copilotkits-remote-endpoint-not-found-error`, + troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#remote-endpoint-not-found-error`, visibility: ErrorVisibility.BANNER, severity: Severity.CRITICAL, }, [CopilotKitErrorCode.AUTHENTICATION_ERROR]: { statusCode: 401, - troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues#authentication-errors`, + troubleshootingUrl: `${BASE_URL}/troubleshooting/common-issues`, visibility: ErrorVisibility.BANNER, severity: Severity.CRITICAL, }, diff --git a/scripts/release/__tests__/detect-py-version-changes.test.sh b/scripts/release/__tests__/detect-py-version-changes.test.sh new file mode 100755 index 00000000000..5ad9d6b62b8 --- /dev/null +++ b/scripts/release/__tests__/detect-py-version-changes.test.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Red-green test for detect-py-version-changes.sh using a local fixture HTTP +# server. No network access. Requires python3 >= 3.11 (tomllib). +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="${HERE}/../detect-py-version-changes.sh" +TMP="$(mktemp -d)" +SRV_PID="" +STDERR_LOG="$TMP/stderr.log" +SRV_LOG="$TMP/server.log" +cleanup() { [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null || true; rm -rf "$TMP"; } +trap cleanup EXIT INT TERM + +# Preflight: tomllib requires py3.11+ +python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)' \ + || { echo "SKIP/FAIL: need python3 >= 3.11 for tomllib"; exit 1; } + +# Fake pyproject with local version 0.2.0 +mkdir -p "$TMP/pkg" +cat > "$TMP/pkg/pyproject.toml" <<'TOML' +[tool.poetry] +name = "copilotkit" +version = "0.2.0" +TOML + +PORTFILE="$TMP/port" +WWW="$TMP/www" + +# Start a fixture server that serves $WWW and writes its bound port to PORTFILE. +# FAIL_500_PATH (optional): when set, requests with that exact path return HTTP 500 +# instead of the default SimpleHTTPRequestHandler behavior. Used by Case I (5xx). +start_server() { + rm -f "$PORTFILE" "$SRV_LOG" + PORTFILE="$PORTFILE" WWW="$WWW" FAIL_500_PATH="${FAIL_500_PATH:-}" python3 - 2>"$SRV_LOG" <<'PY' & +import os, http.server, socketserver, functools +www = os.environ["WWW"] +os.makedirs(www, exist_ok=True) +fail_500_path = os.environ.get("FAIL_500_PATH", "") + +class H(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=www, **kw) + def do_GET(self): + if fail_500_path and self.path == fail_500_path: + self.send_error(500, "fixture: forced 500") + return + super().do_GET() + +httpd = socketserver.TCPServer(("127.0.0.1", 0), H) +with open(os.environ["PORTFILE"], "w") as f: + f.write(str(httpd.server_address[1])) +httpd.serve_forever() +PY + SRV_PID=$! + for _ in $(seq 1 50); do [ -s "$PORTFILE" ] && break; sleep 0.1; done + # Liveness check: even if PORTFILE never landed, surface the server's stderr + # so a crashed fixture python process produces a real error instead of a + # vague timeout. + if ! kill -0 "$SRV_PID" 2>/dev/null; then + echo "FAIL: fixture server process died before binding" >&2 + if [ -s "$SRV_LOG" ]; then + echo "--- captured server stderr ---" >&2 + cat "$SRV_LOG" >&2 + echo "--- end server stderr ---" >&2 + fi + exit 1 + fi + if [ ! -s "$PORTFILE" ]; then + echo "FAIL: fixture server failed to bind/write PORTFILE within 5s" >&2 + if [ -s "$SRV_LOG" ]; then + echo "--- captured server stderr ---" >&2 + cat "$SRV_LOG" >&2 + echo "--- end server stderr ---" >&2 + fi + exit 1 + fi + PORT="$(cat "$PORTFILE")" +} +stop_server() { kill "$SRV_PID" 2>/dev/null || true; wait "$SRV_PID" 2>/dev/null || true; SRV_PID=""; unset FAIL_500_PATH; } + +serve_published() { + # Realistic PyPI-shaped response: info.version AND a releases dict (single + # released version). Real PyPI always returns `releases`; the bare-info shape + # was a test-only shortcut that the max-over-releases logic doesn't match. + # File list contains one non-yanked dict so the script's yanked filter (which + # excludes empty/all-yanked file lists) still counts this as a live release. + mkdir -p "$WWW/pypi/copilotkit" + printf '{"info":{"version":"%s"},"releases":{"%s":[{"yanked":false}]}}' "$1" "$1" > "$WWW/pypi/copilotkit/json" +} + +# Serve a fuller PyPI-shaped response: info.version + a releases dict whose keys +# are the version strings. $1 = info.version, remaining args = release keys. +# Each release key gets a single non-yanked file entry so it counts as live. +serve_published_with_releases() { + mkdir -p "$WWW/pypi/copilotkit" + local info="$1"; shift + local rels="" k + for k in "$@"; do + [ -z "$rels" ] && rels="\"$k\":[{\"yanked\":false}]" || rels="$rels,\"$k\":[{\"yanked\":false}]" + done + printf '{"info":{"version":"%s"},"releases":{%s}}' "$info" "$rels" > "$WWW/pypi/copilotkit/json" +} + +# Serve a custom raw JSON body at /pypi/copilotkit/json. Used by Cases G and H +# to inject yanked file lists or omit the `releases` key entirely. +serve_raw_json() { + mkdir -p "$WWW/pypi/copilotkit" + printf '%s' "$1" > "$WWW/pypi/copilotkit/json" +} + +run() { + PYPROJECT_PATH="$TMP/pkg/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \ + "$SCRIPT" 2>"$STDERR_LOG" | tail -n1 +} +# Run and assert non-zero exit. Captures the script's exit status BEFORE the +# pipeline (a `| tail` rhs would mask the lhs exit code). Returns 0 if the +# script failed as expected, non-zero otherwise. PYPROJECT path overridable +# via $1. +run_expect_fail() { + local pyproj="${1:-$TMP/pkg/pyproject.toml}" + set +e + PYPROJECT_PATH="$pyproj" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \ + "$SCRIPT" >"$TMP/stdout.log" 2>"$STDERR_LOG" + local ec=$? + set -e + if [ "$ec" -eq 0 ]; then return 1; fi + return 0 +} +fail() { + echo "FAIL: $1" >&2 + if [ -s "$STDERR_LOG" ]; then + echo "--- captured stderr ---" >&2 + cat "$STDERR_LOG" >&2 + echo "--- end stderr ---" >&2 + fi + exit 1 +} + +# Case A: published == local (0.2.0) -> should_publish=false (no-op) +# Also assert GITHUB_OUTPUT emission: the script must append should_publish=, +# name=, and version= lines when GITHUB_OUTPUT is set. +rm -rf "$WWW"; serve_published "0.2.0"; start_server +GHO="$TMP/gho.txt"; : > "$GHO" +OUT="$(GITHUB_OUTPUT="$GHO" PYPROJECT_PATH="$TMP/pkg/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \ + "$SCRIPT" 2>"$STDERR_LOG" | tail -n1)" +echo "A: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "no-op: got '$OUT'" +grep -Fxq 'should_publish=false' "$GHO" || fail "GITHUB_OUTPUT missing should_publish=false (got: $(cat "$GHO"))" +grep -Fxq 'name=copilotkit' "$GHO" || fail "GITHUB_OUTPUT missing name=copilotkit (got: $(cat "$GHO"))" +grep -Fxq 'version=0.2.0' "$GHO" || fail "GITHUB_OUTPUT missing version=0.2.0 (got: $(cat "$GHO"))" +stop_server + +# Case B: published < local (0.1.91 < 0.2.0) -> should_publish=true (exactly one pkg) +rm -rf "$WWW"; serve_published "0.1.91"; start_server +OUT="$(run)"; echo "B: $OUT"; [ "$OUT" = "true copilotkit 0.2.0" ] || fail "bump: got '$OUT'" +stop_server + +# Case C: package missing (404) -> should_publish=true (NEW) +rm -rf "$WWW"; mkdir -p "$WWW"; start_server +OUT="$(run)"; echo "C: $OUT"; [ "$OUT" = "true copilotkit 0.2.0" ] || fail "new-pkg: got '$OUT'" +stop_server + +# Case D: info.version is LOWER than the true max in releases. PyPI's info.version +# is the LATEST-UPLOADED, not the highest — an out-of-order patch upload to an +# old line can produce this state. The script must compute the max over the +# numeric-parseable releases keys, not trust info.version. +# releases = {0.1.0, 0.2.0}, info.version=0.1.0, local=0.2.0 -> 0.2.0==0.2.0 -> false. +# Explicitly (re)write pyproject so this case doesn't implicitly depend on +# Case A's setup persisting through the prior cases. +cat > "$TMP/pkg/pyproject.toml" <<'TOML' +[tool.poetry] +name = "copilotkit" +version = "0.2.0" +TOML +rm -rf "$WWW"; serve_published_with_releases "0.1.0" "0.1.0" "0.2.0"; start_server +OUT="$(run)"; echo "D: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "max-over-releases: got '$OUT'" +stop_server + +# Case E: releases contains a non-numeric prerelease key alongside numeric. The +# script must ignore non-numeric published keys (not abort on them) and compare +# against the numeric max. info.version is the prerelease (rc1); local is 0.2.1. +# numeric max published = 0.2.0 < 0.2.1 -> should_publish=true. +rm -rf "$WWW" +mkdir -p "$TMP/pkg2" +cat > "$TMP/pkg2/pyproject.toml" <<'TOML' +[tool.poetry] +name = "copilotkit" +version = "0.2.1" +TOML +serve_published_with_releases "0.2.1rc1" "0.2.0" "0.2.1rc1"; start_server +OUT="$(PYPROJECT_PATH="$TMP/pkg2/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \ + "$SCRIPT" 2>"$STDERR_LOG" | tail -n1)" +echo "E: $OUT"; [ "$OUT" = "true copilotkit 0.2.1" ] || fail "non-numeric-released-ignored: got '$OUT'" +stop_server + +# Case F (zero-pad): published has only key "0.2" (live); local pyproject "0.2.0". +# PEP 440 treats 0.2 == 0.2.0, so should_publish must be false. Without +# zero-padding the comparison, (0,2,0) > (0,2) would wrongly yield True and +# trigger a duplicate-version uv publish that PyPI rejects with 400. +cat > "$TMP/pkg/pyproject.toml" <<'TOML' +[tool.poetry] +name = "copilotkit" +version = "0.2.0" +TOML +rm -rf "$WWW"; serve_published_with_releases "0.2" "0.2"; start_server +OUT="$(run)"; echo "F: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "zero-pad: got '$OUT'" +stop_server + +# Case G (yanked): releases = {0.2.0:[non-yanked], 0.99.0:[yanked]}, local 0.2.1. +# The fully-yanked 0.99.0 must be excluded when computing the published max so +# a yanked bogus high version can't block legitimate 0.2.x bumps. Live max = +# 0.2.0 < 0.2.1 -> should_publish=true. +cat > "$TMP/pkg/pyproject.toml" <<'TOML' +[tool.poetry] +name = "copilotkit" +version = "0.2.1" +TOML +rm -rf "$WWW" +serve_raw_json '{"info":{"version":"0.2.0"},"releases":{"0.2.0":[{"yanked":false}],"0.99.0":[{"yanked":true}]}}' +start_server +OUT="$(run)"; echo "G: $OUT"; [ "$OUT" = "true copilotkit 0.2.1" ] || fail "yanked-excluded: got '$OUT'" +stop_server + +# Case H (missing-releases fail-loud): HTTP 200 with body missing the +# `releases` key entirely. This is a malformed/unexpected PyPI response and a +# publish gate must NOT silently treat it as "new package" — only a genuine +# 404 means NEW. Script must exit non-zero. +cat > "$TMP/pkg/pyproject.toml" <<'TOML' +[tool.poetry] +name = "copilotkit" +version = "0.2.0" +TOML +rm -rf "$WWW" +serve_raw_json '{"info":{"version":"0.2.0"}}' +start_server +run_expect_fail || fail "missing-releases must fail loud (script exited 0; stdout=$(cat "$TMP/stdout.log" 2>/dev/null))" +echo "H: non-zero exit as expected" +stop_server + +# Case I (5xx coverage): server returns HTTP 500. The script already fails on +# any unexpected non-200/404 status, so this is GREEN immediately — pure +# coverage to lock in the 5xx-fails-loud contract. +cat > "$TMP/pkg/pyproject.toml" <<'TOML' +[tool.poetry] +name = "copilotkit" +version = "0.2.0" +TOML +rm -rf "$WWW"; mkdir -p "$WWW" +FAIL_500_PATH="/pypi/copilotkit/json" start_server +run_expect_fail || fail "5xx must fail loud (script exited 0)" +echo "I: non-zero exit as expected" +stop_server + +echo "ALL PASS" diff --git a/scripts/release/detect-py-version-changes.sh b/scripts/release/detect-py-version-changes.sh new file mode 100755 index 00000000000..9f6865fe65d --- /dev/null +++ b/scripts/release/detect-py-version-changes.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Detect whether sdk-python/pyproject.toml declares a version newer than what's +# published on PyPI. Emits GitHub Actions outputs: should_publish, name, version. +# Also prints " " to stdout for test consumption. +# PYPI_BASE_URL overridable for tests (default https://pypi.org). Requires py3.11+. +set -euo pipefail + +PYPROJECT="${PYPROJECT_PATH:-sdk-python/pyproject.toml}" +PYPI_BASE_URL="${PYPI_BASE_URL:-https://pypi.org}" + +# Parse name + version from pyproject (tomllib, py3.11+). Capture explicitly so a +# parse failure aborts (heredoc-fed `read` would otherwise mask it under set -e). +PYOUT="$(python3 - "$PYPROJECT" <<'PY' +import sys, tomllib +with open(sys.argv[1], "rb") as f: + data = tomllib.load(f) +poetry = data.get("tool", {}).get("poetry", {}) +project = data.get("project", {}) +name = poetry.get("name") or project.get("name") +version = poetry.get("version") or project.get("version") +if not name or not version: + sys.exit("could not read name/version from pyproject") +print(name) +print(version) +PY +)" || { echo "ERROR: failed to parse ${PYPROJECT}" >&2; exit 1; } +NAME="$(printf '%s\n' "$PYOUT" | sed -n 1p)" +VERSION="$(printf '%s\n' "$PYOUT" | sed -n 2p)" +echo "Local: ${NAME}==${VERSION}" >&2 + +# Fetch published version, distinguishing 404 (new package) from other failures. +# Capture curl stderr so transport errors (DNS/TLS/connection refused) surface to +# the operator instead of being erased into a vague "HTTP 000". +RESP="$(mktemp)"; CURL_ERR="$(mktemp)"; trap 'rm -f "$RESP" "$CURL_ERR"' EXIT +CODE="$(curl -sS --max-time 30 --retry 3 --retry-all-errors --retry-connrefused -o "$RESP" -w '%{http_code}' "${PYPI_BASE_URL}/pypi/${NAME}/json" 2>"$CURL_ERR" || echo "000")" +case "$CODE" in + 200) + # Compute the MAX numeric-parseable version from the `releases` dict (the + # complete set of released versions). `info.version` is the LATEST-UPLOADED, + # not necessarily the highest — out-of-order patch uploads to an old line + # can produce info.version < max(releases). Non-numeric keys (prereleases + # like "0.2.0rc1", dev/post tags) are filtered out, not aborted on. + # + # Exclude fully-yanked releases: each release maps to a list of file dicts + # with a "yanked" bool. A version with an empty file list or all files + # yanked is NOT a live release and must be skipped — otherwise a yanked + # bogus high version (e.g. 0.99.0) permanently blocks legitimate bumps. + # + # If a 200 response lacks a "releases" key, FAIL LOUD: that's not a "new + # package" signal (only 404 is), it's malformed/unexpected JSON for a + # publish gate. If no live numeric release exists, treat as NEW. + PUBLISHED="$(python3 - "$RESP" <<'PY' +import sys, json, re +with open(sys.argv[1]) as f: + data = json.load(f) +if not isinstance(data, dict) or "releases" not in data or data.get("releases") is None: + sys.exit("missing or null 'releases' key in PyPI JSON response") +releases = data["releases"] +numeric = [] +for k, files in releases.items(): + if not re.fullmatch(r"\d+(\.\d+)*", k): + continue + # Live iff at least one non-yanked file exists. Empty list -> excluded. + if isinstance(files, list) and any(not f.get("yanked", False) for f in files): + numeric.append(k) +if not numeric: + print("") +else: + best = max(numeric, key=lambda v: tuple(int(x) for x in v.split("."))) + print(best) +PY +)" || { echo "ERROR: bad JSON from PyPI (or missing releases key)" >&2; exit 1; } + if [ -z "$PUBLISHED" ]; then + echo "Published: ${NAME} has no live numeric releases — treating as NEW" >&2 + else + echo "Published: ${NAME}==${PUBLISHED}" >&2 + fi ;; + 404) + PUBLISHED=""; echo "Not found on PyPI — treating as NEW" >&2 ;; + 000) + echo "ERROR: curl transport/connection failure contacting ${PYPI_BASE_URL}/pypi/${NAME}/json (HTTP 000 = no response, not an HTTP status)" >&2 + if [ -s "$CURL_ERR" ]; then + echo "--- curl stderr ---" >&2; cat "$CURL_ERR" >&2; echo "--- end curl stderr ---" >&2 + fi + exit 1 ;; + *) + echo "ERROR: unexpected HTTP ${CODE} from ${PYPI_BASE_URL}/pypi/${NAME}/json" >&2 + if [ -s "$CURL_ERR" ]; then + echo "--- curl stderr ---" >&2; cat "$CURL_ERR" >&2; echo "--- end curl stderr ---" >&2 + fi + exit 1 ;; +esac + +if [ -z "$PUBLISHED" ]; then + SHOULD_PUBLISH="true" +else + # Plain X.Y.Z numeric-tuple comparison (no third-party deps). The stable lane + # only ships dotted-numeric LOCAL versions; refuse non-numeric LOCAL loudly. + # PUBLISHED is already guaranteed numeric (filtered above when computing max). + # Zero-pad the shorter tuple before comparing so "0.2" and "0.2.0" compare + # equal (PEP 440). Without padding, (0,2,0) > (0,2) wrongly yields True and + # triggers a duplicate-version `uv publish` that PyPI rejects with 400. + SHOULD_PUBLISH="$(python3 - "$VERSION" "$PUBLISHED" <<'PY' +import sys, re +def parse_local(v): + if not re.fullmatch(r"\d+(\.\d+)*", v): + sys.exit(f"non-numeric local version not supported on stable lane: {v!r}") + return tuple(int(x) for x in v.split(".")) +local = parse_local(sys.argv[1]) +pub = tuple(int(x) for x in sys.argv[2].split(".")) +n = max(len(local), len(pub)) +local += (0,) * (n - len(local)) +pub += (0,) * (n - len(pub)) +print("true" if local > pub else "false") +PY +)" || exit 1 +fi + +echo "should_publish=${SHOULD_PUBLISH}" >&2 +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { echo "should_publish=${SHOULD_PUBLISH}"; echo "name=${NAME}"; echo "version=${VERSION}"; } >> "$GITHUB_OUTPUT" +fi +echo "${SHOULD_PUBLISH} ${NAME} ${VERSION}" diff --git a/sdk-python/copilotkit/header_propagation.py b/sdk-python/copilotkit/header_propagation.py index 5e1506e2a82..e6b2df9a236 100644 --- a/sdk-python/copilotkit/header_propagation.py +++ b/sdk-python/copilotkit/header_propagation.py @@ -1,59 +1,201 @@ -"""Header propagation for forwarding x-* prefixed headers to outgoing LLM calls. +"""Forward CopilotKit request-context headers onto outbound LLM/provider HTTP calls +so downstream services (e.g. the aimock test server, proxies, request routing / +fixture-matching infrastructure) can correlate the outbound provider call with the +original inbound request. -Uses Python contextvars for per-request ambient state in async FastAPI handlers. -An httpx event hook reads the ContextVar and injects headers on outgoing requests. -Matches the CopilotKit runtime's extractForwardableHeaders() behavior. +What this module does +--------------------- +On each inbound request the application stores a small set of ``x-*`` prefixed +headers (for example ``x-aimock-context``, ``x-aimock-session``, ``x-request-id``, +``x-trace-id``) on a per-request ``contextvars.ContextVar``. When the application +later makes an outbound HTTP call to an LLM provider (OpenAI, Anthropic, or any +client that wraps ``httpx``), an httpx request event hook reads that ContextVar +and copies those same headers onto the outbound request so downstream services +can correlate the two. + +This is plain header propagation, not data collection. Scope and limits: + +* Only headers the application itself set on the request context via + ``set_forwarded_headers`` are forwarded. The module never reads request + bodies, cookies, user data, credentials, or anything off the inbound + request beyond the headers explicitly handed to it. +* Only ``x-*`` prefixed headers pass the filter; ``authorization``, + ``content-type``, and any other non ``x-*`` headers are dropped. +* Nothing is collected, persisted, logged, or sent anywhere by this module + itself — it only attaches headers to an HTTP request that the caller was + already going to make. There is no telemetry, no out-of-band channel, and + no end-user data flow. + +Mechanics +--------- +``install_httpx_hook`` does two small things: + +1. It walks the ``._client`` chain on the given object (modern provider SDKs + wrap their httpx client behind several layers of ``._client``) to find the + first object that exposes an httpx-style ``event_hooks`` mapping. +2. It attaches a request event hook to that mapping. The hook flavor matches + the client: an async coroutine hook for ``httpx.AsyncClient`` (httpx awaits + request hooks on async clients), and a plain sync hook for ``httpx.Client``. + Installation is idempotent via a marker attribute on the installed callable. + +This mirrors the CopilotKit runtime's ``extractForwardableHeaders()`` behavior +on the Node side so the Python SDK forwards the same set of context headers. """ import contextvars import warnings -from typing import Dict +from typing import Any, Dict, Optional -# Ambient per-request state for headers to forward to LLM calls +# Per-request storage for the set of headers the application has asked to forward +# onto outbound LLM/provider calls (populated by ``set_forwarded_headers``). _forwarded_headers: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar( "copilotkit_forwarded_headers" ) +# Marker used to identify hooks we have already installed, so install_httpx_hook +# is idempotent across repeated calls on the same client. +_HOOK_MARKER = "_copilotkit_forwarded_header_hook" + +# Bound on how deep we'll walk a ``._client`` chain looking for event_hooks. +# The modern OpenAI SDK shape is: +# ChatOpenAI.client -> Completions/AsyncCompletions resource +# -> ._client = openai.OpenAI / AsyncOpenAI (no event_hooks) +# -> ._client._client = httpx wrapper (HAS event_hooks) +# 5 hops is plenty of headroom for similar SDKs without risking pathological loops. +_MAX_CHAIN_DEPTH = 5 + def set_forwarded_headers(headers: Dict[str, str]) -> None: - """Store headers to forward to outgoing LLM calls. - Filters to x-* prefixed headers only.""" + """Record the set of headers to forward onto outbound LLM/provider calls + made later in this request context. + + Only ``x-*`` prefixed headers are kept; everything else is dropped. + """ filtered = {k.lower(): v for k, v in headers.items() if k.lower().startswith("x-")} _forwarded_headers.set(filtered) def get_forwarded_headers() -> Dict[str, str]: - """Get headers that should be forwarded to outgoing LLM calls.""" + """Return the headers the application has asked to forward onto outbound + LLM/provider calls in the current request context.""" return _forwarded_headers.get({}) -def install_httpx_hook(client) -> None: - """Append an event hook to an httpx client that injects forwarded headers. +def _find_event_hooks_target(client: Any) -> Optional[Any]: + """Walk the ``._client`` chain looking for the first object that exposes + an httpx-style ``event_hooks`` mapping. + + Returns the target object, or ``None`` if no such object is found within + ``_MAX_CHAIN_DEPTH`` hops. + """ + current = client + for _ in range(_MAX_CHAIN_DEPTH + 1): + if current is None: + return None + if hasattr(current, "event_hooks"): + return current + nxt = getattr(current, "_client", None) + if nxt is current or nxt is None: + return None + current = nxt + return None + + +def install_httpx_hook(client: Any) -> None: + """Attach a request event hook to ``client``'s underlying httpx client so + that headers recorded via ``set_forwarded_headers`` are copied onto + outbound requests. + + Works with OpenAI and Anthropic Python SDKs (both wrap httpx internally, + sometimes via several layers of ``._client`` indirection), as well as raw + ``httpx.Client`` / ``httpx.AsyncClient`` instances. - Works with OpenAI and Anthropic Python SDKs (both use httpx internally). - No-op when no headers are set (demo traffic). + For ``httpx.AsyncClient`` an async hook is attached (httpx awaits request + hooks on async clients); for sync clients a sync hook is attached. + + Idempotent: a marker attribute on the installed callable prevents double + installation on the same target. Parameters ---------- client : object An OpenAI/Anthropic client instance, or a raw httpx.Client/AsyncClient. - For SDK clients the underlying transport lives at ``client._client``. """ + target = _find_event_hooks_target(client) - def _inject_headers(request): - headers = get_forwarded_headers() - for key, value in headers.items(): - request.headers[key] = value - - # OpenAI / Anthropic SDKs wrap an httpx client at client._client - if hasattr(client, "_client") and hasattr(client._client, "event_hooks"): - client._client.event_hooks["request"].append(_inject_headers) - elif hasattr(client, "event_hooks"): - # Raw httpx.Client / httpx.AsyncClient - client.event_hooks["request"].append(_inject_headers) - else: + if target is None: warnings.warn( f"install_httpx_hook: client of type {type(client).__name__} has no " "recognized event_hooks attribute; x-* headers will not be forwarded", stacklevel=2, ) + return + + request_hooks = target.event_hooks.get("request", []) + + # Idempotency: don't double-install on the same target. + for existing in request_hooks: + if getattr(existing, _HOOK_MARKER, False): + return + + # Choose sync vs async hook flavor based on the target class. + # httpx.AsyncClient awaits request hooks; a sync hook returning None would + # raise "TypeError: object NoneType can't be used in 'await' expression", + # which surfaces as APIConnectionError to the caller. + is_async = _is_async_httpx_target(target) + + if is_async: + + async def _inject_headers_async(request): + headers = get_forwarded_headers() + for key, value in headers.items(): + request.headers[key] = value + + setattr(_inject_headers_async, _HOOK_MARKER, True) + request_hooks.append(_inject_headers_async) + else: + + def _inject_headers(request): + headers = get_forwarded_headers() + for key, value in headers.items(): + request.headers[key] = value + + setattr(_inject_headers, _HOOK_MARKER, True) + request_hooks.append(_inject_headers) + + # In case ``event_hooks`` returned a fresh list (defensive), make sure the + # mutation is reflected on the target. + target.event_hooks["request"] = request_hooks + + +def _is_async_httpx_target(target: Any) -> bool: + """Best-effort detection: is this object an httpx async client? + + Tries ``isinstance`` against the real ``httpx.AsyncClient`` / ``httpx.Client`` + first (the authoritative answer for real clients). If httpx is not + importable, or the target is neither of those (e.g. a wrapped or + duck-typed client used in tests), falls back to an EXACT MRO class-name + match against ``"AsyncClient"``. Avoids a broad ``startswith("Async")`` + check, which would misclassify a sync client whose MRO happens to + include an ``Async*``-named base (e.g. ``AsyncContextManager``) as + async — attaching an async hook that httpx calls synchronously would + leave the coroutine unawaited and the forwarded headers would not be + attached to the outbound request. + """ + try: + import httpx # local import keeps httpx an optional concern at import time + + if isinstance(target, httpx.AsyncClient): + return True + if isinstance(target, httpx.Client): + return False + except ( + ImportError + ): # pragma: no cover - httpx should always be importable in practice + pass + + # Fall back to exact class-name match for wrapped/duck-typed clients. + for cls in type(target).__mro__: + if cls.__name__ == "AsyncClient": + return True + return False diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index 0ceefe44d64..62dc7cdd22c 100644 --- a/sdk-python/pyproject.toml +++ b/sdk-python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "copilotkit" -version = "0.1.91" +version = "0.1.92" description = "CopilotKit python SDK" authors = ["Markus Ecker "] license = "MIT" diff --git a/sdk-python/tests/test_header_propagation.py b/sdk-python/tests/test_header_propagation.py index 187084a4b59..2f2ba154fa1 100644 --- a/sdk-python/tests/test_header_propagation.py +++ b/sdk-python/tests/test_header_propagation.py @@ -1,8 +1,11 @@ """Tests for header propagation (x-* prefixed headers to outgoing LLM calls).""" +import asyncio import contextvars +import inspect import warnings +import httpx import pytest from copilotkit.header_propagation import ( @@ -173,6 +176,250 @@ def test_no_event_hooks_warns(self): assert "event_hooks" in str(w[0].message) +class TestInstallHttpxHookNestedChain: + """install_httpx_hook walks the ``._client`` chain (modern OpenAI SDK shape).""" + + def test_walks_chain_to_find_event_hooks(self): + """Modern OpenAI SDK: ChatOpenAI.client -> Resource -> openai.OpenAI + -> ._client = httpx wrapper (event_hooks here). The hook installer + must walk past intermediate ``._client`` hops that do NOT expose + event_hooks, and attach to the first one that does. + """ + + class HttpxWrapper: + def __init__(self): + self.event_hooks = {"request": []} + + class OpenAIClient: + """Mirrors openai.OpenAI / openai.AsyncOpenAI: holds an httpx + wrapper at ``._client`` but exposes no event_hooks itself.""" + + def __init__(self): + self._client = HttpxWrapper() + + class Resource: + """Mirrors openai resources (Completions, AsyncCompletions, etc): + holds the OpenAI client at ``._client``.""" + + def __init__(self): + self._client = OpenAIClient() + + class LangChainWrapper: + """Mirrors langchain_openai.ChatOpenAI.client: the resource.""" + + def __init__(self): + self.client = Resource() + + outer = LangChainWrapper() + install_httpx_hook(outer.client) + + # The hook MUST land on the deepest object that has event_hooks + hooks = outer.client._client._client.event_hooks["request"] + assert len(hooks) == 1, ( + f"expected hook installed on deepest httpx wrapper, got " + f"{len(hooks)} hook(s) (chain walk likely stopped too shallow)" + ) + + def test_idempotent_double_install(self): + """Calling install_httpx_hook twice must NOT register the hook twice.""" + + class FakeClient: + def __init__(self): + self.event_hooks = {"request": []} + + client = FakeClient() + install_httpx_hook(client) + install_httpx_hook(client) + assert len(client.event_hooks["request"]) == 1, ( + "install_httpx_hook must be idempotent — double install detected" + ) + + def test_header_agnostic_injection(self): + """Hook must forward whatever headers are in the ContextVar, not just x-aimock-*.""" + + class FakeRequest: + def __init__(self): + self.headers = {} + + class FakeClient: + def __init__(self): + self.event_hooks = {"request": []} + + client = FakeClient() + install_httpx_hook(client) + + set_forwarded_headers( + { + "x-trace-id": "abc", + "x-team-id": "ck", + "x-anything-custom": "v", + } + ) + + request = FakeRequest() + client.event_hooks["request"][0](request) + + assert request.headers["x-trace-id"] == "abc" + assert request.headers["x-team-id"] == "ck" + assert request.headers["x-anything-custom"] == "v" + + +class TestInstallHttpxHookAsync: + """For httpx.AsyncClient instances the installed hook MUST be an + async callable; httpx awaits async-client request hooks.""" + + def test_async_client_gets_async_hook(self): + """httpx.AsyncClient -> the installed hook is a coroutine function.""" + + async def _run(): + client = httpx.AsyncClient() + try: + install_httpx_hook(client) + hooks = client.event_hooks["request"] + assert len(hooks) == 1 + hook = hooks[0] + assert inspect.iscoroutinefunction(hook), ( + f"AsyncClient must receive an async hook, got sync " + f"callable {hook!r}" + ) + finally: + await client.aclose() + + asyncio.run(_run()) + + def test_async_hook_is_awaitable_and_injects_headers(self): + """Awaiting the installed async hook must inject forwarded headers.""" + + class FakeRequest: + def __init__(self): + self.headers = {} + + async def _run(): + client = httpx.AsyncClient() + try: + install_httpx_hook(client) + set_forwarded_headers({"x-aimock-strict": "true"}) + hook = client.event_hooks["request"][0] + request = FakeRequest() + # Must be awaitable without TypeError + result = hook(request) + assert inspect.isawaitable(result), ( + "async-client hook must return an awaitable" + ) + await result + assert request.headers["x-aimock-strict"] == "true" + finally: + await client.aclose() + + asyncio.run(_run()) + + def test_sync_client_gets_sync_hook(self): + """httpx.Client -> the installed hook is a plain sync callable + (httpx calls request hooks synchronously on a sync client).""" + client = httpx.Client() + try: + install_httpx_hook(client) + hooks = client.event_hooks["request"] + assert len(hooks) == 1 + hook = hooks[0] + assert not inspect.iscoroutinefunction(hook), ( + f"sync Client must receive a sync hook, got coroutine function {hook!r}" + ) + finally: + client.close() + + +class TestInstallHttpxHookRegressions: + """Regression tests for chain-depth, async/sync MRO heuristic, and + foreign-hook preservation.""" + + def test_chain_depth_exhausted_warns(self): + """A ``._client`` chain LONGER than the walker's max depth where NO + node exposes event_hooks must emit a warning (loud-via-warning) and + not silently no-op.""" + + class ChainNode: + def __init__(self): + self._client: object | None = None # filled in below + + # Build a chain of depth well beyond _MAX_CHAIN_DEPTH (5 hops). + # 10 nodes total, none of which carries event_hooks. + nodes = [ChainNode() for _ in range(10)] + for i in range(len(nodes) - 1): + nodes[i]._client = nodes[i + 1] + # Terminate the chain at the last node with a non-None, non-event_hooks + # object so the walker doesn't short-circuit on None. + nodes[-1]._client = object() + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + install_httpx_hook(nodes[0]) + assert len(w) >= 1 + assert any("event_hooks" in str(item.message) for item in w), ( + f"expected a warning mentioning event_hooks, got {[str(i.message) for i in w]}" + ) + + def test_sync_client_with_async_named_mro_base_is_sync(self): + """A SYNC duck-typed client whose MRO includes a base class named + ``Async*`` (but whose own class name is neither ``AsyncClient`` nor + starts with ``Async``) must be classified as SYNC. An overbroad + ``startswith("Async")`` MRO heuristic would misclassify it as async + and install an async hook that httpx calls synchronously -> the + coroutine is never awaited and headers are silently dropped.""" + + class AsyncMixin: + """A base class whose NAME starts with ``Async`` but which does + not represent an async client (mirrors e.g. AsyncContextManager + appearing in MRO).""" + + pass + + class FakeSyncClient(AsyncMixin): + def __init__(self): + self.event_hooks = {"request": []} + + client = FakeSyncClient() + install_httpx_hook(client) + hooks = client.event_hooks["request"] + assert len(hooks) == 1 + hook = hooks[0] + assert not inspect.iscoroutinefunction(hook), ( + f"sync duck-typed client (MRO contains Async*-named base) must " + f"receive a sync hook, got coroutine function {hook!r}" + ) + + def test_preexisting_foreign_hook_is_preserved(self): + """If event_hooks['request'] already contains an unrelated callable + (not carrying our idempotency marker), install_httpx_hook must + APPEND ours alongside it — not skip installation, not replace the + foreign hook.""" + + def foreign_hook(request): + # Unrelated pre-existing hook; carries no marker. + return None + + class FakeClient: + def __init__(self): + self.event_hooks = {"request": [foreign_hook]} + + client = FakeClient() + install_httpx_hook(client) + + hooks = client.event_hooks["request"] + assert len(hooks) == 2, ( + f"expected foreign hook + ours = 2 hooks, got {len(hooks)}: {hooks!r}" + ) + # Foreign hook still present and unchanged. + assert foreign_hook in hooks + # Exactly one of the two hooks is ours (carries the marker). + from copilotkit.header_propagation import _HOOK_MARKER + + marked = [h for h in hooks if getattr(h, _HOOK_MARKER, False)] + assert len(marked) == 1, ( + f"expected exactly one hook to carry our marker, got {len(marked)}" + ) + + class TestContextVarIsolation: """ContextVar provides proper isolation across contexts.""" diff --git a/sdk-python/uv.lock b/sdk-python/uv.lock index 02b888f148e..7e3fb6c8f56 100644 --- a/sdk-python/uv.lock +++ b/sdk-python/uv.lock @@ -5,7 +5,10 @@ requires-python = ">=3.11" [manifest] [manifest.dependency-groups] -dev = [{ name = "pytest", specifier = ">=9.0.2,<10.0.0" }] +dev = [ + { name = "pytest", specifier = ">=9.0.2,<10.0.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0,<2.0.0" }, +] [[package]] name = "colorama" @@ -67,3 +70,25 @@ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc 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 = "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 = "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" }, +] diff --git a/showcase/integrations/langgraph-fastapi/requirements.txt b/showcase/integrations/langgraph-fastapi/requirements.txt index bea0bd57ea6..b155f93394e 100644 --- a/showcase/integrations/langgraph-fastapi/requirements.txt +++ b/showcase/integrations/langgraph-fastapi/requirements.txt @@ -1,4 +1,4 @@ -copilotkit==0.1.91 +copilotkit==0.1.92 langchain==1.2.15 langchain-openai==1.1.9 langchain-anthropic==1.4.1 diff --git a/showcase/integrations/langgraph-python/requirements.txt b/showcase/integrations/langgraph-python/requirements.txt index 49c2538eafd..ad78486a6ea 100644 --- a/showcase/integrations/langgraph-python/requirements.txt +++ b/showcase/integrations/langgraph-python/requirements.txt @@ -1,4 +1,4 @@ -copilotkit==0.1.91 +copilotkit==0.1.92 langchain==1.2.15 langchain-openai==1.1.9 langchain-anthropic==1.4.1 diff --git a/showcase/integrations/strands/requirements.txt b/showcase/integrations/strands/requirements.txt index 20bdf38a4b7..6579192d165 100644 --- a/showcase/integrations/strands/requirements.txt +++ b/showcase/integrations/strands/requirements.txt @@ -2,7 +2,7 @@ ag-ui-protocol==0.1.18 ag_ui_strands==0.1.8 strands-agents[OpenAI]==1.18.0 strands-agents-tools==0.2.16 -copilotkit==0.1.91 +copilotkit==0.1.92 langchain==1.2.15 langchain-openai==1.1.9 openai==1.109.1 diff --git a/showcase/scripts/fail-baseline.json b/showcase/scripts/fail-baseline.json index e2b44c60387..f761a5b1a5f 100644 --- a/showcase/scripts/fail-baseline.json +++ b/showcase/scripts/fail-baseline.json @@ -1,6 +1,6 @@ { "_comment": "Drift ratchet baseline for showcase_validate.yml. `validatePinsFailCount` holds the last-known FAIL count; `validatePinsFailHash` is a SHA-256 of the sorted, deduplicated `[FAIL] ...` lines from validate-pins.ts. CI compares BOTH: if the count changes, it tells you to ratchet (up rejected, down instructed). If the count is equal but the hash differs, the FAIL *set* has drifted (one item fixed, another regressed) and CI fails with a diff. Never raise the count without an explicit review + sign-off. `baselineDemoCount` is the per-package e2e-spec-count floor (single source of truth consumed by both the workflow and validate-parity.ts). NOTE: validate-parity.ts `BASELINE_DEMO_COUNT` default must match `baselineDemoCount` here; keep them in sync. See .github/workflows/showcase_validate.yml 'Run validate-pins (ratchet)' step.", "validatePinsFailCount": 106, - "validatePinsFailHash": "d340cdebe623177b957b62576821b51cde7f174b6b788040d67cc87e3d20b702", + "validatePinsFailHash": "4355457a222f8011c361da7a848d7361e897ee588ad0b8c6487b851fd0c23b77", "baselineDemoCount": 9 } diff --git a/showcase/shell-docs/src/app/[[...slug]]/page.tsx b/showcase/shell-docs/src/app/[[...slug]]/page.tsx index ed3c9d33b19..af3f87a8981 100644 --- a/showcase/shell-docs/src/app/[[...slug]]/page.tsx +++ b/showcase/shell-docs/src/app/[[...slug]]/page.tsx @@ -8,17 +8,10 @@ import React from "react"; import type { Metadata } from "next"; -import Link from "next/link"; -import { - ArrowRight, - Blocks, - Bot, - Braces, - Code2, - MessageSquare, - Workflow, -} from "lucide-react"; import { DocsLandingNext } from "@/components/docs-landing-next"; +import { HeroCommandCopy } from "@/components/hero-command-copy"; +import { HeroQuickstartDropdown } from "@/components/hero-quickstart-dropdown"; +import { LandingSampleTabs } from "@/components/landing-sample-tabs"; import { ShellDocsLayout } from "@/components/shell-docs-layout"; import { SidebarFrameworkSelector } from "@/components/sidebar-framework-selector"; import { UnscopedDocsPage } from "@/components/unscoped-docs-page"; @@ -28,8 +21,14 @@ import { buildFrameworkOnlyNav, loadDoc, } from "@/lib/docs-render"; +import { compareByDisplayOrder } from "@/lib/framework-order"; import { navTreeToPageTree } from "@/lib/page-tree-bridge"; -import { getDocsFolder, getDocsMode, getIntegration } from "@/lib/registry"; +import { + getDocsFolder, + getDocsMode, + getIntegration, + getIntegrations, +} from "@/lib/registry"; import { buildDocMetadata } from "@/lib/seo-metadata"; // Force dynamic rendering so unknown slugs reliably return HTTP 404 @@ -44,6 +43,7 @@ export const dynamic = "force-dynamic"; // here keeps the sidebar tree on `/` identical to what the user sees // after clicking any Built-in Agent sidebar link. const HOME_DEFAULT_FRAMEWORK = "built-in-agent"; +const CREATE_COMMAND = "npx copilotkit@latest create"; // Per-framework self-canonical: each variant of a doc page declares // itself canonical so search engines index every framework's quickstart @@ -123,159 +123,60 @@ function DocsOverview() { ...pageTree, children: rewriteUrls(pageTree.children), }; - const homeIntegration = getIntegration(HOME_DEFAULT_FRAMEWORK); - const stackItems = [ - { - label: "Frontend", - title: "React UX primitives", - detail: "Chat, generative UI, shared state, and HITL controls.", - icon: , - }, - { - label: "Runtime", - title: "AG-UI transport", - detail: "Event streams, tools, state snapshots, and observability.", - icon: , - }, - { - label: "Agent", - title: "Any backend", - detail: "LangGraph, ADK, Mastra, CrewAI, PydanticAI, and more.", - icon: , - }, - ]; - const primaryCards = [ - { - href: "/built-in-agent/quickstart", - title: "Start building", - body: "Create a working CopilotKit app with the built-in agent.", - icon: , - }, - { - href: "/built-in-agent/generative-ui/tool-rendering", - title: "Render agent UI", - body: "Turn tool calls and state into first-class React components.", - icon: , - }, - { - href: "/reference", - title: "API Reference", - body: "Hooks, components, runtime config, and integration APIs.", - icon: , - }, - ]; + const quickstartOptions = getIntegrations() + .filter((i) => getDocsMode(i.slug) !== "hidden") + .slice() + .sort((a, b) => { + if (a.slug === HOME_DEFAULT_FRAMEWORK) return -1; + if (b.slug === HOME_DEFAULT_FRAMEWORK) return 1; + return compareByDisplayOrder(a.slug, b.slug); + }) + .map((i) => ({ + slug: i.slug, + name: i.slug === HOME_DEFAULT_FRAMEWORK ? "CopilotKit (Default)" : i.name, + logo: i.logo ?? null, + href: `/${i.slug}/quickstart`, + })); return ( }> -
-
-
-
- - Frontend stack for agents -
+
+
+
-

- CopilotKit connects agent backends to real product interfaces. +
+
+ +
+ + Documentation + +
+

+ CopilotKit

-

- Build chat, generative UI, shared state, canvas apps, and - human-in-the-loop workflows on top of LangGraph, Google ADK, - Mastra, PydanticAI, CrewAI, the built-in agent, or any AG-UI - compatible backend. +

+ The frontend stack for agentic user experience. +

+

+ Build production chat, generative UI, shared state, and + human-in-the-loop workflows on any AG-UI compatible backend.

-
- - Start with the built-in agent - - -
- - npx copilotkit@latest create - -
-
-
- -
-
- {stackItems.map((item) => ( -
-
- - {item.label} - - - {item.icon} - -
-
- {item.title} -
-

- {item.detail} -

-
- ))} -
-
- {[ - "LangGraph", - "Google ADK", - "Mastra", - "PydanticAI", - "CrewAI", - "AG-UI", - ].map((label) => ( - - {label} - - ))} +
+ +
-
- {primaryCards.map((card) => ( - -
- - {card.icon} - - -
-
-
- {card.title} -
-
- {card.body} -
-
- - ))} +
+ +
- -
); diff --git a/showcase/shell-docs/src/app/globals.css b/showcase/shell-docs/src/app/globals.css index c44d3910a00..0c78ac0c9b2 100644 --- a/showcase/shell-docs/src/app/globals.css +++ b/showcase/shell-docs/src/app/globals.css @@ -52,7 +52,7 @@ * link in the scroll viewport is governed entirely by the viewport's * top padding (also 1rem) — keeping above/below the picker symmetric. */ .shell-docs-sidebar > div:first-child { - padding: 1rem 1rem 0 1rem !important; + padding: 0 1rem !important; } /* Hide the empty home-link wrapper Fumadocs ships in the desktop * SidebarHeader. Scoped via `:has(> a:only-child:empty)` so it ONLY diff --git a/showcase/shell-docs/src/app/layout.tsx b/showcase/shell-docs/src/app/layout.tsx index d6cc45194c2..5cc15f805af 100644 --- a/showcase/shell-docs/src/app/layout.tsx +++ b/showcase/shell-docs/src/app/layout.tsx @@ -180,7 +180,7 @@ export default function RootLayout({ * (margin: 0 4px; xl: 0 8px 8px 8px). */} -
+
{children}
diff --git a/showcase/shell-docs/src/app/reference/[...slug]/page.tsx b/showcase/shell-docs/src/app/reference/[...slug]/page.tsx index 12379aded0c..af097947204 100644 --- a/showcase/shell-docs/src/app/reference/[...slug]/page.tsx +++ b/showcase/shell-docs/src/app/reference/[...slug]/page.tsx @@ -1,8 +1,10 @@ import type { Metadata } from "next"; +import type React from "react"; import Link from "next/link"; import { notFound } from "next/navigation"; import { MDXRemote } from "next-mdx-remote/rsc"; import matter from "gray-matter"; +import { LinkIcon } from "lucide-react"; import { PropertyReference } from "@/components/property-reference"; import { Callout, @@ -19,14 +21,16 @@ import { DocsDescription, } from "fumadocs-ui/page"; import { ShellDocsLayout } from "@/components/shell-docs-layout"; -import type * as PageTree from "fumadocs-core/page-tree"; +import { ReferenceVersionSelector } from "@/components/reference-version-selector"; import { - REFERENCE_CONTENT_DIR, - loadAllReferenceItems, + REFERENCE_VERSIONS, + buildReferencePageTree, + referenceHref, referenceStaticParams, + referenceVersionHref, + resolveReferencePage, } from "@/lib/reference-items"; import { stripLeadingImports } from "@/lib/docs-render"; -import { safeReadFileSync } from "@/lib/safe-fs"; import { buildDocMetadata } from "@/lib/seo-metadata"; // Self-canonical for /reference/. Reference pages are not @@ -41,11 +45,8 @@ export async function generateMetadata({ params: Promise<{ slug: string[] }>; }): Promise { const { slug } = await params; - const slugPath = slug.join("/"); - const canonicalPath = `/reference/${slugPath}`; - // Read the reference MDX directly to extract frontmatter. Reuse - // safeReadFileSync so a crafted slug can't escape REFERENCE_CONTENT_DIR. - const raw = safeReadFileSync(REFERENCE_CONTENT_DIR, `${slugPath}.mdx`); + const resolved = resolveReferencePage(slug); + const raw = resolved?.raw ?? null; let title: string | undefined; let description: string | undefined; if (raw !== null) { @@ -64,7 +65,9 @@ export async function generateMetadata({ return buildDocMetadata({ title: title ?? slug[slug.length - 1], description, - canonicalPath, + canonicalPath: resolved + ? referenceHref(resolved.version, resolved.pageSlug) + : `/reference/${slug.join("/")}`, }); } @@ -77,6 +80,12 @@ const mdxComponents = { Accordions, Accordion, OpsPlatformCTA, + LinkIcon, + Frame: ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+ ), // Strip unknown imports — MDX import statements become no-ops in next-mdx-remote }; @@ -90,15 +99,12 @@ export default async function ReferenceSlugPage({ params: Promise<{ slug: string[] }>; }) { const { slug } = await params; - const slugPath = slug.join("/"); - // slugPath is user-supplied (URL segments). Route the filesystem read - // through safeReadFileSync so crafted paths like `..%2F..%2Fsecrets` - // can't escape REFERENCE_CONTENT_DIR. - const raw = safeReadFileSync(REFERENCE_CONTENT_DIR, `${slugPath}.mdx`); - if (raw === null) { + const resolved = resolveReferencePage(slug); + if (resolved === null) { notFound(); } + const { version, pageSlug, contentSlug, raw } = resolved; let content = ""; let data: Record = {}; try { @@ -107,7 +113,7 @@ export default async function ReferenceSlugPage({ data = parsed.data; } catch (err) { console.error( - `[reference] Failed to parse frontmatter in ${slugPath}.mdx:`, + `[reference] Failed to parse frontmatter in ${contentSlug}.mdx:`, err, ); notFound(); @@ -115,35 +121,28 @@ export default async function ReferenceSlugPage({ const cleanedContent = stripLeadingImports(content); - const allItems = loadAllReferenceItems(); const title = typeof data.title === "string" && data.title.length > 0 ? data.title : slug[slug.length - 1]; const description = typeof data.description === "string" ? data.description : undefined; - - // Build a Fumadocs PageTree from the reference items, grouped by - // category. Reference's IA is its own (Components / Hooks) — we don't - // share the docs nav tree here. - const pageTree: PageTree.Root = { - name: "Reference", - children: ["Components", "Hooks"].flatMap((cat) => [ - { type: "separator" as const, name: cat }, - ...allItems - .filter((i) => i.category === cat) - .map( - (item): PageTree.Item => ({ - type: "page", - name: item.title, - url: `/reference/${item.slug}`, - }), - ), - ]), - }; + const pageTree = buildReferencePageTree(version); + const versionOptions = REFERENCE_VERSIONS.map((referenceVersion) => ({ + version: referenceVersion, + href: referenceVersionHref(referenceVersion, pageSlug), + })); return ( - + + } + > {" / "} - {slug[0]} + {version} + {pageSlug && ( + <> + {" / "} + {pageSlug.split("/")[0]} + + )}
{title} {description && ( diff --git a/showcase/shell-docs/src/app/reference/page.tsx b/showcase/shell-docs/src/app/reference/page.tsx index 8451be84991..63e0746ce4b 100644 --- a/showcase/shell-docs/src/app/reference/page.tsx +++ b/showcase/shell-docs/src/app/reference/page.tsx @@ -1,60 +1,50 @@ import Link from "next/link"; -import fs from "fs"; -import path from "path"; -import matter from "gray-matter"; import { DocsPage } from "fumadocs-ui/page"; import { ShellDocsLayout } from "@/components/shell-docs-layout"; -import type * as PageTree from "fumadocs-core/page-tree"; +import { ReferenceVersionSelector } from "@/components/reference-version-selector"; import { - REFERENCE_CONTENT_DIR, - loadReferenceItems, - loadAllReferenceItems, + REFERENCE_CATEGORIES, + REFERENCE_VERSIONS, + buildReferencePageTree, + loadReferenceVersionItems, + referenceVersionHref, + readReferenceIndexDescription, } from "@/lib/reference-items"; +import type { ReferenceCategory, ReferenceItem } from "@/lib/reference-items"; -export default function ReferencePage() { - const components = loadReferenceItems("components"); - const hooks = loadReferenceItems("hooks"); - const allItems = loadAllReferenceItems(); +function displayTitle(item: ReferenceItem): string { + if (item.category === "Components") return `<${item.title} />`; + if (item.category === "Hooks") return `${item.title}()`; + return item.title; +} - // Mirror the PageTree built by `/reference/[...slug]` so the sidebar - // chrome is identical between the index and the per-item pages. - const pageTree: PageTree.Root = { - name: "Reference", - children: ["Components", "Hooks"].flatMap((cat) => [ - { type: "separator" as const, name: cat }, - ...allItems - .filter((i) => i.category === cat) - .map( - (item): PageTree.Item => ({ - type: "page", - name: item.title, - url: `/reference/${item.slug}`, - }), - ), - ]), - }; +function categoryItems( + items: ReferenceItem[], + category: ReferenceCategory, +): ReferenceItem[] { + return items.filter((item) => item.category === category); +} - // Also load the index page frontmatter for the intro. Guarded so a - // malformed frontmatter block falls back to a default rather than - // crashing the whole index page. - let intro = "API Reference for the next-generation CopilotKit React API."; - const indexPath = path.join(REFERENCE_CONTENT_DIR, "index.mdx"); - if (fs.existsSync(indexPath)) { - try { - const { data } = matter(fs.readFileSync(indexPath, "utf-8")); - if (typeof data.description === "string" && data.description.length > 0) { - intro = data.description; - } - } catch (err) { - console.error( - `[reference] Failed to parse frontmatter in ${indexPath}:`, - err, - ); - } - } +export default function ReferencePage() { + const activeVersion = "v2"; + const allItems = loadReferenceVersionItems(activeVersion); + const pageTree = buildReferencePageTree(activeVersion); + const intro = readReferenceIndexDescription(activeVersion); + const versionOptions = REFERENCE_VERSIONS.map((version) => ({ + version, + href: referenceVersionHref(version), + })); return ( - + + } + >

{intro}

-
-

- UI Components -

-
- {components.map((item) => ( - -
- {"<"} - {item.title} - {" />"} -
- {item.description && ( -
- {item.description} -
- )} - - ))} -
-
+ {REFERENCE_CATEGORIES.map((category) => { + const items = categoryItems(allItems, category); + if (items.length === 0) return null; -
-

- Hooks -

-
- {hooks.map((item) => ( - -
- {item.title}() -
- {item.description && ( -
- {item.description} -
- )} - - ))} -
-
+ return ( +
+

+ {category === "Components" ? "UI Components" : category} +

+
+ {items.map((item) => ( + +
+ {displayTitle(item)} +
+ {item.description && ( +
+ {item.description} +
+ )} + + ))} +
+
+ ); + })}
diff --git a/showcase/shell-docs/src/components/brand-nav.tsx b/showcase/shell-docs/src/components/brand-nav.tsx index a38c1103f8d..0807fb01ca2 100644 --- a/showcase/shell-docs/src/components/brand-nav.tsx +++ b/showcase/shell-docs/src/components/brand-nav.tsx @@ -77,191 +77,120 @@ export function BrandNav(_props: BrandNavProps = {}) { }; return ( -