Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions openhands-sdk/openhands/sdk/agent/acp_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ def _make_dummy_llm() -> LLM:
"gemini-api-key": "GEMINI_API_KEY",
}
_CHATGPT_AUTH_PATH = Path(".codex") / "auth.json"
# Gemini CLI personal (Google OAuth) login, cached by ``gemini login`` /
# ``gemini --acp``. Its presence lets us select the server's ``oauth-personal``
# auth method without an API key (mirrors the ChatGPT subscription path).
_GEMINI_OAUTH_PATH = Path(".gemini") / "oauth_creds.json"


def _select_auth_method(
Expand All @@ -186,15 +190,23 @@ def _select_auth_method(
Returns the ``id`` of the first matching method, or ``None`` if no
supported credential source is available (the server may not require auth).

ChatGPT subscription login (device-code flow stored in
``~/.codex/auth.json``) is checked first so it takes precedence over
explicit API keys, which serve as the fallback.
Subscription / OAuth logins (whose cached credential file is present) are
checked first so they take precedence over explicit API keys, which serve
as the fallback:

- ``chatgpt`` (codex-acp) — ``~/.codex/auth.json``
- ``oauth-personal`` (gemini-cli) — ``~/.gemini/oauth_creds.json``

In a server image these files are absent (no interactive login), so the
API-key fallback (e.g. ``GEMINI_API_KEY``) is used instead.
"""
method_ids = {m.id for m in auth_methods}
# Prefer ChatGPT subscription login when the auth file is present.
if "chatgpt" in method_ids:
if (Path.home() / _CHATGPT_AUTH_PATH).is_file():
return "chatgpt"
# Prefer subscription / OAuth logins when their cached credential file is
# present.
if "chatgpt" in method_ids and (Path.home() / _CHATGPT_AUTH_PATH).is_file():
return "chatgpt"
if "oauth-personal" in method_ids and (Path.home() / _GEMINI_OAUTH_PATH).is_file():
return "oauth-personal"
# Fall back to explicit API key env vars.
for method_id, env_var in _AUTH_METHOD_ENV_MAP.items():
if method_id in method_ids and env_var in env:
Expand Down
48 changes: 48 additions & 0 deletions tests/sdk/agent/test_acp_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3161,6 +3161,54 @@ def test_chatgpt_auth_file(self, tmp_path):
with patch("openhands.sdk.agent.acp_agent.Path.home", return_value=tmp_path):
assert _select_auth_method(methods, {}) == "chatgpt"

def test_gemini_oauth_personal_when_creds_file_present(self, tmp_path):
"""gemini-cli's OAuth login is selected when ~/.gemini/oauth_creds.json
exists, with no GEMINI_API_KEY needed."""
methods = [
self._make_auth_method("oauth-personal"),
self._make_auth_method("gemini-api-key"),
]
gem_dir = tmp_path / ".gemini"
gem_dir.mkdir()
(gem_dir / "oauth_creds.json").write_text("{}", encoding="utf-8")

with patch("openhands.sdk.agent.acp_agent.Path.home", return_value=tmp_path):
assert _select_auth_method(methods, {}) == "oauth-personal"

def test_gemini_oauth_preferred_over_api_key(self, tmp_path):
"""OAuth login takes precedence over GEMINI_API_KEY (mirrors chatgpt)."""
methods = [
self._make_auth_method("oauth-personal"),
self._make_auth_method("gemini-api-key"),
]
gem_dir = tmp_path / ".gemini"
gem_dir.mkdir()
(gem_dir / "oauth_creds.json").write_text("{}", encoding="utf-8")

env = {"GEMINI_API_KEY": "g-test"}
with patch("openhands.sdk.agent.acp_agent.Path.home", return_value=tmp_path):
assert _select_auth_method(methods, env) == "oauth-personal"

def test_gemini_api_key_fallback_when_no_oauth_file(self, tmp_path):
"""Falls back to GEMINI_API_KEY when oauth-personal is offered but the
creds file is absent (e.g. in a server image)."""
methods = [
self._make_auth_method("oauth-personal"),
self._make_auth_method("gemini-api-key"),
]
env = {"GEMINI_API_KEY": "g-test"}
with patch("openhands.sdk.agent.acp_agent.Path.home", return_value=tmp_path):
assert _select_auth_method(methods, env) == "gemini-api-key"

def test_gemini_oauth_offered_but_no_creds_no_key(self, tmp_path):
"""oauth-personal offered, no creds file and no API key -> None."""
methods = [
self._make_auth_method("oauth-personal"),
self._make_auth_method("gemini-api-key"),
]
with patch("openhands.sdk.agent.acp_agent.Path.home", return_value=tmp_path):
assert _select_auth_method(methods, {}) is None

def test_empty_auth_methods(self):
assert _select_auth_method([], {}) is None

Expand Down
Loading