diff --git a/AGENTS.md b/AGENTS.md
index e07c05408a..7d4aef35e4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -521,10 +521,10 @@ When adding code that needs a new string, decide up front which rule it falls un
- `scripts/dev-safe.mjs` uses `uvx` for temporary agent-server installation — no permanent `uv tool install` needed. Environment variables (highest precedence first):
- `OH_AGENT_SERVER_LOCAL_PATH` — absolute path to a local `software-agent-sdk` checkout. Runs the local checkout via `uvx` with `--with-editable` for `openhands-sdk`/`openhands-tools`/`openhands-workspace` and `--reinstall` for `openhands-agent-server`, so SDK edits are picked up on restart. Highest precedence.
- `OH_AGENT_SERVER_GIT_REF` — git commit SHA or branch name (takes precedence over version)
- - `OH_AGENT_SERVER_VERSION` — specific PyPI version (e.g., "1.27.0")
+ - `OH_AGENT_SERVER_VERSION` — specific PyPI version (e.g., "1.28.1")
- `OH_SECRET_KEY` — secret key for settings encryption; auto-generated and persisted to `~/.openhands/agent-canvas/secret-key.txt` on first run (same file Docker uses), ensuring dev mode and Docker share the same key when both mount the same `~/.openhands` directory. Override with the env var to pin a specific key.
- `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0` / `VITE_SESSION_API_KEY` — session API key for agent-server authentication; auto-generated using `crypto.randomBytes(32)` if not set, passed to both agent-server (`OH_SESSION_API_KEYS_0`) and frontend (`VITE_SESSION_API_KEY`)
- - Default: released PyPI version `1.27.0` for agent-server SDK libraries
+ - Default: released PyPI version `1.28.1` for agent-server SDK libraries
- Security: `scripts/dev-safe.mjs` and `scripts/dev-with-automation.mjs` auto-generate random API keys when needed and persist the defaults so static builds, localStorage, and restarted services stay in sync:
- `SESSION_API_KEY` — 64-character hex (256-bit) for agent-server API authentication; persisted at `~/.openhands/agent-canvas/session-api-key.txt` unless overridden via env var
diff --git a/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx b/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx
index 3ca4b23b1d..03c5033767 100644
--- a/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx
+++ b/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx
@@ -625,6 +625,46 @@ describe("LlmSettingsLocalView", () => {
expect(savedLlm.model).toBe("litellm_proxy/claude-opus-4-8");
expect(savedLlm.base_url).toBe(OPENHANDS_LLM_PROXY_BASE_URL);
});
+
+ it("injects the proxy base_url for a litellm_proxy model when server omits it (agent-server ≥1.28)", async () => {
+ // Arrange — agent-server ≥1.28 may omit the default base_url from stored
+ // profile configs. When the profile is fetched with base_url:null the save
+ // must still inject the All-Hands proxy URL, otherwise the profile is
+ // stranded on the next save from the Basic tab.
+ const user = userEvent.setup();
+ vi.mocked(ProfilesService.getProfile).mockResolvedValue({
+ name: "gpt-4-profile",
+ api_key_set: true,
+ config: {
+ model: "litellm_proxy/claude-opus-4-8",
+ api_key: "gAAAA_encrypted_key",
+ base_url: null,
+ },
+ });
+ mockSaveMutateAsync.mockResolvedValueOnce({ success: true });
+
+ renderWithProviders();
+
+ await user.click(screen.getAllByTestId("profile-menu-trigger")[0]);
+ await user.click(screen.getByTestId("profile-edit"));
+ await waitFor(() => {
+ expect(screen.getByTestId("profile-name-input")).toHaveValue(
+ "gpt-4-profile",
+ );
+ });
+ await user.click(await screen.findByTestId("sdk-section-basic-toggle"));
+ await waitFor(() => {
+ expect(screen.getByTestId("save-profile-btn")).not.toBeDisabled();
+ });
+ await user.click(screen.getByTestId("save-profile-btn"));
+
+ // Assert — even though the server returned base_url:null, the Basic-tab
+ // save must inject the proxy URL so the profile stays usable.
+ await waitFor(() => expect(mockSaveMutateAsync).toHaveBeenCalled());
+ const savedLlm = mockSaveMutateAsync.mock.calls[0][0].request.llm;
+ expect(savedLlm.model).toBe("litellm_proxy/claude-opus-4-8");
+ expect(savedLlm.base_url).toBe(OPENHANDS_LLM_PROXY_BASE_URL);
+ });
});
describe("All tab save", () => {
diff --git a/__tests__/scripts/dev-safe.test.ts b/__tests__/scripts/dev-safe.test.ts
index 9bfc8e8638..ee062d2d2e 100644
--- a/__tests__/scripts/dev-safe.test.ts
+++ b/__tests__/scripts/dev-safe.test.ts
@@ -389,16 +389,16 @@ describe("buildAgentServerCommand", () => {
// Defaults to the released PyPI version with all SDK packages pinned to same version
expect(cmd.args).toEqual([
"--from",
- "openhands-agent-server==1.27.0",
+ "openhands-agent-server==1.28.1",
"--with",
- "openhands-sdk==1.27.0",
+ "openhands-sdk==1.28.1",
"--with",
- "openhands-tools==1.27.0",
+ "openhands-tools==1.28.1",
"--with",
- "openhands-workspace==1.27.0",
+ "openhands-workspace==1.28.1",
"agent-server",
]);
- expect(cmd.source).toBe("PyPI (1.27.0, default)");
+ expect(cmd.source).toBe("PyPI (1.28.1, default)");
});
it("uses specific PyPI version when OH_AGENT_SERVER_VERSION is set with all packages pinned", () => {
diff --git a/config/defaults.json b/config/defaults.json
index b30a64ab41..e6fdfa971b 100644
--- a/config/defaults.json
+++ b/config/defaults.json
@@ -2,10 +2,10 @@
"_comment": "Single source of truth for version pins, ports, paths, and defaults shared across the npm and Docker install paths. Read by scripts/dev-safe.mjs, scripts/dev-with-automation.mjs, docker/entrypoint.sh (via generated defaults.env), and .github/workflows/docker.yml.",
"versions": {
- "agentServer": "1.27.0",
+ "agentServer": "1.28.1",
"agentCanvas": "1.0.0-rc.6",
- "automation": "1.0.0a7",
- "automationSdk": "1.27.0",
+ "automation": "1.0.0a9",
+ "automationSdk": "1.28.1",
"githubMcpServer": "1.2.0"
},
diff --git a/package-lock.json b/package-lock.json
index 34a3d55737..df22f98b84 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.20",
"@monaco-editor/react": "4.7.0",
- "@openhands/extensions": "0.4.0",
+ "@openhands/extensions": "0.4.1",
"@openhands/typescript-client": "1.24.3",
"@react-router/node": "7.17.0",
"@react-router/serve": "7.17.0",
@@ -3468,9 +3468,9 @@
"license": "MIT"
},
"node_modules/@openhands/extensions": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.4.0.tgz",
- "integrity": "sha512-cz0k8/rl/rfsD8rAatU1P0SOj/LKaSy+aUu9qp11yEfl0yF2D1vltY4XYa84LmTkTwxtOwDbKtqRPdZ6GJ5b/A==",
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.4.1.tgz",
+ "integrity": "sha512-wpoLYiuy4aiaiLeJpUdkoc2oK861/u5uc8rvZD9s+98fDS8vjnDSImEIG35XKsKT6xMsKlOK6pTpdgNfb8sB8w==",
"license": "MIT",
"engines": {
"node": ">=18.20.0"
diff --git a/package.json b/package.json
index 4a24c1235d..5b7a31e807 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,7 @@
"@heroui/react": "2.8.10",
"@microlink/react-json-view": "1.31.20",
"@monaco-editor/react": "4.7.0",
- "@openhands/extensions": "0.4.0",
+ "@openhands/extensions": "0.4.1",
"@openhands/typescript-client": "1.24.3",
"@react-router/node": "7.17.0",
"@react-router/serve": "7.17.0",
diff --git a/scripts/check-sdk-version-sync.mjs b/scripts/check-sdk-version-sync.mjs
index 582ef0c9aa..b21e78cd57 100644
--- a/scripts/check-sdk-version-sync.mjs
+++ b/scripts/check-sdk-version-sync.mjs
@@ -19,7 +19,7 @@
*
* Usage:
* node scripts/check-sdk-version-sync.mjs
- * EXPECTED_SDK_VERSION=1.27.0 node scripts/check-sdk-version-sync.mjs
+ * EXPECTED_SDK_VERSION=1.28.1 node scripts/check-sdk-version-sync.mjs
* node scripts/check-sdk-version-sync.mjs --check-pypi
*
* Environment variables:
@@ -79,7 +79,7 @@ Triggering from other repos:
-H "Authorization: token \$GITHUB_TOKEN" \\
-H "Accept: application/vnd.github.v3+json" \\
https://api.github.com/repos/OpenHands/agent-canvas/dispatches \\
- -d '{"event_type": "sdk-version-check", "client_payload": {"version": "1.27.0"}}'
+ -d '{"event_type": "sdk-version-check", "client_payload": {"version": "1.28.1"}}'
`);
process.exit(0);
}
@@ -268,9 +268,9 @@ async function fetchPyPIDependencies(packageName, version) {
* Parse PyPI requires_dist array and extract SDK package versions
*
* PyPI returns dependencies in PEP 508 format like:
- * "openhands-sdk>=1.27.0,<2.0.0"
- * "openhands-tools==1.27.0"
- * "openhands-workspace (>=1.27.0)"
+ * "openhands-sdk>=1.28.1,<2.0.0"
+ * "openhands-tools==1.28.1"
+ * "openhands-workspace (>=1.28.1)"
*/
function parseSdkVersionsFromRequiresDist(requiresDist) {
const versions = {};
@@ -284,7 +284,7 @@ function parseSdkVersionsFromRequiresDist(requiresDist) {
}
// Extract the version number - look for patterns like:
- // ">=1.27.0", "==1.27.0", "(>=1.27.0)", "~=1.27.0"
+ // ">=1.28.1", "==1.28.1", "(>=1.28.1)", "~=1.28.1"
// After the package name and before any comma or closing paren
const versionPattern = /[><=~!]+\s*([0-9]+(?:\.[0-9]+)*)/;
const match = dep.match(versionPattern);
diff --git a/scripts/dev-safe.mjs b/scripts/dev-safe.mjs
index cac5b7ebc8..284d1776e9 100644
--- a/scripts/dev-safe.mjs
+++ b/scripts/dev-safe.mjs
@@ -394,7 +394,7 @@ export function validateFrontendDependencies(
* edits are picked up without a manual reinstall. The agent-server itself
* is rebuilt from local source on each invocation (--reinstall).
* - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name
- * - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.27.0")
+ * - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.28.1")
*
* If none are set, defaults to the released version specified by
* DEFAULT_AGENT_SERVER_VERSION. Set OH_AGENT_SERVER_GIT_REF to use a
diff --git a/src/components/features/settings/llm-profiles/llm-settings-local-view.tsx b/src/components/features/settings/llm-profiles/llm-settings-local-view.tsx
index 3309671f4a..2909785410 100644
--- a/src/components/features/settings/llm-profiles/llm-settings-local-view.tsx
+++ b/src/components/features/settings/llm-profiles/llm-settings-local-view.tsx
@@ -255,13 +255,26 @@ export function LlmSettingsLocalView() {
// The Basic tab has no base_url field; the provider implies it. Persist
// the All-Hands proxy explicitly for OpenHands models — including ones the
- // SDK has already rewritten to `litellm_proxy/*` — because older local
+ // SDK has already rewritten to `litellm_proxy/*` — because local
// agent-server builds do not infer the LiteLLM proxy api_base on their own,
// and dropping it strands the profile as `litellm_proxy/* + base_url:null`
// (issue #1146). For other providers, drop any stale custom value and let
// the backend use its normal provider defaults.
+ //
+ // Agent-server ≥1.28 may omit the default base_url from profile configs,
+ // causing the stored value to be null even for genuine OpenHands proxy
+ // models. Treat litellm_proxy/* with a missing base_url the same as
+ // litellm_proxy/* with the proxy URL already set.
if (saveControl.view === "basic") {
- if (isOpenHandsProxyModel(llmConfig.model, llmConfig.base_url)) {
+ const model = llmConfig.model;
+ const baseUrl = llmConfig.base_url;
+ const isProxy = isOpenHandsProxyModel(model, baseUrl);
+ const isLitellmProxyWithMissingBaseUrl =
+ !isProxy &&
+ typeof model === "string" &&
+ model.startsWith("litellm_proxy/") &&
+ !baseUrl;
+ if (isProxy || isLitellmProxyWithMissingBaseUrl) {
llmConfig.base_url = OPENHANDS_LLM_PROXY_BASE_URL;
} else {
delete llmConfig.base_url;
diff --git a/tests/e2e/mock-llm/mock-llm-profile-management.spec.ts b/tests/e2e/mock-llm/mock-llm-profile-management.spec.ts
index 706387237b..ef0e7097d9 100644
--- a/tests/e2e/mock-llm/mock-llm-profile-management.spec.ts
+++ b/tests/e2e/mock-llm/mock-llm-profile-management.spec.ts
@@ -353,6 +353,46 @@ test.describe("same-model profile identity", () => {
// (PR #1148, issue #1146)
// ═══════════════════════════════════════════════════════════════════════
+/**
+ * Assert that a proxy profile config is valid after a Basic-tab re-save.
+ *
+ * Agent-server ≥1.28 normalises `litellm_proxy/*` → `openhands/*` on storage
+ * and manages the proxy URL internally (returning base_url: null). Both
+ * representations are accepted here. The original issue #1146 regression
+ * (litellm_proxy/* with null base_url) is still guarded explicitly.
+ */
+function assertProxyProfileConfig(
+ config: Record,
+ litellmProxyModel: string,
+ openHandsEquivalentModel: string,
+ proxyBaseUrl: string,
+) {
+ const model = config.model as string | null | undefined;
+ const baseUrl = config.base_url as string | null | undefined;
+
+ const isLitellmForm =
+ typeof model === "string" && model.startsWith("litellm_proxy/");
+ const isOpenHandsForm =
+ typeof model === "string" && model.startsWith("openhands/");
+
+ expect(
+ isLitellmForm || isOpenHandsForm,
+ `model must be ${litellmProxyModel} or ${openHandsEquivalentModel}; got: ${model}`,
+ ).toBe(true);
+
+ if (isLitellmForm) {
+ // A litellm_proxy/* profile without the proxy URL is stranded (issue #1146).
+ // The frontend must always inject the URL when saving from the Basic tab.
+ expect(
+ baseUrl,
+ "base_url must be the All-Hands proxy URL for litellm_proxy/* profiles " +
+ "(dropping it strands the profile — issue #1146)",
+ ).toBe(proxyBaseUrl);
+ }
+ // For openhands/* (agent-server ≥1.28 rewrite), null base_url is correct;
+ // the server routes the request through the proxy internally.
+}
+
test.describe("litellm_proxy proxy base_url preservation", () => {
// Simulates the state the SDK persists after onboarding through the
// OpenHands provider: openhands/* is rewritten to litellm_proxy/* and
@@ -385,6 +425,11 @@ test.describe("litellm_proxy proxy base_url preservation", () => {
page,
request,
}) => {
+ // Agent-server ≥1.28 normalises litellm_proxy/* → openhands/* on storage
+ // and manages the proxy URL internally (returning base_url: null). The old
+ // assertions hard-coded the pre-1.28 storage format.
+ const OPENHANDS_EQUIVALENT_MODEL = "openhands/claude-opus-4-8";
+
// ── Setup: create a profile with the SDK-rewritten litellm_proxy
// model + proxy base_url through the Settings UI, exactly as
// the agent-server persists it after an openhands/* model
@@ -451,15 +496,15 @@ test.describe("litellm_proxy proxy base_url preservation", () => {
await waitForTestId(page, "add-llm-profile");
});
- // ── Verify: the proxy base_url survived the Basic-tab save ──
- await test.step("verify base_url is preserved after save", async () => {
+ // ── Verify: the proxy setup survived the Basic-tab save ──
+ await test.step("verify proxy setup is preserved after save", async () => {
const config = await getProfileConfig(request, PROXY_PROFILE);
- expect(
- config.base_url,
- "base_url must be the All-Hands proxy URL after a Basic-tab re-save; " +
- "dropping it strands the profile (issue #1146)",
- ).toBe(OPENHANDS_PROXY_BASE_URL);
- expect(config.model).toBe(LITELLM_PROXY_MODEL);
+ assertProxyProfileConfig(
+ config,
+ LITELLM_PROXY_MODEL,
+ OPENHANDS_EQUIVALENT_MODEL,
+ OPENHANDS_PROXY_BASE_URL,
+ );
});
// ── Verify: the profile also looks correct after a page reload ──
@@ -469,8 +514,12 @@ test.describe("litellm_proxy proxy base_url preservation", () => {
// Re-read via API to confirm persistence is durable
const config = await getProfileConfig(request, PROXY_PROFILE);
- expect(config.base_url).toBe(OPENHANDS_PROXY_BASE_URL);
- expect(config.model).toBe(LITELLM_PROXY_MODEL);
+ assertProxyProfileConfig(
+ config,
+ LITELLM_PROXY_MODEL,
+ OPENHANDS_EQUIVALENT_MODEL,
+ OPENHANDS_PROXY_BASE_URL,
+ );
});
});
});