Skip to content
Closed
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
35 changes: 28 additions & 7 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,11 @@ def _extract_url_query_params(url: str):

def _normalize_aux_provider(provider: Optional[str]) -> str:
normalized = (provider or "auto").strip().lower()
if normalized.startswith("custom:"):
suffix = normalized.split(":", 1)[1].strip()
if not suffix:
return "custom"
normalized = suffix
# Named custom providers (e.g. "custom:qwen") are preserved intact so
# resolve_provider_client can route them through the named-custom-provider
# branch which reads custom_providers from config.yaml. Bare "custom"
# (no colon) still falls through to the anonymous-custom path that reads
# model.base_url + OPENAI_API_KEY from config/env.
if normalized == "codex":
return "openai-codex"
if normalized == "main":
Expand Down Expand Up @@ -2285,8 +2285,29 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[st

custom_base = custom_base.strip().rstrip("/")
if base_url_host_matches(custom_base, "openrouter.ai"):
# requested='custom' falls back to OpenRouter when no custom endpoint is
# configured. Treat that as "no custom endpoint" for auxiliary routing.
# requested='custom' fell back to OpenRouter because the user has a
# named custom provider (e.g. custom:qwen in custom_providers) but no
# bare "custom" entry. Try resolving with requested=None so the
# resolver picks up the user's actual main provider from config.
try:
main_runtime = resolve_runtime_provider(requested=None)
if isinstance(main_runtime, dict):
mb = main_runtime.get("base_url")
mk = main_runtime.get("api_key")
if isinstance(mb, str) and mb.strip():
mb = mb.strip().rstrip("/")
if not base_url_host_matches(mb, "openrouter.ai"):
logger.debug(
"Auxiliary client: custom runtime fell back to main "
"provider %s → %s",
main_runtime.get("provider", "unknown"), mb,
)
if not isinstance(mk, str) or not mk.strip():
mk = "no-key-required"
mm = main_runtime.get("api_mode")
return mb, mk.strip(), mm.strip() if isinstance(mm, str) and mm.strip() else None
except Exception:
pass
return None, None, None

# Local servers (Ollama, llama.cpp, vLLM, LM Studio) don't require auth.
Expand Down
16 changes: 16 additions & 0 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,22 @@ def test_maps_github_copilot_acp_aliases(self):
assert _normalize_aux_provider("github-copilot-acp") == "copilot-acp"
assert _normalize_aux_provider("copilot-acp-agent") == "copilot-acp"

def test_named_custom_providers_preserved(self):
"""Named custom providers (custom:xxx) are preserved intact so
resolve_provider_client can route them through the named-custom-provider
branch. Bare 'custom' (no colon) stays as 'custom' for the anonymous path.

Regression test: the old strip-to-custom approach (#37182) fixed a bug where
'custom:qwen' was normalised to 'qwen' (no auxiliary backend), but it broke
named custom providers in auxiliary tasks (vision, goal_judge, etc.) because
the strip prevented the named-custom-provider lookup from ever running.
"""
assert _normalize_aux_provider("custom:qwen") == "custom:qwen"
assert _normalize_aux_provider("custom:my-vllm") == "custom:my-vllm"
assert _normalize_aux_provider("custom:local") == "custom:local"
assert _normalize_aux_provider("custom:") == "custom:"
assert _normalize_aux_provider("custom") == "custom"


class TestReadCodexAccessToken:
def test_valid_auth_store(self, tmp_path, monkeypatch):
Expand Down
4 changes: 3 additions & 1 deletion tests/agent/test_auxiliary_named_custom_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ def test_bare_provider_name_unchanged(self):
assert _normalize_vision_provider("deepseek") == "deepseek"

def test_custom_colon_named_provider_preserved(self):
"""custom:name is preserved intact so the named-custom-provider branch
can resolve it through custom_providers. (Not stripped to bare name.)"""
from agent.auxiliary_client import _normalize_vision_provider
assert _normalize_vision_provider("custom:beans") == "beans"
assert _normalize_vision_provider("custom:beans") == "custom:beans"

def test_codex_alias_still_works(self):
from agent.auxiliary_client import _normalize_vision_provider
Expand Down