From a14a382b2fc5ae33a9f6733f25e83c544d31530f Mon Sep 17 00:00:00 2001 From: Veritas-7 <234569343+Veritas-7@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:22:20 +0900 Subject: [PATCH] fix(delegation): honor api_key_env for direct endpoints --- tests/tools/test_delegate.py | 35 ++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 37 ++++++++++++++++++++++-------------- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 911025e9993b..61eb22bc2d82 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1024,6 +1024,41 @@ def test_direct_endpoint_returns_none_api_key_when_not_configured(self): self.assertIsNone(creds["api_key"]) self.assertEqual(creds["provider"], "custom") + def test_direct_endpoint_uses_configured_api_key_env(self): + # Direct endpoints can be non-OpenAI providers. When api_key_env is set, + # delegation must use that key instead of inheriting the parent's key. + parent = _make_mock_parent(depth=0) + cfg = { + "model": "glm-5.2", + "provider": "custom", + "base_url": "https://api.z.ai/api/coding/paas/v4", + "api_key_env": "GLM_API_KEY", + } + with patch.dict(os.environ, {"GLM_API_KEY": "glm-key-from-env"}, clear=False): + creds = _resolve_delegation_credentials(cfg, parent) + self.assertEqual(creds["api_key"], "glm-key-from-env") + self.assertEqual(creds["provider"], "custom") + self.assertEqual(creds["base_url"], "https://api.z.ai/api/coding/paas/v4") + + def test_direct_endpoint_api_key_env_missing_or_empty_raises(self): + # If the user explicitly configured api_key_env, falling back to the + # parent's unrelated key would hide the real setup error and usually + # fail later as a confusing 401 from the custom endpoint. + parent = _make_mock_parent(depth=0) + cfg = { + "model": "glm-5.2", + "provider": "custom", + "base_url": "https://api.z.ai/api/coding/paas/v4", + "api_key_env": "GLM_API_KEY", + } + for env in ({}, {"GLM_API_KEY": ""}, {"GLM_API_KEY": " "}): + with self.subTest(env=env): + with patch.dict(os.environ, env, clear=True): + with self.assertRaises(ValueError) as ctx: + _resolve_delegation_credentials(cfg, parent) + self.assertIn("delegation.api_key_env", str(ctx.exception)) + self.assertIn("GLM_API_KEY", str(ctx.exception)) + def test_direct_endpoint_no_raise_when_only_provider_env_key_present(self): # Even if OPENAI_API_KEY is absent, no ValueError — _build_child_agent uses parent key. parent = _make_mock_parent(depth=0) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b89e7f8dbbd3..1b0e9769ebc8 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2652,12 +2652,12 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: """Resolve credentials for subagent delegation. If ``delegation.base_url`` is configured, subagents use that direct - OpenAI-compatible endpoint. ``delegation.api_key`` overrides the key; when - omitted, ``api_key`` is returned as ``None`` so ``_build_child_agent`` - inherits the parent agent's key (``effective_api_key = override_api_key or - parent_api_key``). This lets providers that store their key outside - ``OPENAI_API_KEY`` (e.g. ``MINIMAX_API_KEY``, ``DASHSCOPE_API_KEY``) work - without a duplicate config entry. + OpenAI-compatible endpoint. ``delegation.api_key`` overrides the key; + ``delegation.api_key_env`` resolves a provider-specific environment key and + fails fast when that key is missing. When neither key field is configured, + ``api_key`` is returned as ``None`` so ``_build_child_agent`` inherits the + parent agent's key (``effective_api_key = override_api_key or + parent_api_key``). Otherwise, if ``delegation.provider`` is configured, the full credential bundle (base_url, api_key, api_mode, provider) is resolved via the runtime @@ -2673,16 +2673,25 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: configured_provider = str(cfg.get("provider") or "").strip() or None configured_base_url = str(cfg.get("base_url") or "").strip() or None configured_api_key = str(cfg.get("api_key") or "").strip() or None + configured_api_key_env = str(cfg.get("api_key_env") or "").strip() or None configured_api_mode = str(cfg.get("api_mode") or "").strip().lower() or None if configured_base_url: - # When delegation.api_key is not set, return None so _build_child_agent - # falls back to the parent agent's API key via the credential inheritance - # path (effective_api_key = override_api_key or parent_api_key). This - # lets providers that store their key in a non-OPENAI_API_KEY env var - # (e.g. MINIMAX_API_KEY, DASHSCOPE_API_KEY) work without requiring - # callers to duplicate the key under delegation.api_key. - api_key = configured_api_key # None → inherited from parent in _build_child_agent + # Direct endpoints may need a provider-specific env key (for example + # GLM_API_KEY for Z.AI coding-plan). Prefer an explicit api_key, then + # delegation.api_key_env, and only inherit the parent key when neither + # is configured. Without this, a custom delegation endpoint can receive + # the parent's unrelated key and fail with 401. + resolved_key = configured_api_key + if not resolved_key and configured_api_key_env: + env_value = (os.getenv(configured_api_key_env) or "").strip() + if not env_value: + raise ValueError( + "delegation.api_key_env is set to " + f"{configured_api_key_env!r}, but that environment " + "variable is missing or empty" + ) + resolved_key = env_value # Use the shared URL-based api_mode detector (same path the main agent's # runtime resolver uses) so Anthropic-compatible direct endpoints with a @@ -2717,7 +2726,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "model": configured_model, "provider": provider, "base_url": configured_base_url, - "api_key": api_key, + "api_key": resolved_key, "api_mode": api_mode, }