Skip to content

Keep openhands/* as public LLM model; translate to litellm_proxy only at transport time #3514

@enyst

Description

@enyst

Problem

openhands/* is intended to be the user-facing/provider-facing model namespace, but the SDK currently rewrites it into the LiteLLM transport namespace during LLM validation:

https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm/llm.py#L499-L520

if model_val.startswith("openhands/"):
    model_name = model_val.removeprefix("openhands/")
    d["model"] = f"litellm_proxy/{model_name}"
    d["base_url"] = d.get("base_url") or "https://llm-proxy.app.all-hands.dev/"

Because this happens in a Pydantic @model_validator(mode="before"), it is not only a LiteLLM request-time transformation. It mutates the public LLM.model field before agent-server routes and persistence see the object.

Observed downstream effect in agent-canvas (see OpenHands/agent-canvas#1146 and OpenHands/agent-canvas#1148):

  1. GUI submits an OpenHands provider model as model: "openhands/<model>".
  2. Agent server validates the request as LLM, which rewrites it to model: "litellm_proxy/<model>" and stamps the OpenHands proxy base_url.
  3. The rewritten shape is persisted and returned over the API.
  4. A later UI Basic-mode save path can treat litellm_proxy/* as a non-OpenHands provider and drop base_url.
  5. The profile becomes model: "litellm_proxy/<model>", base_url: null; LiteLLM then falls back toward the wrong provider/default OpenAI route and reports a misleading auth error.

The immediate agent-canvas PR preserves the proxy base_url for current/released agent-server behavior, but the cleaner SDK contract would be: store and expose openhands/*; translate to litellm_proxy/* only at LiteLLM transport boundaries.

Existing history

The rewrite appears to have been introduced by #70 (f16a2da9, originally in openhands/core/config/llm_config.py), then moved into the current LLM pydantic class by #91. Related base-url fixes were made in #338 and #1341.

Desired behavior

  • Public/stored/API LLM config remains stable as model: "openhands/<model>" for OpenHands provider models.
  • The SDK still sends LiteLLM the transport model it expects: model="litellm_proxy/<model>" and api_base="https://llm-proxy.app.all-hands.dev/" (unless a caller intentionally supplied another base URL and that remains supported).
  • Existing users with already persisted litellm_proxy/* + OpenHands proxy base_url profiles/settings are migrated seamlessly back to the public openhands/* shape.
  • Third-party LiteLLM proxy profiles must not be converted accidentally.
  • Avoid leaving redundant frontend/backend normalization code long-term once the SDK contract is stable.

Proposed SDK implementation

1. Centralize OpenHands-provider helpers/constants

Add a small internal helper module or section in llm.py with explicit constants and conversion helpers, for example:

  • OPENHANDS_PROVIDER_PREFIX = "openhands/"
  • LITELLM_PROXY_PREFIX = "litellm_proxy/"
  • OPENHANDS_LLM_PROXY_BASE_URL = "https://llm-proxy.app.all-hands.dev/"
  • accepted normalized OpenHands proxy base URLs, likely both:
    • https://llm-proxy.app.all-hands.dev
    • https://llm-proxy.app.all-hands.dev/v1
  • is_openhands_provider_model(model)
  • is_openhands_proxy_base_url(base_url)
  • is_openhands_litellm_proxy_model(model, base_url)
  • openhands_public_model_from_proxy(model, base_url)
  • litellm_transport_model_for(model)
  • possibly litellm_transport_base_url_for(model, base_url)

The conversion from litellm_proxy/* back to openhands/* should be conservative: only when paired with the known OpenHands proxy URL, and ideally only for models in the verified OpenHands model list. Do not rewrite arbitrary litellm_proxy/* from a customer/third-party proxy.

2. Stop mutating LLM.model during normal validation

Change LLM._coerce_inputs() so openhands/* remains in d["model"].

It should still default base_url for openhands/* when unset/None, preserving the #1341 fix:

if model_val.startswith("openhands/"):
    d["base_url"] = d.get("base_url") or OPENHANDS_LLM_PROXY_BASE_URL

If custom base_url for openhands/* is intentionally supported today (test_base_url_for_openhands_provider_with_custom_url), keep that behavior or make a deliberate breaking-change decision.

3. Translate only for LiteLLM transport calls

Use the transport model when calling LiteLLM, not when storing the public LLM.model:

  • _prepare_transport_kwargs() should pass model=<transport model>.
  • _transport_call() / _atransport_call() inherit that via _prepare_transport_kwargs().
  • Responses API paths should be checked too if they use separate transport construction.

Conceptually:

def _litellm_transport_model(self) -> str:
    if self.model.startswith("openhands/"):
        return f"litellm_proxy/{self.model.removeprefix('openhands/')}"
    return self.model

Then:

return dict(
    model=self._litellm_transport_model(),
    api_key=self._get_litellm_api_key_value(),
    api_base=self.base_url,
    ...
)

4. Audit all code paths that currently rely on the rewritten model

The rewrite currently means self.model is already litellm_proxy/*, so several helper paths may implicitly depend on that. They should use either the public model or transport model intentionally:

  • _infer_litellm_provider() / infer_litellm_provider(model=..., api_base=...) probably needs the transport model.
  • get_litellm_model_info() currently only calls /v1/model/info for model.startswith("litellm_proxy/"); for public openhands/*, pass the transport model into that lookup so proxy model-info still works.
  • _model_name_for_capabilities() / get_features() / supports_vision() should be checked. Some local feature tables understand litellm_proxy/*; they may need either OpenHands-aware normalization or transport-model input for capability lookup.
  • telemetry/metrics/cost fields should be reviewed. Prefer reporting the public openhands/* model where user-facing, but ensure price/capability lookup still resolves correctly.
  • error messages should ideally reference the public model while preserving enough transport context for debugging.
  • fallback strategies / router / model switch tool should be reviewed for any direct string-prefix assumptions.

5. Add seamless migration for persisted settings

There is already settings versioning:

  • AGENT_SETTINGS_SCHEMA_VERSION and _AGENT_SETTINGS_MIGRATIONS in openhands-sdk/openhands/sdk/settings/model.py.
  • top-level PERSISTED_SETTINGS_SCHEMA_VERSION in openhands-agent-server/openhands/agent_server/persistence/models.py.
  • PersistedSettings.from_persisted() / validate_agent_settings() already apply nested settings migrations.

For the active agent settings path, consider bumping AGENT_SETTINGS_SCHEMA_VERSION and adding an agent-settings migration that canonicalizes:

{
  "llm": {
    "model": "litellm_proxy/<known-openhands-model>",
    "base_url": "https://llm-proxy.app.all-hands.dev/"
  }
}

to:

{
  "llm": {
    "model": "openhands/<known-openhands-model>",
    "base_url": "https://llm-proxy.app.all-hands.dev/"
  }
}

This seems like an appropriate use of agent settings versioning for the active settings file because it is changing the canonical persisted shape of a nested agent_settings.llm field.

If we choose not to bump agent settings, then the equivalent normalization must still happen in a clearly documented load path; however piggybacking on the existing versioned migration mechanism is probably cleaner and easier to test.

Do not silently infer OpenHands from litellm_proxy/* + base_url: null unless we intentionally accept that heuristic. That stranded shape is the broken downstream state, but without the proxy URL it is ambiguous with third-party LiteLLM proxy usage. A conservative migration should only repair unambiguous profiles/settings; ambiguous ones can raise/validate/show a setup error.

6. Add seamless migration for LLM profiles

Profiles have their own versioning concept too:

  • LLM_PROFILE_SCHEMA_VERSION in openhands-sdk/openhands/sdk/llm/llm.py.
  • LLM.from_persisted() / LLM.to_persisted().
  • LLMProfileStore.save() writes llm.to_persisted().
  • LLMProfileStore.load() goes through LLM.load_from_json().

Consider bumping LLM_PROFILE_SCHEMA_VERSION and adding profile migrations inside LLM.from_persisted() so saved profile JSON is canonicalized from old litellm_proxy/* + OpenHands proxy base_url to openhands/* + OpenHands proxy base_url.

Important: LLMProfileStore.list_summaries() currently reads raw JSON directly and does not instantiate LLM; it returns data.get("model") / data.get("base_url"). It should either:

  • apply the same lightweight profile migration/normalization before building the summary, or
  • share a helper with LLM.from_persisted() that can canonicalize raw profile dicts without exposing/decrypting secrets.

Otherwise the UI profile list can still show litellm_proxy/* even though loading the profile would normalize it.

7. Compatibility / rollout notes

  • Keep accepting old litellm_proxy/* + OpenHands proxy base_url inputs for at least one release so existing clients and files do not break.
  • Persist the new public shape on the next save/write after migration.
  • Third-party litellm_proxy/* with non-OpenHands base URLs should remain unchanged.
  • litellm_proxy/* + base_url: null should probably not be auto-converted unless there is an additional reliable signal. It can remain invalid and surface a clearer validation/setup error.
  • Once this ships and agent-canvas pins a compatible agent-server version, downstream workaround code can be reduced to compatibility handling for older servers.

Suggested tests

SDK unit tests

  • LLM(model="openhands/foo") keeps llm.model == "openhands/foo" and sets default base_url to the OpenHands proxy.
  • LLM(model="openhands/foo", base_url=None) also sets the proxy base URL.
  • LLM(model="openhands/foo", base_url="https://custom-proxy.example") preserves the custom base URL if that remains intended.
  • Mock litellm.completion / litellm.acompletion and verify transport kwargs use model="litellm_proxy/foo", api_base=<proxy>, while llm.model remains openhands/foo.
  • Provider inference and model-info lookup tests for openhands/foo still use the proxy path and do not regress capabilities.
  • Negative tests: third-party litellm_proxy/foo + custom base URL remains unchanged.

Profile migration tests

  • A v1/legacy profile JSON containing litellm_proxy/<known-openhands-model> + OpenHands proxy base URL loads as openhands/<known-openhands-model>.
  • Saving after load writes the new schema version and public openhands/* model.
  • LLMProfileStore.list_summaries() reports the normalized public model for migrated profiles.
  • litellm_proxy/foo + custom proxy URL remains a LiteLLM proxy profile.
  • litellm_proxy/foo + base_url: null is not silently converted (or, if we decide to convert, document and test the heuristic).

Settings migration tests

  • Persisted agent_settings.llm with old rewritten OpenHands proxy shape migrates to public openhands/* through validate_agent_settings() / PersistedSettings.from_persisted().
  • The migration bumps AGENT_SETTINGS_SCHEMA_VERSION if using the existing settings migration mechanism.
  • PATCH /api/settings / GET /api/settings returns the public model shape after migration.
  • ACP settings are unaffected.

Agent-server/profile API tests

  • POST /api/profiles/{name} with openhands/* returns/saves public openhands/*, not litellm_proxy/*.
  • GET /api/profiles/{name} for a legacy file returns public openhands/*.
  • GET /api/profiles summaries are normalized.
  • Starting a conversation/profile activation with an OpenHands profile still results in LiteLLM receiving litellm_proxy/* transport model.

Acceptance criteria

  • No public API response or persisted profile/settings file created by the SDK/agent-server rewrites an OpenHands provider model to litellm_proxy/*.
  • LiteLLM requests still use the correct litellm_proxy/* model and OpenHands proxy base URL.
  • Existing unambiguous legacy profiles/settings migrate seamlessly.
  • Third-party LiteLLM proxy usage is not changed.
  • Downstream UIs no longer need to reverse-map the SDK's public LLM.model field for normal OpenHands-provider profiles.

This issue was created by an AI agent (OpenHands) on behalf of the agent-canvas maintainers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestproposalproposal for discussion

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions