Skip to content

Commit a4f7924

Browse files
committed
fix(sdk): split Codex ACP model config options
1 parent 263a782 commit a4f7924

2 files changed

Lines changed: 118 additions & 32 deletions

File tree

openhands-sdk/openhands/sdk/agent/acp_agent.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,33 @@ def _codex_base_url_overrides(
357357
return ["-c", f'openai_base_url="{base_url}"']
358358

359359

360+
_CODEX_REASONING_EFFORTS: Final[frozenset[str]] = frozenset(
361+
{"low", "medium", "high", "xhigh"}
362+
)
363+
364+
365+
def _codex_model_config_options(acp_model: str) -> tuple[tuple[str, str], ...]:
366+
"""Map Canvas Codex model IDs to codex-acp config options.
367+
368+
codex-acp 0.16 exposes base model and reasoning effort as separate config
369+
options. Agent Canvas keeps a single stable model id such as
370+
``gpt-5.5/high``. Sending that combined id as the raw ``model`` option is
371+
accepted at config time but fails the next prompt for ChatGPT-auth sessions.
372+
"""
373+
base_model, sep, effort = acp_model.rpartition("/")
374+
if sep and base_model and effort in _CODEX_REASONING_EFFORTS:
375+
return (("model", base_model), ("reasoning_effort", effort))
376+
return (("model", acp_model),)
377+
378+
379+
def _model_config_options(
380+
provider: ACPProviderInfo | None, acp_model: str
381+
) -> tuple[tuple[str, str], ...]:
382+
if provider is not None and provider.key == "codex":
383+
return _codex_model_config_options(acp_model)
384+
return (("model", acp_model),)
385+
386+
360387
def _write_secret_file(path: Path, value: str) -> None:
361388
"""Write ``value`` to ``path`` as a ``0600`` file.
362389
@@ -584,11 +611,12 @@ async def _apply_acp_model(
584611
"""Apply a model with the provider's configured ACP model-selection method."""
585612
if provider is not None:
586613
if provider.model_selection_method == "set_config_option":
587-
await conn.set_config_option(
588-
config_id="model",
589-
value=acp_model,
590-
session_id=session_id,
591-
)
614+
for config_id, value in _model_config_options(provider, acp_model):
615+
await conn.set_config_option(
616+
config_id=config_id,
617+
value=value,
618+
session_id=session_id,
619+
)
592620
logger.info(
593621
"Set model %r on ACP provider %s via set_config_option",
594622
acp_model,
@@ -3467,9 +3495,18 @@ def set_acp_model(self, model: str) -> None:
34673495
provider is not None
34683496
and provider.model_selection_method == "set_config_option"
34693497
):
3470-
model_selection_call = self._conn.set_config_option(
3471-
config_id="model", value=model, session_id=self._session_id
3472-
)
3498+
3499+
async def _set_config_options() -> None:
3500+
assert self._conn is not None
3501+
assert self._session_id is not None
3502+
for config_id, value in _model_config_options(provider, model):
3503+
await self._conn.set_config_option(
3504+
config_id=config_id,
3505+
value=value,
3506+
session_id=self._session_id,
3507+
)
3508+
3509+
model_selection_call = _set_config_options()
34733510
else:
34743511
model_selection_call = self._conn.set_session_model(
34753512
model_id=model, session_id=self._session_id

tests/sdk/agent/test_acp_agent.py

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from concurrent.futures import Future
1111
from pathlib import Path
1212
from typing import Any
13-
from unittest.mock import AsyncMock, MagicMock, patch
13+
from unittest.mock import AsyncMock, MagicMock, call, patch
1414

1515
import pytest
1616
from acp.exceptions import RequestError as ACPRequestError
@@ -22,6 +22,7 @@
2222
_classify_acp_init_error,
2323
_codex_auth_file,
2424
_codex_base_url_overrides,
25+
_codex_model_config_options,
2526
_estimate_cost_from_tokens,
2627
_extract_session_models,
2728
_extract_token_usage,
@@ -4419,24 +4420,44 @@ def test_noop_when_caller_already_set_provider(self):
44194420
)
44204421

44214422

4423+
class TestCodexModelConfigOptions:
4424+
def test_splits_combined_model_and_reasoning_effort(self):
4425+
assert _codex_model_config_options("gpt-5.5/high") == (
4426+
("model", "gpt-5.5"),
4427+
("reasoning_effort", "high"),
4428+
)
4429+
4430+
def test_leaves_base_or_custom_model_id_unchanged(self):
4431+
assert _codex_model_config_options("gpt-5.5") == (("model", "gpt-5.5"),)
4432+
assert _codex_model_config_options("custom/provider/model") == (
4433+
("model", "custom/provider/model"),
4434+
)
4435+
4436+
44224437
# ---------------------------------------------------------------------------
44234438
# ACP model overrides
44244439
# ---------------------------------------------------------------------------
44254440

44264441

44274442
class TestMaybeSetSessionModel:
44284443
@pytest.mark.asyncio
4429-
async def test_codex_agent_uses_config_option_model_override(self):
4444+
async def test_codex_agent_splits_model_and_reasoning_effort(self):
44304445
conn = AsyncMock()
44314446
applied = await _maybe_set_session_model(
4432-
conn, "codex-acp", "session-1", "gpt-5.4"
4447+
conn, "codex-acp", "session-1", "gpt-5.4/low"
44334448
)
44344449
conn.set_session_model.assert_not_called()
4435-
conn.set_config_option.assert_awaited_once_with(
4436-
config_id="model",
4437-
value="gpt-5.4",
4438-
session_id="session-1",
4450+
conn.set_config_option.assert_has_awaits(
4451+
[
4452+
call(config_id="model", value="gpt-5.4", session_id="session-1"),
4453+
call(
4454+
config_id="reasoning_effort",
4455+
value="low",
4456+
session_id="session-1",
4457+
),
4458+
]
44394459
)
4460+
assert conn.set_config_option.await_count == 2
44404461
# The override was actually pushed to the server via the protocol call.
44414462
assert applied is True
44424463

@@ -4525,11 +4546,17 @@ async def test_codex_reapplies_persisted_model_on_resume(self):
45254546
conn, "codex-acp", "sess-1", "gpt-5.4/low"
45264547
)
45274548
conn.set_session_model.assert_not_called()
4528-
conn.set_config_option.assert_awaited_once_with(
4529-
config_id="model",
4530-
value="gpt-5.4/low",
4531-
session_id="sess-1",
4549+
conn.set_config_option.assert_has_awaits(
4550+
[
4551+
call(config_id="model", value="gpt-5.4", session_id="sess-1"),
4552+
call(
4553+
config_id="reasoning_effort",
4554+
value="low",
4555+
session_id="sess-1",
4556+
),
4557+
]
45324558
)
4559+
assert conn.set_config_option.await_count == 2
45334560
assert applied is True
45344561

45354562
@pytest.mark.asyncio
@@ -4623,22 +4650,36 @@ class TestSetACPModel:
46234650
@staticmethod
46244651
def _wire(agent: ACPAgent, agent_name: str) -> ACPAgent:
46254652
agent._conn = MagicMock()
4653+
agent._conn.set_config_option = AsyncMock()
4654+
agent._conn.set_session_model = AsyncMock()
46264655
agent._session_id = "sess-1"
46274656
agent._agent_name = agent_name
4657+
4658+
def run_async(awaitable, timeout=None):
4659+
if asyncio.iscoroutine(awaitable):
4660+
return asyncio.run(awaitable)
4661+
return awaitable
4662+
46284663
executor = MagicMock()
4629-
executor.run_async = MagicMock()
4664+
executor.run_async = MagicMock(side_effect=run_async)
46304665
agent._executor = executor
46314666
return agent
46324667

46334668
def test_switches_model_on_live_codex_session(self):
46344669
agent = self._wire(_make_agent(), "codex-acp")
46354670
agent.set_acp_model("gpt-5.4/low")
46364671
agent._conn.set_session_model.assert_not_called()
4637-
agent._conn.set_config_option.assert_called_once_with(
4638-
config_id="model",
4639-
value="gpt-5.4/low",
4640-
session_id="sess-1",
4672+
agent._conn.set_config_option.assert_has_awaits(
4673+
[
4674+
call(config_id="model", value="gpt-5.4", session_id="sess-1"),
4675+
call(
4676+
config_id="reasoning_effort",
4677+
value="low",
4678+
session_id="sess-1",
4679+
),
4680+
]
46414681
)
4682+
assert agent._conn.set_config_option.await_count == 2
46424683
agent._executor.run_async.assert_called_once()
46434684
# Sentinel LLM + metrics reflect the live model for cost/token tracking.
46444685
assert agent.llm.model == "gpt-5.4/low"
@@ -4648,7 +4689,7 @@ def test_claude_provider_supports_runtime_switch(self):
46484689
agent = self._wire(_make_agent(), "claude-agent-acp")
46494690
agent.set_acp_model("claude-haiku-4-5-20251001")
46504691
agent._conn.set_session_model.assert_not_called()
4651-
agent._conn.set_config_option.assert_called_once_with(
4692+
agent._conn.set_config_option.assert_awaited_once_with(
46524693
config_id="model",
46534694
value="claude-haiku-4-5-20251001",
46544695
session_id="sess-1",
@@ -4659,7 +4700,7 @@ def test_unknown_provider_still_attempts_switch(self):
46594700
# the call; the ACP layer errors if it isn't actually supported.
46604701
agent = self._wire(_make_agent(), "some-custom-acp")
46614702
agent.set_acp_model("whatever")
4662-
agent._conn.set_session_model.assert_called_once()
4703+
agent._conn.set_session_model.assert_awaited_once()
46634704

46644705
def test_rejects_empty_model(self):
46654706
agent = self._wire(_make_agent(), "codex-acp")
@@ -4701,9 +4742,13 @@ def test_translates_acp_request_error_to_value_error(self):
47014742
# or an invalid model id) must surface as a ValueError — not leak as a
47024743
# raw acp.exceptions.RequestError — so the agent-server maps it to 400.
47034744
agent = self._wire(_make_agent(), "codex-acp")
4704-
agent._executor.run_async.side_effect = ACPRequestError(
4705-
code=-32601, message="method not found"
4706-
)
4745+
4746+
def reject(awaitable, timeout=None):
4747+
if asyncio.iscoroutine(awaitable):
4748+
awaitable.close()
4749+
raise ACPRequestError(code=-32601, message="method not found")
4750+
4751+
agent._executor.run_async.side_effect = reject
47074752
with pytest.raises(ValueError, match="rejected model switch"):
47084753
agent.set_acp_model("bogus-model")
47094754
# The sentinel LLM must not be mutated when the switch fails.
@@ -4715,9 +4760,13 @@ def test_propagates_server_internal_error(self):
47154760
# than be mislabeled as a 400-class ValueError, mirroring the retriable
47164761
# handling on the prompt path.
47174762
agent = self._wire(_make_agent(), "codex-acp")
4718-
agent._executor.run_async.side_effect = ACPRequestError(
4719-
code=-32603, message="internal error"
4720-
)
4763+
4764+
def fail_internal(awaitable, timeout=None):
4765+
if asyncio.iscoroutine(awaitable):
4766+
awaitable.close()
4767+
raise ACPRequestError(code=-32603, message="internal error")
4768+
4769+
agent._executor.run_async.side_effect = fail_internal
47214770
with pytest.raises(ACPRequestError):
47224771
agent.set_acp_model("some-model")
47234772
# The sentinel LLM must not be mutated when the switch fails.

0 commit comments

Comments
 (0)