Skip to content

Commit 21e9f87

Browse files
feat(settings): add verification.critic_api_key with fallback to LLM key (#3494)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 9080470 commit 21e9f87

3 files changed

Lines changed: 136 additions & 3 deletions

File tree

openhands-sdk/openhands/sdk/settings/model.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
decrypt_str_with_cipher_or_keep,
4949
resolve_expose_mode,
5050
serialize_secret,
51+
validate_secret,
5152
validate_secret_dict,
5253
)
5354
from openhands.sdk.utils.redact import sanitize_dict
@@ -353,6 +354,34 @@ class VerificationSettings(BaseModel):
353354
).model_dump()
354355
},
355356
)
357+
critic_api_key: str | SecretStr | None = Field(
358+
default=None,
359+
description=(
360+
"API key used to authenticate with the critic service. "
361+
"When None, the LLM's ``api_key`` is reused, which preserves "
362+
"the auto-configuration path for the All-Hands LLM proxy."
363+
),
364+
json_schema_extra={
365+
SETTINGS_METADATA_KEY: SettingsFieldMetadata(
366+
label="Critic API Key",
367+
prominence=SettingProminence.CRITICAL,
368+
depends_on=("critic_enabled",),
369+
).model_dump()
370+
},
371+
)
372+
373+
@field_validator("critic_api_key")
374+
@classmethod
375+
def _validate_critic_api_key(
376+
cls, v: str | SecretStr | None, info: ValidationInfo
377+
) -> SecretStr | None:
378+
return validate_secret(v, info)
379+
380+
@field_serializer("critic_api_key", when_used="always")
381+
def _serialize_critic_api_key(
382+
self, v: SecretStr | None, info: SerializationInfo
383+
) -> Any:
384+
return serialize_secret(v, info)
356385

357386

358387
def _default_llm_settings() -> LLM:
@@ -913,8 +942,15 @@ def build_condenser(self, llm: LLM) -> LLMSummarizingCondenser | None:
913942
def build_critic(self) -> CriticBase | None:
914943
"""Create an :class:`APIBasedCritic` from these settings.
915944
916-
Returns ``None`` when the critic is disabled or when the LLM
917-
has no ``api_key`` (the critic service requires authentication).
945+
Returns ``None`` when the critic is disabled or when no API key
946+
is available (the critic service requires authentication).
947+
948+
If ``verification.critic_api_key`` is set it is used to
949+
authenticate with the critic service; otherwise the LLM's
950+
``api_key`` is reused. This preserves the existing
951+
auto-configuration path for the All-Hands LLM proxy while
952+
letting deployments route the critic through a different
953+
provider (e.g. an LLM proxy with its own credential).
918954
919955
If ``verification.critic_server_url`` or
920956
``verification.critic_model_name`` are set they override the
@@ -924,7 +960,7 @@ def build_critic(self) -> CriticBase | None:
924960
if not self.verification.critic_enabled:
925961
return None
926962

927-
api_key = self.llm.api_key
963+
api_key = self.verification.critic_api_key or self.llm.api_key
928964
if api_key is None:
929965
return None
930966

tests/sdk/agent/test_acp_agent.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,23 @@
6262
# ---------------------------------------------------------------------------
6363

6464

65+
class _FakeLookupSecret(SecretSource):
66+
"""Module-level stand-in for ``LookupSecret`` used by registry tests.
67+
68+
Defined at module scope (not inside a test method) so its ``__qualname__``
69+
does not contain ``<locals>``. ``DiscriminatedUnionMixin`` rejects
70+
subclasses whose qualname contains ``<locals>`` during ``SecretSource``
71+
union validation, and any such local subclass leaks into the global
72+
``__subclasses__`` registry — breaking unrelated serialization tests
73+
that run later on the same xdist worker.
74+
"""
75+
76+
stored_value: str
77+
78+
def get_value(self) -> str | None:
79+
return self.stored_value
80+
81+
6582
def _make_agent(**kwargs) -> ACPAgent:
6683
return ACPAgent(acp_command=["echo", "test"], **kwargs)
6784

tests/sdk/test_settings.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,15 @@ def test_llm_agent_settings_export_schema_groups_sections() -> None:
122122
is SettingProminence.CRITICAL
123123
)
124124

125+
# The critic API key must surface in the schema as a CRITICAL, secret
126+
# field that depends on critic_enabled — this is what the GUI uses to
127+
# render a masked input gated on the toggle.
128+
critic_api_key = v_fields["verification.critic_api_key"]
129+
assert critic_api_key.secret is True
130+
assert critic_api_key.value_type == "string"
131+
assert critic_api_key.prominence is SettingProminence.CRITICAL
132+
assert critic_api_key.depends_on == ["verification.critic_enabled"]
133+
125134

126135
def test_acp_agent_settings_export_schema_has_acp_section() -> None:
127136
schema = ACPAgentSettings.export_schema()
@@ -554,6 +563,77 @@ def test_llm_create_agent_no_critic_without_api_key() -> None:
554563
assert agent.critic is None
555564

556565

566+
def test_llm_create_agent_critic_uses_explicit_api_key() -> None:
567+
"""When ``verification.critic_api_key`` is set, the critic authenticates
568+
with it instead of the LLM key. The LLM's own key is preserved untouched
569+
so the main agent loop still talks to its provider."""
570+
settings = OpenHandsAgentSettings(
571+
llm=LLM(model="m", api_key=SecretStr("llm-key")),
572+
verification=VerificationSettings(
573+
critic_enabled=True,
574+
critic_api_key=SecretStr("critic-key"),
575+
),
576+
)
577+
agent = settings.create_agent()
578+
assert isinstance(agent.critic, APIBasedCritic)
579+
assert isinstance(agent.critic.api_key, SecretStr)
580+
assert agent.critic.api_key.get_secret_value() == "critic-key"
581+
# LLM key unaffected.
582+
assert isinstance(agent.llm.api_key, SecretStr)
583+
assert agent.llm.api_key.get_secret_value() == "llm-key"
584+
585+
586+
def test_llm_create_agent_critic_falls_back_to_llm_api_key() -> None:
587+
"""Without ``verification.critic_api_key``, the legacy behavior holds:
588+
the critic reuses the LLM key (auto-config path for the All-Hands proxy)."""
589+
settings = OpenHandsAgentSettings(
590+
llm=LLM(model="m", api_key=SecretStr("llm-key")),
591+
verification=VerificationSettings(critic_enabled=True),
592+
)
593+
agent = settings.create_agent()
594+
assert isinstance(agent.critic, APIBasedCritic)
595+
assert isinstance(agent.critic.api_key, SecretStr)
596+
assert agent.critic.api_key.get_secret_value() == "llm-key"
597+
598+
599+
def test_llm_create_agent_critic_with_only_critic_api_key() -> None:
600+
"""If the LLM has no key but ``critic_api_key`` is supplied, the critic
601+
is still built — its credential is independent of the LLM's."""
602+
settings = OpenHandsAgentSettings(
603+
llm=LLM(model="m", api_key=None),
604+
verification=VerificationSettings(
605+
critic_enabled=True,
606+
critic_api_key=SecretStr("critic-only-key"),
607+
),
608+
)
609+
agent = settings.create_agent()
610+
assert isinstance(agent.critic, APIBasedCritic)
611+
assert isinstance(agent.critic.api_key, SecretStr)
612+
assert agent.critic.api_key.get_secret_value() == "critic-only-key"
613+
614+
615+
def test_verification_settings_critic_api_key_roundtrip() -> None:
616+
"""``critic_api_key`` survives dump → validate when secrets are exposed,
617+
and validates from both plain strings and SecretStr inputs."""
618+
settings = VerificationSettings(
619+
critic_enabled=True,
620+
critic_api_key="plain-string-key",
621+
)
622+
assert isinstance(settings.critic_api_key, SecretStr)
623+
assert settings.critic_api_key.get_secret_value() == "plain-string-key"
624+
625+
dumped = settings.model_dump(context={"expose_secrets": "plaintext"})
626+
assert dumped["critic_api_key"] == "plain-string-key"
627+
628+
restored = VerificationSettings.model_validate(dumped)
629+
assert isinstance(restored.critic_api_key, SecretStr)
630+
assert restored.critic_api_key.get_secret_value() == "plain-string-key"
631+
632+
# Empty strings normalize to None (consistent with LLM.api_key handling).
633+
empty = VerificationSettings(critic_enabled=True, critic_api_key="")
634+
assert empty.critic_api_key is None
635+
636+
557637
def test_llm_create_agent_critic_with_iterative_refinement() -> None:
558638
settings = OpenHandsAgentSettings(
559639
llm=LLM(model="m", api_key=SecretStr("k")),

0 commit comments

Comments
 (0)