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):
- GUI submits an OpenHands provider model as
model: "openhands/<model>".
- Agent server validates the request as
LLM, which rewrites it to model: "litellm_proxy/<model>" and stamps the OpenHands proxy base_url.
- The rewritten shape is persisted and returned over the API.
- A later UI Basic-mode save path can treat
litellm_proxy/* as a non-OpenHands provider and drop base_url.
- 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.
Problem
openhands/*is intended to be the user-facing/provider-facing model namespace, but the SDK currently rewrites it into the LiteLLM transport namespace duringLLMvalidation:https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/llm/llm.py#L499-L520
Because this happens in a Pydantic
@model_validator(mode="before"), it is not only a LiteLLM request-time transformation. It mutates the publicLLM.modelfield 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):model: "openhands/<model>".LLM, which rewrites it tomodel: "litellm_proxy/<model>"and stamps the OpenHands proxybase_url.litellm_proxy/*as a non-OpenHands provider and dropbase_url.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-canvasPR preserves the proxybase_urlfor current/released agent-server behavior, but the cleaner SDK contract would be: store and exposeopenhands/*; translate tolitellm_proxy/*only at LiteLLM transport boundaries.Existing history
The rewrite appears to have been introduced by #70 (
f16a2da9, originally inopenhands/core/config/llm_config.py), then moved into the currentLLMpydantic class by #91. Related base-url fixes were made in #338 and #1341.Desired behavior
model: "openhands/<model>"for OpenHands provider models.model="litellm_proxy/<model>"andapi_base="https://llm-proxy.app.all-hands.dev/"(unless a caller intentionally supplied another base URL and that remains supported).litellm_proxy/*+ OpenHands proxybase_urlprofiles/settings are migrated seamlessly back to the publicopenhands/*shape.Proposed SDK implementation
1. Centralize OpenHands-provider helpers/constants
Add a small internal helper module or section in
llm.pywith 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/"https://llm-proxy.app.all-hands.devhttps://llm-proxy.app.all-hands.dev/v1is_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)litellm_transport_base_url_for(model, base_url)The conversion from
litellm_proxy/*back toopenhands/*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 arbitrarylitellm_proxy/*from a customer/third-party proxy.2. Stop mutating
LLM.modelduring normal validationChange
LLM._coerce_inputs()soopenhands/*remains ind["model"].It should still default
base_urlforopenhands/*when unset/None, preserving the #1341 fix:If custom
base_urlforopenhands/*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 passmodel=<transport model>._transport_call()/_atransport_call()inherit that via_prepare_transport_kwargs().Conceptually:
Then:
4. Audit all code paths that currently rely on the rewritten model
The rewrite currently means
self.modelis alreadylitellm_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/infoformodel.startswith("litellm_proxy/"); for publicopenhands/*, 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 understandlitellm_proxy/*; they may need either OpenHands-aware normalization or transport-model input for capability lookup.openhands/*model where user-facing, but ensure price/capability lookup still resolves correctly.5. Add seamless migration for persisted settings
There is already settings versioning:
AGENT_SETTINGS_SCHEMA_VERSIONand_AGENT_SETTINGS_MIGRATIONSinopenhands-sdk/openhands/sdk/settings/model.py.PERSISTED_SETTINGS_SCHEMA_VERSIONinopenhands-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_VERSIONand 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.llmfield.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: nullunless 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_VERSIONinopenhands-sdk/openhands/sdk/llm/llm.py.LLM.from_persisted()/LLM.to_persisted().LLMProfileStore.save()writesllm.to_persisted().LLMProfileStore.load()goes throughLLM.load_from_json().Consider bumping
LLM_PROFILE_SCHEMA_VERSIONand adding profile migrations insideLLM.from_persisted()so saved profile JSON is canonicalized from oldlitellm_proxy/* + OpenHands proxy base_urltoopenhands/* + OpenHands proxy base_url.Important:
LLMProfileStore.list_summaries()currently reads raw JSON directly and does not instantiateLLM; it returnsdata.get("model")/data.get("base_url"). It should either: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
litellm_proxy/* + OpenHands proxy base_urlinputs for at least one release so existing clients and files do not break.litellm_proxy/*with non-OpenHands base URLs should remain unchanged.litellm_proxy/* + base_url: nullshould probably not be auto-converted unless there is an additional reliable signal. It can remain invalid and surface a clearer validation/setup error.agent-canvaspins 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")keepsllm.model == "openhands/foo"and sets defaultbase_urlto 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.litellm.completion/litellm.acompletionand verify transport kwargs usemodel="litellm_proxy/foo",api_base=<proxy>, whilellm.modelremainsopenhands/foo.openhands/foostill use the proxy path and do not regress capabilities.litellm_proxy/foo+ custom base URL remains unchanged.Profile migration tests
litellm_proxy/<known-openhands-model>+ OpenHands proxy base URL loads asopenhands/<known-openhands-model>.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: nullis not silently converted (or, if we decide to convert, document and test the heuristic).Settings migration tests
agent_settings.llmwith old rewritten OpenHands proxy shape migrates to publicopenhands/*throughvalidate_agent_settings()/PersistedSettings.from_persisted().AGENT_SETTINGS_SCHEMA_VERSIONif using the existing settings migration mechanism.PATCH /api/settings/GET /api/settingsreturns the public model shape after migration.Agent-server/profile API tests
POST /api/profiles/{name}withopenhands/*returns/saves publicopenhands/*, notlitellm_proxy/*.GET /api/profiles/{name}for a legacy file returns publicopenhands/*.GET /api/profilessummaries are normalized.litellm_proxy/*transport model.Acceptance criteria
litellm_proxy/*.litellm_proxy/*model and OpenHands proxy base URL.LLM.modelfield for normal OpenHands-provider profiles.This issue was created by an AI agent (OpenHands) on behalf of the agent-canvas maintainers.