From 79bd7060d335103fe440db230b42fce14163d47a Mon Sep 17 00:00:00 2001 From: Juan Pedro Michelini Jorge Date: Thu, 24 Sep 2026 11:43:43 -0300 Subject: [PATCH 1/6] feat(ci): auto-bump enterprise SDK pins via version-bump-prs.yml (#5289) Co-authored-by: openhands --- .github/workflows/version-bump-prs.yml | 96 ++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/.github/workflows/version-bump-prs.yml b/.github/workflows/version-bump-prs.yml index b8795b4192..2d162840d2 100644 --- a/.github/workflows/version-bump-prs.yml +++ b/.github/workflows/version-bump-prs.yml @@ -267,11 +267,102 @@ jobs: echo "✅ PR created for $REPO" fi + - name: Create PR for enterprise repo + id: enterprise_pr + continue-on-error: true + env: + VERSION: ${{ steps.get_version.outputs.version }} + run: | + set -euo pipefail + + REPO="OpenHands/enterprise" + BRANCH="bump-sdk-$VERSION" + + echo "🔄 Creating PR for $REPO..." + + # Clone the repo + git clone "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" enterprise-repo + cd enterprise-repo + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Check if branch already exists on remote + if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then + echo "⚠️ Branch $BRANCH already exists, checking out existing branch" + git fetch origin "$BRANCH" + git checkout "$BRANCH" + else + # Create branch + git checkout -b "$BRANCH" + fi + + # enterprise pins openhands-sdk + openhands-agent-server + openhands-tools as + # exact PEP 621 dependencies and keeps a uv.lock. Update the pins with + # sed (exact, no normalization), then regenerate the lock, keeping the + # project's own [tool.uv] table (exclude-newer + exclude-newer-package) + # intact so the regenerated lock still satisfies `uv sync --locked`in CI. + + echo "📝 Updating pyproject.toml SDK pins..." + sed -i -E 's/"openhands-sdk==[^"]*"/"openhands-sdk=='"$VERSION"'"/' pyproject.toml + sed -i -E 's/"openhands-agent-server==[^"]*"/"openhands-agent-server=='"$VERSION"'"/' pyproject.toml + sed -i -E 's/"openhands-tools==[^"]*"/"openhands-tools=='"$VERSION"'"/' pyproject.toml + + # --no-cache avoids stale index data from the just-published packages. + + echo "📝 Regenerating uv.lock..." + uv lock --no-cache + + # Check if there are changes + if git diff --quiet; then + echo "⚠️ No changes detected in $REPO - versions may already be up to date" + exit 0 + fi + + # Commit and push. Use a Conventional Commit `chore(deps):` subject so the + # target repo's release-please does not cut a release from these bumps + # (the manual bump this replaces, #484, used the same convention). + git add pyproject.toml uv.lock + git commit -m "chore(deps): bump SDK to $VERSION" \ + -m "Bump openhands-sdk, openhands-agent-server, openhands-tools to $VERSION (regenerated uv.lock)." \ + -m "Automated version bump after PyPI release." \ + -m "Co-authored-by: openhands " + git push -u origin "$BRANCH" + + # Check if PR already exists + EXISTING_PR=$(gh pr list --repo "$REPO" --head "$BRANCH" --json number --jq '.[0].number') + if [ -n "$EXISTING_PR" ]; then + echo "✅ PR #$EXISTING_PR already exists for $REPO" + else + # Create PR with a `chore:` title to match the bump-PR convention. + + gh pr create \ + --repo "$REPO" \ + --title "chore: bump SDK to $VERSION" \ + --body "## Automated Version Bump + + This PR updates the following packages to version **$VERSION**: + - \`openhands-sdk\` + - \`openhands-agent-server\` + - \`openhands-tools\` + + **Triggered by:** Release of [software-agent-sdk v$VERSION](https://github.com/OpenHands/software-agent-sdk/releases/tag/v$VERSION) + + --- + _This PR was automatically created by the version-bump-prs workflow._" \ + --base main \ + --head "$BRANCH" + + echo "✅ PR created for $REPO" + fi + - name: Summary env: VERSION: ${{ steps.get_version.outputs.version }} CLI_OUTCOME: ${{ steps.cli_pr.outcome }} AUTOMATION_OUTCOME: ${{ steps.automation_pr.outcome }} + ENTERPRISE_OUTCOME: ${{ steps.enterprise_pr.outcome }} run: | echo "## Version Bump PRs" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -291,6 +382,11 @@ jobs: else echo "- [automation](https://github.com/OpenHands/automation/pulls?q=is%3Apr+bump-sdk-$VERSION)" >> $GITHUB_STEP_SUMMARY fi + if [ "$ENTERPRISE_OUTCOME" = "failure" ]; then + echo "- ⚠️ **enterprise PR creation FAILED** — see the \"Create PR for enterprise repo\" step logs" >> $GITHUB_STEP_SUMMARY + else + echo "- [enterprise](https://github.com/OpenHands/enterprise/pulls?q=is%3Apr+bump-sdk-$VERSION)" >> $GITHUB_STEP_SUMMARY + fi - name: Notify Slack if: env.SLACK_BOT_TOKEN != '' From a7f8f24e0bb37c6d58fa30d46c5709bc4b6360e9 Mon Sep 17 00:00:00 2001 From: Juan Pedro Michelini Jorge Date: Thu, 24 Sep 2026 13:08:37 -0300 Subject: [PATCH 2/6] feat(ci): auto-bump agent-server chart tag via version-bump-prs.yml (#5283) (#5287) Co-authored-by: openhands --- .github/workflows/version-bump-prs.yml | 132 +++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/.github/workflows/version-bump-prs.yml b/.github/workflows/version-bump-prs.yml index 2d162840d2..51817c9afa 100644 --- a/.github/workflows/version-bump-prs.yml +++ b/.github/workflows/version-bump-prs.yml @@ -549,3 +549,135 @@ jobs: VERSION: ${{ steps.get_version.outputs.version }} run: | echo "TypeScript client bump: https://github.com/${{ github.repository }}/pulls?q=is%3Apr+bump-typescript-agent-server-$VERSION" >> "$GITHUB_STEP_SUMMARY" + + bump-cloud-chart: + # After an SDK release, bump the agent-server image tag pinned in the + # openhands-cloud charts (three locations) so the Check agent-server sync + # gate never trips on a stale pin. Shares the pypi-release dispatch chain + # and image-readiness wait used by bump-typescript-client. + # + # The sync gate compares this agent-server tag against the SDK version the + # chart's pinned enterprise-server release was built against (see + # OpenHands/OpenHands-Cloud scripts/check_agent_server_sync.py). This job + # is only correct because the same version-bump-prs.yml run also opens the + # enterprise SDK-pin bump PR (create-version-bump-prs), so the two pins + # move together and are merged together in the same release cycle. + concurrency: + group: bump-image-tag-OpenHands-OpenHands-Cloud-agent-server + cancel-in-progress: false + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Get version + id: get_version + env: + VERSION_INPUT: ${{ github.event.inputs.version }} + run: | + VERSION="$VERSION_INPUT" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ Invalid version format. Expected: X.Y.Z (e.g., 1.49.5)" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "image_tag=${VERSION}-python" >> "$GITHUB_OUTPUT" + + - name: Wait for agent-server image in GHCR + env: + IMAGE_TAG: ${{ steps.get_version.outputs.image_tag }} + run: | + set -euo pipefail + IMAGE="ghcr.io/openhands/agent-server:${IMAGE_TAG}" + for ATTEMPT in $(seq 1 90); do + if docker manifest inspect "$IMAGE" > /dev/null 2>&1; then + echo "✅ ${IMAGE} is published" + exit 0 + fi + echo "Attempt $ATTEMPT/90: waiting 30s for ${IMAGE}" + sleep 30 + done + echo "❌ Timeout waiting for ${IMAGE}" + exit 1 + + # Auth choice: the sibling jobs open PRs in public repos with the + # PAT, but cloud chart bumps write to the private OpenHands-Cloud + # repo, so this job uses the shared release App (RELEASE_APP_ID / + # RELEASE_APP_PRIVATE_KEY) exactly like the enterprise and + # agent-canvas callers, with deliberately no PAT/GITHUB_TOKEN fallback. + - name: Mint GitHub App token (scoped to OpenHands-Cloud) + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: OpenHands + repositories: OpenHands-Cloud + + - name: Checkout chart repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: OpenHands/OpenHands-Cloud + ref: main + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: latest + python-version: '3.12' + enable-cache: false + + - name: Bump agent-server image tag (three pin locations) + env: + IMAGE_TAG: ${{ steps.get_version.outputs.image_tag }} + run: | + set -euo pipefail + # The chart repo's blessed edit script: path-aware, idempotent, + # preserves quoting/comments/blank lines, verifies after writing. + uv run scripts/bump_image_tag/bump_image_tag.py \ + --file charts/image-loader/values.yaml \ + --path .image.tag \ + --tag "$IMAGE_TAG" + uv run scripts/bump_image_tag/bump_image_tag.py \ + --file charts/openhands/charts/runtime-api/values.yaml \ + --path .global.agentServerImage.tag \ + --tag "$IMAGE_TAG" + uv run scripts/bump_image_tag/bump_image_tag.py \ + --file charts/openhands/values.yaml \ + --path .global.agentServerImage.tag \ + --tag "$IMAGE_TAG" + + - name: Create or update pull request + id: cpr + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + add-paths: | + charts/image-loader/values.yaml + charts/openhands/charts/runtime-api/values.yaml + charts/openhands/values.yaml + base: main + branch: bump-image-tag/agent-server + delete-branch: true + commit-message: 'feat(agent-server): bump image tag to ${{ steps.get_version.outputs.image_tag }}' + title: 'feat(agent-server): bump image tag to ${{ steps.get_version.outputs.image_tag }}' + labels: automated,image-bump + body: | + Automated image tag bump for **agent-server** after the + [software-agent-sdk v${{ steps.get_version.outputs.version }} release](https://github.com/OpenHands/software-agent-sdk/releases/tag/v${{ steps.get_version.outputs.version }}). + + | File | Path | + | --- | --- | + | `charts/image-loader/values.yaml` | `.image.tag` | + | `charts/openhands/charts/runtime-api/values.yaml` | `.global.agentServerImage.tag` | + | `charts/openhands/values.yaml` | `.global.agentServerImage.tag` | + + Opened by the software-agent-sdk `version-bump-prs.yml` workflow. + + - name: Summary + env: + VERSION: ${{ steps.get_version.outputs.version }} + IMAGE_TAG: ${{ steps.get_version.outputs.image_tag }} + URL: ${{ steps.cpr.outputs.pull-request-url }} + run: | + echo "Cloud chart bump (agent-server → ${IMAGE_TAG}): ${URL}" >> "$GITHUB_STEP_SUMMARY" From c78c9b2bfd03788d4f50ea91ff9afd9399b80dab Mon Sep 17 00:00:00 2001 From: Juan Pedro Michelini Jorge Date: Thu, 24 Sep 2026 13:16:04 -0300 Subject: [PATCH 3/6] Add gpt-6-sol, gpt-6-luna, and claude-opus-5-5 to verified models (#5311) Co-authored-by: openhands --- openhands-sdk/openhands/sdk/llm/utils/AGENTS.md | 2 +- .../openhands/sdk/llm/utils/model_features.py | 1 - .../openhands/sdk/llm/utils/verified_models.py | 10 +++++++--- tests/sdk/llm/test_llm_profile_store.py | 8 ++++---- tests/sdk/llm/test_model_list.py | 11 ++++++++--- tests/sdk/test_settings.py | 4 ++-- 6 files changed, 22 insertions(+), 14 deletions(-) diff --git a/openhands-sdk/openhands/sdk/llm/utils/AGENTS.md b/openhands-sdk/openhands/sdk/llm/utils/AGENTS.md index a4c5ef01d3..b592f08c45 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/AGENTS.md +++ b/openhands-sdk/openhands/sdk/llm/utils/AGENTS.md @@ -6,7 +6,7 @@ See the [SDK AGENTS.md](../../AGENTS.md) for package-wide policies. These lists are a curated set of models that work well, not a catalog of everything a provider offers. Keep them short. -- For each model line, keep only the **two latest versions** (for example `gpt-6` and `gpt-5.6`; `claude-opus-5` and `claude-opus-4-8`). +- For each model line, keep only the **two latest versions** (for example `gpt-6` and `gpt-5.6`; `claude-opus-5-5` and `claude-opus-5`). - Variants of a kept version (`-pro`, `-mini`, `-codex`, `-flash`, dated aliases) stay with that version. Unversioned "current" aliases (`deepseek-chat`, `kimi-for-coding`) stay. - When you add a new version, remove the oldest version in the same line, in every list where it appears (the provider list and `VERIFIED_OPENHANDS_MODELS`). - Every entry in `VERIFIED_OPENHANDS_MODELS` must also appear in a provider list, unless it is OpenHands-only; `tests/sdk/llm/test_model_list.py` checks this. diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py index 2c72562c9b..23d7700fcd 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py +++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py @@ -109,7 +109,6 @@ def _normalized_supported_openai_params(model: str | None) -> frozenset[str]: REASONING_EFFORT_MODEL_OVERRIDES = { - "gpt-5.2-codex": "gpt-5.2-codex", "kimi-k3": "moonshot/kimi-k3", } diff --git a/openhands-sdk/openhands/sdk/llm/utils/verified_models.py b/openhands-sdk/openhands/sdk/llm/utils/verified_models.py index 1b60ce07df..d799a3d6b4 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/verified_models.py +++ b/openhands-sdk/openhands/sdk/llm/utils/verified_models.py @@ -11,6 +11,8 @@ # GPT: gpt-6 and gpt-5.6. Codex: gpt-5.3-codex and gpt-5.2-codex. VERIFIED_OPENAI_MODELS = [ + "gpt-6-sol", + "gpt-6-luna", "gpt-6-astra", "gpt-5.6", "gpt-5.6-sol", @@ -20,10 +22,10 @@ "gpt-5.2-codex", ] -# Opus: 5 and 4.8. Sonnet: 5 and 4.6. Haiku: 4.5. Fable: 5.1 and 5. +# Opus: 5.5 and 5. Sonnet: 5 and 4.6. Haiku: 4.5. Fable: 5.1 and 5. VERIFIED_ANTHROPIC_MODELS = [ + "claude-opus-5-5", "claude-opus-5", - "claude-opus-4-8", "claude-sonnet-5", "claude-sonnet-4-6", "claude-haiku-4-5-20251001", @@ -104,12 +106,14 @@ # What the ``openhands/`` provider serves. Same rule; every entry must also be in # a provider list above, except OpenHands-only models. VERIFIED_OPENHANDS_MODELS = [ + "claude-opus-5-5", "claude-opus-5", - "claude-opus-4-8", "claude-sonnet-5", "claude-sonnet-4-6", "claude-fable-5-1", "claude-fable-5", + "gpt-6-sol", + "gpt-6-luna", "gpt-6-astra", "gpt-5.6", "gpt-5.3-codex", diff --git a/tests/sdk/llm/test_llm_profile_store.py b/tests/sdk/llm/test_llm_profile_store.py index 6c429c165a..34028d552c 100644 --- a/tests/sdk/llm/test_llm_profile_store.py +++ b/tests/sdk/llm/test_llm_profile_store.py @@ -154,7 +154,7 @@ def test_load_migrates_legacy_openhands_proxy_profile( json.dumps( { "schema_version": 1, - "model": "litellm_proxy/claude-opus-4-8", + "model": "litellm_proxy/claude-opus-5", "base_url": "https://llm-proxy.app.all-hands.dev/", } ) @@ -162,7 +162,7 @@ def test_load_migrates_legacy_openhands_proxy_profile( loaded = profile_store.load("legacy") - assert loaded.model == "openhands/claude-opus-4-8" + assert loaded.model == "openhands/claude-opus-5" assert loaded.base_url is None @@ -174,7 +174,7 @@ def test_list_summaries_migrates_legacy_openhands_proxy_profile( json.dumps( { "schema_version": 1, - "model": "litellm_proxy/claude-opus-4-8", + "model": "litellm_proxy/claude-opus-5", "base_url": "https://llm-proxy.app.all-hands.dev/", } ) @@ -185,7 +185,7 @@ def test_list_summaries_migrates_legacy_openhands_proxy_profile( assert summaries == [ { "name": "legacy", - "model": "openhands/claude-opus-4-8", + "model": "openhands/claude-opus-5", "base_url": None, "provider_connection_id": None, "provider_connection_broken": False, diff --git a/tests/sdk/llm/test_model_list.py b/tests/sdk/llm/test_model_list.py index e33bcc61f4..a9930813a8 100644 --- a/tests/sdk/llm/test_model_list.py +++ b/tests/sdk/llm/test_model_list.py @@ -157,7 +157,10 @@ def test_verified_lists_keep_two_latest_versions_per_line(): """ expectations = { "openai": ({"gpt-6-astra", "gpt-5.6"}, {"gpt-5.5", "gpt-5.4", "gpt-4o", "o3"}), - "anthropic": ({"claude-opus-5", "claude-opus-4-8"}, {"claude-opus-4-7"}), + "anthropic": ( + {"claude-opus-5-5", "claude-opus-5"}, + {"claude-opus-4-8", "claude-opus-4-7"}, + ), "mistral": ( {"devstral-2512", "devstral-medium-2512"}, {"devstral-medium-2507"}, @@ -175,8 +178,10 @@ def test_verified_lists_keep_two_latest_versions_per_line(): models = set(VERIFIED_MODELS[provider]) assert present <= models, f"{provider}: missing {present - models}" assert not (absent & models), f"{provider}: stale {absent & models}" - assert {"gpt-6-astra", "gpt-5.6", "claude-opus-5"} <= set(VERIFIED_OPENHANDS_MODELS) - assert not {"gpt-5.5", "claude-opus-4-7", "minimax-m2.5"} & set( + assert {"gpt-6-astra", "gpt-5.6", "claude-opus-5-5", "claude-opus-5"} <= set( + VERIFIED_OPENHANDS_MODELS + ) + assert not {"gpt-5.5", "claude-opus-4-8", "claude-opus-4-7", "minimax-m2.5"} & set( VERIFIED_OPENHANDS_MODELS ) diff --git a/tests/sdk/test_settings.py b/tests/sdk/test_settings.py index 2cc42e5040..8e76e34d5d 100644 --- a/tests/sdk/test_settings.py +++ b/tests/sdk/test_settings.py @@ -523,7 +523,7 @@ def test_validate_agent_settings_migrates_legacy_openhands_proxy_llm() -> None: "schema_version": 3, "agent_kind": "openhands", "llm": { - "model": "litellm_proxy/claude-opus-4-8", + "model": "litellm_proxy/claude-opus-5", "base_url": "https://llm-proxy.app.all-hands.dev/", }, } @@ -531,7 +531,7 @@ def test_validate_agent_settings_migrates_legacy_openhands_proxy_llm() -> None: assert isinstance(settings, OpenHandsAgentSettings) assert settings.schema_version == AGENT_SETTINGS_SCHEMA_VERSION - assert settings.llm.model == "openhands/claude-opus-4-8" + assert settings.llm.model == "openhands/claude-opus-5" assert settings.llm.base_url is None From 6a47aca178092aabd665f978a0ea88a0f2167830 Mon Sep 17 00:00:00 2001 From: Juan Pedro Michelini Jorge Date: Thu, 24 Sep 2026 13:41:39 -0300 Subject: [PATCH 4/6] feat(ci): auto-bump OpenHands agent-server version pin via version-bump-prs.yml (#5288) Co-authored-by: openhands --- .github/workflows/version-bump-prs.yml | 247 +++++++++++++++++++++++++ 1 file changed, 247 insertions(+) diff --git a/.github/workflows/version-bump-prs.yml b/.github/workflows/version-bump-prs.yml index 51817c9afa..774b53a995 100644 --- a/.github/workflows/version-bump-prs.yml +++ b/.github/workflows/version-bump-prs.yml @@ -267,6 +267,247 @@ jobs: echo "✅ PR created for $REPO" fi + - name: Create PR for OpenHands repo + id: openhands_pr + continue-on-error: true + env: + VERSION: ${{ steps.get_version.outputs.version }} + run: | + set -euo pipefail + + REPO="OpenHands/OpenHands" + BRANCH="bump-sdk-$VERSION" + + echo "🔄 Creating PR for $REPO..." + + # Clone the repo + git clone "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" openhands-repo + cd openhands-repo + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Check if branch already exists on remote + if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then + echo "⚠️ Branch $BRANCH already exists, checking out existing branch" + git fetch origin "$BRANCH" + git checkout "$BRANCH" + else + # Create branch + git checkout -b "$BRANCH" + fi + + # OpenHands/OpenHands treats versions.agentServer and the + # @openhands/typescript-client pin in package.json as a synchronized + # pair: scripts/check-sdk-version-sync.mjs (run on PRs that touch + # config/defaults.json) exits 1 on any skew, and the + # docs-version-sync / dev-safe tests assert the same version + # literals. So bump the whole synchronized set (versions.agentServer + # + client pin in package.json/package-lock.json + doc/test + # examples), keeping the diff limited to the version literals. + + CURRENT_VERSION=$(python3 -c 'import json; print(json.load(open("config/defaults.json"))["versions"]["agentServer"])') + echo "📦 Bumping $CURRENT_VERSION -> $VERSION ..." + + # Fetch the released client package integrity from the npm registry + # so package-lock.json stays valid for npm ci. + INTEGRITY=$(curl -sS "https://registry.npmjs.org/@openhands/typescript-client/$VERSION" | python3 -c 'import json,sys; print(json.load(sys.stdin)["dist"]["integrity"])') + + python3 - "$VERSION" "$CURRENT_VERSION" "$INTEGRITY" <<'PY' + import re + import sys + + version = sys.argv[1] + current = sys.argv[2] + integrity = sys.argv[3] + + + def sub_file(path, pattern, repl, count=0): + text = open(path).read() + new, n = re.subn(pattern, repl, text, count=count) + if n == 0: + raise SystemExit(f"no match for {pattern!r} in {path}") + open(path, "w").write(new) + print(f"updated {path}: {n} replacement(s)") + + + # config/defaults.json: only versions.agentServer (bare semver) + sub_file( + "config/defaults.json", + r'("agentServer"\s*:\s*")[0-9]+\.[0-9]+\.[0-9]+(")', + lambda m: m.group(1) + version + m.group(2), + count=1, + ) + # package.json: root client pin + sub_file( + "package.json", + r'("@openhands/typescript-client"\s*:\s*")[0-9]+\.[0-9]+\.[0-9]+(")', + lambda m: m.group(1) + version + m.group(2), + count=1, + ) + # package-lock.json: root client pin + sub_file( + "package-lock.json", + r'("@openhands/typescript-client"\s*:\s*")[0-9]+\.[0-9]+\.[0-9]+(")', + lambda m: m.group(1) + version + m.group(2), + count=1, + ) + # package-lock.json: node_modules client entry (version + resolved + integrity) + lock = open("package-lock.json").read() + start = lock.index('"node_modules/@openhands/typescript-client": {') + end = lock.index("\n },", start) + block = lock[start:end] + block = re.sub(r'("version"\s*:\s*")[0-9]+\.[0-9]+\.[0-9]+(")', + lambda m: m.group(1) + version + m.group(2), block, count=1) + block = re.sub(r'("resolved"\s*:\s*"https://registry\.npmjs\.org/@openhands/typescript-client/-/typescript-client-)[0-9]+\.[0-9]+\.[0-9]+(\.tgz")', + lambda m: m.group(1) + version + m.group(2), block, count=1) + block = re.sub(r'("integrity"\s*:\s*")[^"]+(")', + lambda m: m.group(1) + integrity + m.group(2), block, count=1) + open("package-lock.json", "w").write(lock[:start] + block + lock[end:]) + print("updated package-lock.json node_modules client entry") + + # Doc/test files: every current-version literal is a version example the + # docs-version-sync / dev-safe tests assert, so replace them all. + for path in ( + "AGENTS.md", + "scripts/dev-safe.mjs", + "scripts/check-sdk-version-sync.mjs", + "__tests__/scripts/dev-safe.test.ts", + ): + sub_file(path, re.escape(current), version) + PY + + # Verify the edits took + if ! grep -qE '"agentServer"[[:space:]]*:[[:space:]]*"'"$VERSION"'"' config/defaults.json; then + echo "❌ Failed to bump versions.agentServer to $VERSION" + exit 1 + fi + if ! grep -qE '"@openhands/typescript-client"[[:space:]]*:[[:space:]]*"'"$VERSION"'"' package.json;then + echo "❌ Failed to bump @openhands/typescript-client to $VERSION" + exit 1 + fi + # Note:the images.agentServer key also contains "agentServer", + # so the config grep above is scoped to the quoted-scalar form + # ("agentServer": "") used only under versions. + + # On the "branch already exists" re-run path CURRENT_VERSION == $VERSION, + # so only check for leftover stale literals when we actually changed the version. + if [ "$CURRENT_VERSION" != "$VERSION" ] && grep -q "$CURRENT_VERSION" AGENTS.md scripts/dev-safe.mjs scripts/check-sdk-version-sync.mjs __tests__/scripts/dev-safe.test.ts; then + echo "❌ Stale $CURRENT_VERSION remains in doc/test files" + exit 1 + fi + + # Check if there are changes + if git diff --quiet; then + echo "⚠️ No changes detected in $REPO - versions may already be up to date" + exit 0 + fi + + # Generate an evidence card (SVG) under .pr/ — the downstream + # Validate PR description gate requires a screenshot/video when a + # PR touches __tests__/, which this bump necessarily does. + mkdir -p .pr + { + echo '' + echo ' ' + echo ' SDK version bump evidence' + echo ' versions.agentServer: '"$CURRENT_VERSION"' -> '"$VERSION"'' + echo ' @openhands/typescript-client: '"$CURRENT_VERSION"' -> '"$VERSION"'' + echo ' package.json/package-lock.json + doc/test literals synced to '"$VERSION"'' + echo '' + } > .pr/sdk-version-bump.svg + git add .pr/sdk-version-bump.svg + + # Commit and push + git add config/defaults.json package.json package-lock.json + git add AGENTS.md scripts/dev-safe.mjs scripts/check-sdk-version-sync.mjs __tests__/scripts/dev-safe.test.ts + git commit -m "chore: bump agent-server to $VERSION" \ + -m "Updates versions.agentServer in config/defaults.json to match software-agent-sdk v$VERSION, and syncs the @openhands/typescript-client pin (package.json/package-lock.json) plus doc/test version examples so check-sdk-version-sync.mjs stays green." \ + -m "Automated version bump after PyPI release." \ + -m "Co-authored-by: openhands " + git push -u origin "$BRANCH" + + # Check if PR already exists + EXISTING_PR=$(gh pr list --repo "$REPO" --head "$BRANCH" --json number --jq '.[0].number') + if [ -n "$EXISTING_PR" ]; then + echo "✅ PR #$EXISTING_PR already exists for $REPO" + else + # Create PR with a body that passes OpenHands/OpenHands's + # Validate PR description gate (HUMAN:/AGENT: template, + # Why/Summary/How to Test sections, linked ready-for-dev + # issue,and an evidence image because the PR touches __tests__/). + gh pr create \ + --repo "$REPO" \ + --title "chore: bump agent-server to $VERSION" \ + --body "HUMAN: + + Automated SDK version bump after software-agent-sdk v$VERSION release. + + + + --- + + AGENT: + + ## Why + + OpenHands/OpenHands defaults to agent-server / TypeScript client $CURRENT_VERSION. software-agent-sdk v$VERSION is now released, so this PR moves the synchronized pins to keep check-sdk-version-sync.mjs green. + + + + ## Summary + + - Move versions.agentServer in config/defaults.json to $VERSION (bare semver. + - Sync the @openhands/typescript-client pin to $VERSION in package.json and package-lock.json (root deps + node_modules entry with registry integrity), plus doc/test version examples in AGENTS.md, scripts/dev-safe.mjs, scripts/check-sdk-version-sync.mjs,and __tests__/scripts/dev-safe.test.ts. + + + + versions.automation is intentionally not bumped here. The sibling OpenHands/automation bump PR (release-please) opened by this same workflow only bumps that repo's own SDK pins and does NOT write versions.automation back into config/defaults.json (repository history shows versions.automation only moves via manual `chore: consume SDK X and Automation Y` PRs). This PR therefore intentionally requires a manual versions.automation follow-up: after the sibling openhands-automation release lands, bump versions.automation in config/defaults.json to that release so the Check SDK version consistency gate goes fully green (the client-pin half is green as-of this PR;the automation half stays red until that manual bump). + + + + ## Issue Number + + Relates to #16865 + + + + ## How to Test + + Run \`node scripts/check-sdk-version-sync.mjs\` on this branch:the client registry pin now matches versions.agentServer (\`@openhands/typescript-client@$VERSION\`). The unit suites (\`docs-version-sync.test.ts\`, \`dev-safe.test.ts\`) assert the same literals and pass;\`npm ci\` installs cleanly from the regenerated lockfile. The automation half of the sync check will stay red until the manual versions.automation follow-up (described in Summary) lands — this is expected and called out in the PR body. + + + + ## Video/Screenshots + + ![sdk-version-bump](https://raw.githubusercontent.com/OpenHands/OpenHands/$BRANCH/.pr/sdk-version-bump.svg) + + + + ## Type + + - [ ] Bug fix + - [ ] Feature + - [ ] Refactor + - [ ] Breaking change + - [x] Docs / chore + + + + ## Notes + + Generated by the version-bump-prs workflow after the software-agent-sdk v$VERSION release;the automation-version follow-up requires a manual versions.automation bump in config/defaults.json (the sibling automation bump flow only updates OpenHands/automation's own pins). + + + + This PR was created by an AI agent (OpenHands) on behalf of @juanmichelini." \ + --base main \ + --head "$BRANCH" + + echo "✅ PR created for $REPO" + fi + - name: Create PR for enterprise repo id: enterprise_pr continue-on-error: true @@ -363,6 +604,7 @@ jobs: CLI_OUTCOME: ${{ steps.cli_pr.outcome }} AUTOMATION_OUTCOME: ${{ steps.automation_pr.outcome }} ENTERPRISE_OUTCOME: ${{ steps.enterprise_pr.outcome }} + OPENHANDS_OUTCOME: ${{ steps.openhands_pr.outcome }} run: | echo "## Version Bump PRs" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -382,6 +624,11 @@ jobs: else echo "- [automation](https://github.com/OpenHands/automation/pulls?q=is%3Apr+bump-sdk-$VERSION)" >> $GITHUB_STEP_SUMMARY fi + if [ "$OPENHANDS_OUTCOME" = "failure" ]; then + echo "- ⚠️ **OpenHands PR creation FAILED** — see the \"Create PR for OpenHands repo\" step logs" >> $GITHUB_STEP_SUMMARY + else + echo "- [OpenHands](https://github.com/OpenHands/OpenHands/pulls?q=is%3Apr+bump-sdk-$VERSION)" >> $GITHUB_STEP_SUMMARY + fi if [ "$ENTERPRISE_OUTCOME" = "failure" ]; then echo "- ⚠️ **enterprise PR creation FAILED** — see the \"Create PR for enterprise repo\" step logs" >> $GITHUB_STEP_SUMMARY else From ea606dcab8047cf79d2df56c0015a7f966464e54 Mon Sep 17 00:00:00 2001 From: Ash Clarke Date: Thu, 24 Sep 2026 11:33:11 -0600 Subject: [PATCH 5/6] fix: surface friendly error message when LLM API key is invalid Co-authored-by: openhands --- .../conversation/impl/local_conversation.py | 31 +++++ .../local/test_auth_error_friendly_message.py | 118 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 tests/sdk/conversation/local/test_auth_error_friendly_message.py diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 665149d34d..2209e7cd13 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -55,6 +55,7 @@ from openhands.sdk.io import FileStore, LocalFileStore from openhands.sdk.llm import LLM, Message, TextContent, content_to_str from openhands.sdk.llm.auth.openai import create_subscription_llm_from_config +from openhands.sdk.llm.exceptions import LLMAuthenticationError from openhands.sdk.llm.llm import LLMCallContext from openhands.sdk.llm.llm_profile_store import LLMProfileStore from openhands.sdk.llm.llm_registry import LLMRegistry @@ -2040,6 +2041,21 @@ def run(self) -> None: ) ) break + except LLMAuthenticationError as e: + with self._state: + self._state.execution_status = ConversationExecutionStatus.ERROR + self._on_event( + ConversationErrorEvent( + source="environment", + code="LLMAuthenticationError", + detail=( + "Your LLM API key appears to be invalid or has expired." + ), + ) + ) + raise ConversationRunError( + self._state.id, e, persistence_dir=self._state.persistence_dir + ) from e except Exception as e: with self._state: self._state.execution_status = ConversationExecutionStatus.ERROR @@ -2540,6 +2556,21 @@ async def arun(self) -> None: self._state.execution_status = ConversationExecutionStatus.PAUSED self._on_event(InterruptEvent()) + except LLMAuthenticationError as e: + with self._state: + self._state.execution_status = ConversationExecutionStatus.ERROR + self._on_event( + ConversationErrorEvent( + source="environment", + code="LLMAuthenticationError", + detail=( + "Your LLM API key appears to be invalid or has expired." + ), + ) + ) + raise ConversationRunError( + self._state.id, e, persistence_dir=self._state.persistence_dir + ) from e except Exception as e: with self._state: updated_agent_state = dict(self._state.agent_state) diff --git a/tests/sdk/conversation/local/test_auth_error_friendly_message.py b/tests/sdk/conversation/local/test_auth_error_friendly_message.py new file mode 100644 index 0000000000..3ae22c9f00 --- /dev/null +++ b/tests/sdk/conversation/local/test_auth_error_friendly_message.py @@ -0,0 +1,118 @@ +"""Tests that LLMAuthenticationError surfaces a user-friendly message. + +Regression tests for: + https://github.com/OpenHands/software-agent-sdk/issues/3411 + +When a user has an invalid/expired API key the raw litellm error (e.g. the +full AnthropicException JSON) must NOT appear in the ConversationErrorEvent +detail that is sent to the UI. Instead, a clear, actionable message should +be emitted, while the ConversationRunError is still raised so server logs +remain unaffected. +""" + +import asyncio +import tempfile + +import pytest + +from openhands.sdk.agent import Agent +from openhands.sdk.conversation import Conversation, LocalConversation +from openhands.sdk.conversation.exceptions import ConversationRunError +from openhands.sdk.event.conversation_error import ConversationErrorEvent +from openhands.sdk.llm import Message, TextContent +from openhands.sdk.llm.exceptions import LLMAuthenticationError +from openhands.sdk.testing import TestLLM + + +_RAW_LITELLM_ERROR = ( + "litellm.AuthenticationError: AnthropicException - " + '{"type":"error","error":{"type":"authentication_error",' + '"message":"invalid x-api-key"},' + '"request_id":"req_011CbTfF4jtKVAB95FSH6ESb"}' +) +_FRIENDLY_SUBSTRING = "invalid or has expired" + + +def _make_auth_failing_conversation(tmpdir: str) -> LocalConversation: + llm = TestLLM.from_messages([LLMAuthenticationError(_RAW_LITELLM_ERROR)]) + agent = Agent(llm=llm, tools=[]) + conv = Conversation(agent=agent, persistence_dir=tmpdir, workspace=tmpdir) + assert isinstance(conv, LocalConversation) + conv.send_message(Message(role="user", content=[TextContent(text="hello")])) + return conv + + +# --------------------------------------------------------------------------- +# Sync path (run) +# --------------------------------------------------------------------------- + + +def test_auth_error_run_raises_conversation_run_error(): + """ConversationRunError is still raised so server logs are unaffected.""" + with tempfile.TemporaryDirectory() as tmpdir: + conv = _make_auth_failing_conversation(tmpdir) + with pytest.raises(ConversationRunError) as exc_info: + conv.run() + assert isinstance(exc_info.value.__cause__, LLMAuthenticationError) + + +def test_auth_error_run_emits_friendly_detail(): + """ConversationErrorEvent.detail is user-readable, not the raw litellm string.""" + with tempfile.TemporaryDirectory() as tmpdir: + conv = _make_auth_failing_conversation(tmpdir) + with pytest.raises(ConversationRunError): + conv.run() + + error_events = [ + e for e in conv.state.events if isinstance(e, ConversationErrorEvent) + ] + assert error_events, "Expected at least one ConversationErrorEvent" + + auth_error_event = next( + (e for e in error_events if e.code == "LLMAuthenticationError"), None + ) + assert auth_error_event is not None, ( + "Expected a ConversationErrorEvent with code='LLMAuthenticationError'" + ) + assert _FRIENDLY_SUBSTRING in auth_error_event.detail, ( + f"Expected friendly message in detail, got: {auth_error_event.detail!r}" + ) + assert _RAW_LITELLM_ERROR not in auth_error_event.detail, ( + "Raw litellm error string must not appear in the UI-facing detail" + ) + + +# --------------------------------------------------------------------------- +# Async path (arun) +# --------------------------------------------------------------------------- + + +def test_auth_error_arun_raises_conversation_run_error(): + """Async path: ConversationRunError is still raised.""" + with tempfile.TemporaryDirectory() as tmpdir: + conv = _make_auth_failing_conversation(tmpdir) + with pytest.raises(ConversationRunError) as exc_info: + asyncio.run(conv.arun()) + assert isinstance(exc_info.value.__cause__, LLMAuthenticationError) + + +def test_auth_error_arun_emits_friendly_detail(): + """Async path: ConversationErrorEvent.detail is user-readable.""" + with tempfile.TemporaryDirectory() as tmpdir: + conv = _make_auth_failing_conversation(tmpdir) + with pytest.raises(ConversationRunError): + asyncio.run(conv.arun()) + + error_events = [ + e for e in conv.state.events if isinstance(e, ConversationErrorEvent) + ] + assert error_events, "Expected at least one ConversationErrorEvent" + + auth_error_event = next( + (e for e in error_events if e.code == "LLMAuthenticationError"), None + ) + assert auth_error_event is not None, ( + "Expected a ConversationErrorEvent with code='LLMAuthenticationError'" + ) + assert _FRIENDLY_SUBSTRING in auth_error_event.detail + assert _RAW_LITELLM_ERROR not in auth_error_event.detail From b874a47c9ca4cd00072af69871005441697b35c0 Mon Sep 17 00:00:00 2001 From: alanhuangyoo Date: Fri, 25 Sep 2026 02:02:04 +0800 Subject: [PATCH 6/6] fix(sdk): treat a non-string hook decision as no decision (#4773) Co-authored-by: neubig Co-authored-by: openhands --- openhands-sdk/openhands/sdk/hooks/executor.py | 7 +++-- tests/sdk/hooks/test_executor.py | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/openhands-sdk/openhands/sdk/hooks/executor.py b/openhands-sdk/openhands/sdk/hooks/executor.py index 9a95ca1712..9958c5e53f 100644 --- a/openhands-sdk/openhands/sdk/hooks/executor.py +++ b/openhands-sdk/openhands/sdk/hooks/executor.py @@ -566,9 +566,10 @@ def execute( try: output_data = json.loads(result.stdout) if isinstance(output_data, dict): - # Parse decision - if "decision" in output_data: - decision_str = output_data["decision"].lower() + # Non-string values represent no decision. + decision_value = output_data.get("decision") + if isinstance(decision_value, str): + decision_str = decision_value.lower() if decision_str == "allow": hook_result.decision = HookDecision.ALLOW elif decision_str == "deny": diff --git a/tests/sdk/hooks/test_executor.py b/tests/sdk/hooks/test_executor.py index dd85164f7e..440274cd84 100644 --- a/tests/sdk/hooks/test_executor.py +++ b/tests/sdk/hooks/test_executor.py @@ -56,6 +56,36 @@ def test_execute_receives_json_stdin(self, executor, sample_event, tmp_path): assert output_data["event_type"] == "PreToolUse" assert output_data["tool_name"] == "BashTool" + def test_null_decision_leaves_a_successful_hook_successful( + self, executor, sample_event + ): + hook = HookDefinition(command="""echo '{"decision": null}'""") + + result = executor.execute(hook, sample_event) + + assert result.success + assert result.exit_code == 0 + assert result.decision is None + assert not result.error + + def test_non_string_decision_is_ignored_rather_than_fatal( + self, executor, sample_event + ): + """Any non-string decision means no decision, not a failed hook.""" + hook = HookDefinition(command="""echo '{"decision": 1}'""") + + result = executor.execute(hook, sample_event) + + assert result.success + assert result.decision is None + + def test_string_decisions_still_parse(self, executor, sample_event): + hook = HookDefinition(command="""echo '{"decision": "allow"}'""") + + result = executor.execute(hook, sample_event) + + assert result.decision == HookDecision.ALLOW + def test_execute_blocking_exit_code(self, executor, sample_event): """Test that exit code 2 blocks the operation.""" hook = HookDefinition(command=python_command("import sys; sys.exit(2)"))