fix: always send frontend's chosen default model to agent-server (#898)

* fix: always send frontend's chosen default model to agent-server (#807)

When the agent-server returns an empty model string (e.g. because no
settings have been saved yet), the adapter's type guard accepted it as
a valid string and forwarded it verbatim. The agent-server would then
fall back to its SDK default ('gpt-5.5') rather than using the
frontend's chosen default ('openhands/minimax-m2.7').

Fix: strengthen the guard in buildConfiguredOpenHandsAgentSettings to
also reject empty/whitespace-only strings, so the frontend's own
default is always sent explicitly:

  llm.model =
    typeof llm.model === 'string' && llm.model.trim().length > 0
      ? llm.model
      : DEFAULT_SETTINGS.llm_model;

While here: align the legacy V0 /api/options/models mock endpoint's
default_model with DEFAULT_SETTINGS (was incorrectly set to claude-opus;
should be minimax-m2.7 to match the rest of the defaults), and document
the canonical default model and the two-location update rule in AGENTS.md.

Closes #807

Co-authored-by: openhands <openhands@all-hands.dev>

* refactor(tests): parameterize llm.model fallback tests + add spec + AGENTS.md

- Extract getModelFrom() helper to eliminate repeated as unknown as ModelPayload casts
- Consolidate 7 individual fallback it() blocks into two it.each() groups:
  (1) agent_settings variants (undefined/empty/whitespace/no-llm-block/empty-settings)
  (2) encryptedAgentSettings variants (empty model / empty object)
- Rename @spec annotation from BM-807 to LLD-001 (not a backend-management spec)
- Add specs/llm-defaults.md with LLD-001 checklist
- Add AGENTS.md note documenting canonical default model, its location, and
  the two-location update rule

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
Ash Clarke
2026-06-01 15:29:30 +00:00
committed by GitHub
co-authored by openhands hieptl
parent 0b98b618c1
commit f87167deb6
4 changed files with 88 additions and 1 deletions
+2
View File
@@ -492,6 +492,8 @@ When adding code that needs a new string, decide up front which rule it falls un
- `ManageWorkspacesModal` should require a confirmation step before removing either a saved workspace or a workspace parent; parent removals should mention the child-workspace impact, and tests should assert both the confirmation flow and that removing the selected workspace clears the launch selection.
- In `useWorkspacesStore`, keep `clearWorkspaces()` scoped to literal workspaces only; use explicit helpers like `clearWorkspaceParents()` / `clearAll()` for broader resets so future callers do not accidentally wipe parent registrations.
- Default LLM model — `DEFAULT_SETTINGS.llm_model` (`"openhands/minimax-m2.7"`, defined in `src/services/settings.ts`) is the canonical frontend default. `buildConfiguredOpenHandsAgentSettings` in `src/api/agent-server-adapter.ts` **always** sends this value explicitly when the resolved `llm.model` is absent, empty, or whitespace-only — the frontend never relies on the agent-server SDK's own default (`gpt-5.5`). If you change the default model, update `DEFAULT_SETTINGS.llm_model` in `src/services/settings.ts` **and** the checklist in `specs/llm-defaults.md`. Spec: `@spec LLD-001`.
- Custom secrets are NOT auto-attached by the agent-server. `POST /api/conversations` only persists what the client sends in `request.secrets`; the persisted secrets store (`/api/settings/secrets`) is never read at conversation-start. `buildStartConversationRequestWithEncryptedSettings` enumerates `SecretsService.getSecrets()` and turns each entry into a `LookupSecret` whose `url` points back at `/api/settings/secrets/{name}` and whose `headers` carry `X-Session-API-Key` for auth. Pre-1.21.x agent-server SDKs would silently drop that header during validation when `secrets_encrypted=true` (the cipher in the validation context tried to `cipher.decrypt(plaintext_session_key)`, failed, and the validator removed the header — the conversation runtime then got 401s for every saved secret). The SDK fix preserves plaintext header values when decryption fails; if you still see saved secrets unavailable inside a conversation, verify the running agent-server bundles a `LookupSecret._validate_secrets` that falls back to plaintext on decrypt failure.
- MCP page layout: MCP is a **top-level** nav entry at `/mcp` (rendered by `src/routes/mcp.tsx`), shown right below "Skills" in `src/components/features/sidebar/sidebar.tsx`. The legacy `/settings/mcp` route still works as a redirect via `src/routes/mcp-settings-redirect.tsx`, and `src/routes/mcp-settings.tsx` re-exports the new page so the published `MCPSettings` library symbol (in `src/components/settings/index.ts`) keeps the same shape. Marketplace catalog data and MCP logo mappings live in the MCP-capable entries from `@openhands/extensions/integrations`; the Slack API catalog option should point at `https://github.com/zencoderai/slack-mcp-server` and use `@zencoderai/slack-mcp-server`. Deprecated marketplace entries removed upstream (for example GitLab / Google Maps / Postgres / Puppeteer / SQLite) should disappear from the marketplace grid. The Installed section still needs to render and search arbitrary non-catalog custom servers via the raw server `name` / `command` fallback in `src/utils/mcp-marketplace-utils.ts` + `InstalledServerCard`. Tavily is a regular stdio MCP entry (`tavily-mcp` + `TAVILY_API_KEY`), not a special built-in sentinel anymore. Components are colocated under `src/components/features/mcp-page/` and reuse the existing `MCPServerForm` for the "Add custom server" / edit flow.
@@ -475,6 +475,80 @@ describe("buildStartConversationRequest", () => {
});
});
});
// @spec LLD-001 — Frontend always sends its chosen default model
describe("llm.model fallback — frontend always sends its chosen default", () => {
type ModelPayload = {
agent_settings: Record<string, unknown> & { llm: Record<string, unknown> };
};
function getModelFrom(
options: Parameters<typeof buildStartConversationRequest>[0],
): unknown {
return (buildStartConversationRequest(options) as unknown as ModelPayload)
.agent_settings.llm.model;
}
it("uses the configured model when one is set", () => {
expect(
getModelFrom({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: { model: "anthropic/claude-opus-4-5" },
},
},
}),
).toBe("anthropic/claude-opus-4-5");
});
// The agent-server returns '' when no model has been saved yet.
// Without this guard the empty string passes the old typeof check and
// the agent-server falls back to its own SDK default (gpt-5.5).
// SettingsValue includes scalars so inline literals are type-safe here.
it.each([
["undefined", { ...DEFAULT_SETTINGS.agent_settings, llm: {} }],
["an empty string", { ...DEFAULT_SETTINGS.agent_settings, llm: { model: "" } }],
["whitespace only", { ...DEFAULT_SETTINGS.agent_settings, llm: { model: " " } }],
// No llm key at all — SettingsValue accepts plain scalars.
["absent (no llm block)", { schema_version: 1, agent_kind: "openhands", agent: "CodeActAgent" }],
// Mirrors a fresh user who skipped onboarding: server returns {}.
["entirely empty", {}],
])(
"falls back to DEFAULT_SETTINGS.llm_model when agent_settings.llm.model is %s",
(_, agentSettings) => {
expect(
getModelFrom({
settings: {
...DEFAULT_SETTINGS,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent_settings: agentSettings as any,
},
}),
).toBe(DEFAULT_SETTINGS.llm_model);
},
);
// encryptedAgentSettings overrides settings.agent_settings at conversation
// start; if the encrypted payload has no model set the frontend default
// must still be sent explicitly.
it.each([
["carries an empty model", { llm: { model: "" } }],
["is empty", {}],
])(
"falls back to DEFAULT_SETTINGS.llm_model when encryptedAgentSettings %s",
(_, encryptedAgentSettings) => {
expect(
getModelFrom({
settings: DEFAULT_SETTINGS,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
encryptedAgentSettings: encryptedAgentSettings as any,
}),
).toBe(DEFAULT_SETTINGS.llm_model);
},
);
});
});
describe("getDefaultConversationTitle", () => {
+9
View File
@@ -0,0 +1,9 @@
# LLM Defaults Specs
---
### LLD-001: Frontend always sends its chosen default model
- [x] When the agent-server returns an absent or empty `llm.model` (e.g. because the user has never saved settings), the frontend adapter shall substitute `DEFAULT_SETTINGS.llm_model` (`"openhands/minimax-m2.7"`) before sending the conversation-start request.
- [x] The frontend shall never rely on the agent-server SDK's own default model (`gpt-5.5`); it shall always send an explicit model value.
- [x] Whitespace-only model strings shall be treated as absent and fall back to the default.
- [x] The same guard applies when LLM settings arrive via `encryptedAgentSettings` (the conversation-start encrypted payload path).
+3 -1
View File
@@ -593,7 +593,9 @@ function buildConfiguredOpenHandsAgentSettings(
const llm = toRecord(agentSettings.llm);
llm.model =
typeof llm.model === "string" ? llm.model : DEFAULT_SETTINGS.llm_model;
typeof llm.model === "string" && llm.model.trim().length > 0
? llm.model
: DEFAULT_SETTINGS.llm_model;
const apiKey = normalizeSecretString(llm.api_key);
if (apiKey) {