Problem
ProviderHandler and the settings model store exactly one credential per ProviderType:
PROVIDER_TOKEN_TYPE = Mapping[ProviderType, ProviderToken]
(openhands/app_server/integrations/provider.py)
A user with two GitHub accounts (e.g. personal + work, or two orgs that don't share SSO) can only ever have one connected at a time. Connecting a second account overwrites the first — there's no way to keep both and pick per-repo/per-conversation which one to use. Same limitation applies to GitLab, Bitbucket, etc.
I searched existing issues (multiple github accounts, multiple git provider tokens, switch github account, one token per provider, the integrations label, and related closed proposals like #10850 and #15492) and didn't find this specific gap tracked, so filing it separately. Apologies in advance if I missed a duplicate.
Proposed design
The codebase already solved this exact shape of problem for LLM configs — LLMProfiles (openhands/app_server/settings/llm_profiles.py) stores a named dict[str, LLM] plus an active: str | None, with validators that reconcile active against the dict and skip individually-invalid entries on load. Git provider tokens could reuse the same pattern instead of inventing a new one:
class ProviderTokenSet(BaseModel):
"""Named collection of ProviderToken for one provider, plus which one is active.
Mirrors LLMProfiles. Backward compatible with today's bare-ProviderToken
storage via a `mode="before"` adapter.
"""
model_config = ConfigDict(validate_assignment=True)
accounts: dict[str, ProviderToken] = Field(default_factory=dict)
active: str | None = None
@model_validator(mode="before")
@classmethod
def _lift_legacy_single_token(cls, value: Any) -> Any:
# Old on-disk shape for this provider was a bare ProviderToken
# payload (token/user_id/host at the top level). Lift it into the
# new shape transparently so existing users don't lose their
# connection on upgrade.
if isinstance(value, dict) and "accounts" not in value and (
"token" in value or "user_id" in value or "host" in value
):
return {"accounts": {"default": value}, "active": "default"}
return value
@model_validator(mode="after")
def _reconcile_active(self) -> "ProviderTokenSet":
if self.active is not None and self.active not in self.accounts:
object.__setattr__(
self, "active", next(iter(self.accounts), None)
)
return self
def active_token(self) -> ProviderToken | None:
return self.accounts.get(self.active) if self.active else None
PROVIDER_TOKEN_TYPE = Mapping[ProviderType, ProviderTokenSet]
ProviderHandler.get_service becomes the one required call-site change — everything else in that class (get_user, get_repositories, search_repositories, verify_repo_provider, get_authenticated_git_url, etc. — about a dozen methods) still does for provider in self.provider_tokens / self.get_service(provider) and keeps working unmodified, because it's still iterating provider types, not accounts:
def get_service(
self, provider: ProviderType, account: str | None = None
) -> GitService:
token_set = self.provider_tokens[provider]
token = token_set.accounts[account] if account else token_set.active_token()
if token is None:
raise AuthenticationError(f"No active account configured for {provider}")
service_class = self.service_class_map[provider]
return service_class(
user_id=token.user_id,
external_auth_id=self.external_auth_id,
external_auth_token=self.external_auth_token,
token=token.token,
external_token_manager=self.external_token_manager,
base_domain=token.host,
)
The optional account param is additive — every existing caller that doesn't pass it keeps resolving to today's single/active token.
API surface (sketch, not implemented)
Following the shape /api/v1/settings/profiles already established for LLMProfiles:
| Method |
Path |
Purpose |
| GET |
/api/v1/secrets/git-providers/{provider}/accounts |
list connected accounts for a provider |
| POST |
/api/v1/secrets/git-providers/{provider}/accounts/{name} |
add/update a named account |
| DELETE |
/api/v1/secrets/git-providers/{provider}/accounts/{name} |
remove a named account |
| POST |
/api/v1/secrets/git-providers/{provider}/accounts/{name}/activate |
switch which account is used by default |
The existing POST /api/v1/secrets/git-providers (bare provider→token) would keep working by writing to accounts["default"] + active="default", so nothing breaks for single-account users.
What this issue is not
I deliberately stopped at the model + one method, not a full PR. This type touches the settings/secrets routers, every git search endpoint (git/repositories/search, git/branches/search, users/git-info), and presumably the Settings UI for account selection — real work, but I wanted to check the shape of the approach first, especially since #10850 and #15492 suggest the settings/integrations area is under active redesign. Happy to build this out (backend + a minimal UI) if the direction looks right, or to adjust if there's a preferred design already in flight.
Open questions for maintainers
- Is per-provider "named accounts with one active" (mirroring
LLMProfiles) the right mental model, or is per-repository/per-conversation account selection (no global "active" account) closer to what's planned for the settings/integrations redesign?
- Should account names be free-text (like LLM profile names) or should the connect flow auto-derive a label from the authenticated username (avoids "personal"/"work" bookkeeping the user has to invent)?
Problem
ProviderHandlerand the settings model store exactly one credential perProviderType:(
openhands/app_server/integrations/provider.py)A user with two GitHub accounts (e.g. personal + work, or two orgs that don't share SSO) can only ever have one connected at a time. Connecting a second account overwrites the first — there's no way to keep both and pick per-repo/per-conversation which one to use. Same limitation applies to GitLab, Bitbucket, etc.
I searched existing issues (
multiple github accounts,multiple git provider tokens,switch github account,one token per provider, theintegrationslabel, and related closed proposals like #10850 and #15492) and didn't find this specific gap tracked, so filing it separately. Apologies in advance if I missed a duplicate.Proposed design
The codebase already solved this exact shape of problem for LLM configs —
LLMProfiles(openhands/app_server/settings/llm_profiles.py) stores a nameddict[str, LLM]plus anactive: str | None, with validators that reconcileactiveagainst the dict and skip individually-invalid entries on load. Git provider tokens could reuse the same pattern instead of inventing a new one:ProviderHandler.get_servicebecomes the one required call-site change — everything else in that class (get_user,get_repositories,search_repositories,verify_repo_provider,get_authenticated_git_url, etc. — about a dozen methods) still doesfor provider in self.provider_tokens/self.get_service(provider)and keeps working unmodified, because it's still iterating provider types, not accounts:The optional
accountparam is additive — every existing caller that doesn't pass it keeps resolving to today's single/active token.API surface (sketch, not implemented)
Following the shape
/api/v1/settings/profilesalready established forLLMProfiles:/api/v1/secrets/git-providers/{provider}/accounts/api/v1/secrets/git-providers/{provider}/accounts/{name}/api/v1/secrets/git-providers/{provider}/accounts/{name}/api/v1/secrets/git-providers/{provider}/accounts/{name}/activateThe existing
POST /api/v1/secrets/git-providers(bare provider→token) would keep working by writing toaccounts["default"]+active="default", so nothing breaks for single-account users.What this issue is not
I deliberately stopped at the model + one method, not a full PR. This type touches the settings/secrets routers, every git search endpoint (
git/repositories/search,git/branches/search,users/git-info), and presumably the Settings UI for account selection — real work, but I wanted to check the shape of the approach first, especially since #10850 and #15492 suggest the settings/integrations area is under active redesign. Happy to build this out (backend + a minimal UI) if the direction looks right, or to adjust if there's a preferred design already in flight.Open questions for maintainers
LLMProfiles) the right mental model, or is per-repository/per-conversation account selection (no global "active" account) closer to what's planned for the settings/integrations redesign?