From cba9975aba62d526211f4e55ad471a33158eb13e Mon Sep 17 00:00:00 2001 From: Chris Korhonen <39121+ckorhonen@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:32:53 -0400 Subject: [PATCH] fix: preserve custom provider output caps Preserve per-provider max_output_tokens through config normalization and runtime delegation resolution so Spark/vLLM subagents do not request the default 65K output budget against a 128K context window. Add a chat-completions preflight clamp for input+output budgets and regressions for vLLM output-cap handling. --- agent/chat_completion_helpers.py | 5 + agent/model_metadata.py | 12 +- agent/transports/chat_completions.py | 93 ++++++++++++++ hermes_cli/config.py | 15 ++- .../test_provider_config_validation.py | 44 +++++++ .../test_runtime_provider_resolution.py | 46 +++++++ tests/test_ctx_halving_fix.py | 44 +++++++ tests/tools/test_delegate.py | 119 ++++++++++++++++++ tools/delegate_tool.py | 37 +++++- 9 files changed, 410 insertions(+), 5 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 4b000372b394..eeda39b1ee1e 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -786,6 +786,9 @@ def build_api_kwargs(agent, api_messages: list) -> dict: "promptId": str(uuid.uuid4()), } + _ctx_len = getattr(agent, "context_compressor", None) + _ctx_len = _ctx_len.context_length if _ctx_len else None + # ── Provider profile path (registered providers) ─────────────────── # Profiles handle per-provider quirks via hooks. When a profile is # found, delegate fully; otherwise fall through to the legacy flag path. @@ -814,6 +817,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: max_tokens=agent.max_tokens, ephemeral_max_output_tokens=_ephemeral_out, max_tokens_param_fn=agent._max_tokens_param, + context_length=_ctx_len, reasoning_config=agent.reasoning_config, request_overrides=agent.request_overrides, session_id=getattr(agent, "session_id", None), @@ -846,6 +850,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: max_tokens=agent.max_tokens, ephemeral_max_output_tokens=_ephemeral_out, max_tokens_param_fn=agent._max_tokens_param, + context_length=_ctx_len, reasoning_config=agent.reasoning_config, request_overrides=agent.request_overrides, session_id=getattr(agent, "session_id", None), diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 726c3300a90f..154c7c75edf4 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1228,10 +1228,18 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]: # the window this stays None, so the caller correctly falls through to # compression instead of futilely shrinking the output cap. _m_vllm_input = re.search( - r'prompt contains (?:at least )?(\d+)\s*input tokens', error_lower + r'prompt contains (?:(at least) )?(\d+)\s*input tokens', error_lower ) if _m_ctx_tok and _m_vllm_input: - _available = int(_m_ctx_tok.group(1)) - int(_m_vllm_input.group(1)) + # vLLM's "at least N input tokens" is a lower bound chosen from the + # requested output cap, not the real prompt token count. Treating it + # as exact makes Hermes stair-step max_tokens down by tiny margins + # (65536 → 65471 → 65406 …) until recovery exhausts. Only use exact + # forms; lower-bound forms should fall through to compression/preflight + # instead of pretending we know the available output budget. + if _m_vllm_input.group(1): + return None + _available = int(_m_ctx_tok.group(1)) - int(_m_vllm_input.group(2)) if _available >= 1: return _available diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index ff2cdcbaee69..c38a97364594 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -10,6 +10,7 @@ reasoning configuration, temperature handling, and extra_body assembly. """ import copy +import logging from typing import Any, Dict from agent.lmstudio_reasoning import resolve_lmstudio_effort @@ -19,6 +20,87 @@ from agent.transports.base import ProviderTransport from agent.transports.types import NormalizedResponse, ToolCall, Usage +logger = logging.getLogger(__name__) + +_OUTPUT_CAP_CONTEXT_MARGIN_TOKENS = 1024 + + +def _rough_chars(value: Any) -> int: + if value is None: + return 0 + if isinstance(value, str): + return len(value) + return len(str(value)) + + +def _estimate_input_tokens_for_output_cap(api_kwargs: dict[str, Any]) -> int: + """Cheap estimate of non-output request tokens for max_tokens clamping. + + This deliberately mirrors Hermes' rough char/4 budgeting rather than + provider tokenization. The clamp is a preflight guardrail: if it is still + too optimistic, provider error handling and compression remain as backup. + """ + total_chars = 0 + for key in ("messages", "tools", "extra_body", "response_format"): + total_chars += _rough_chars(api_kwargs.get(key)) + return max(0, total_chars // 4) + + +def _clamp_output_cap_to_context_window( + api_kwargs: dict[str, Any], + *, + context_length: Any, +) -> None: + """Keep requested output budget inside the total context window. + + Providers validate ``input_tokens + max_tokens <= context_length``. Hermes + used to clamp max_tokens only against the model's standalone output limit, + so a half-full 131k local model plus a 65k default output cap failed before + compression had a chance to run. Clamp the outgoing cap using the current + request estimate so long sessions survive model switches and large tools. + """ + try: + ctx = int(context_length or 0) + except (TypeError, ValueError): + return + if ctx <= 0: + return + + output_key = None + requested = None + for key in ("max_output_tokens", "max_completion_tokens", "max_tokens"): + raw = api_kwargs.get(key) + if raw is None: + continue + try: + value = int(raw) + except (TypeError, ValueError): + continue + if value > 0: + output_key = key + requested = value + break + if not output_key or requested is None: + return + + estimated_input = _estimate_input_tokens_for_output_cap(api_kwargs) + available = ctx - estimated_input - _OUTPUT_CAP_CONTEXT_MARGIN_TOKENS + safe_cap = max(1, available) + if requested <= safe_cap: + return + + api_kwargs[output_key] = safe_cap + logger.info( + "Clamped %s from %s to %s for context_length=%s estimated_input=%s margin=%s", + output_key, + requested, + safe_cap, + ctx, + estimated_input, + _OUTPUT_CAP_CONTEXT_MARGIN_TOKENS, + ) + + def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> dict | None: """Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig.""" if reasoning_config is None or not isinstance(reasoning_config, dict): @@ -236,6 +318,7 @@ class ChatCompletionsTransport(ProviderTransport): max_tokens: int | None — user-configured max tokens ephemeral_max_output_tokens: int | None — one-shot override max_tokens_param_fn: callable — returns {max_tokens: N} or {max_completion_tokens: N} + context_length: int | None — total model context window for output-cap clamping reasoning_config: dict | None request_overrides: dict | None session_id: str | None @@ -454,6 +537,11 @@ class ChatCompletionsTransport(ProviderTransport): if overrides: api_kwargs.update(overrides) + _clamp_output_cap_to_context_window( + api_kwargs, + context_length=params.get("context_length"), + ) + return api_kwargs def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params): @@ -596,6 +684,11 @@ class ChatCompletionsTransport(ProviderTransport): if extra_body: api_kwargs["extra_body"] = extra_body + _clamp_output_cap_to_context_window( + api_kwargs, + context_length=params.get("context_length"), + ) + return api_kwargs def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f7fb4c51b12b..ec25b0ccdc3b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -4517,6 +4517,8 @@ def _normalize_custom_provider_entry( "apiKeyEnv": "key_env", # alias — OpenClaw-compatible + docs variant "defaultModel": "default_model", "contextLength": "context_length", + "maxOutputTokens": "max_output_tokens", + "maxTokens": "max_tokens", "rateLimitDelay": "rate_limit_delay", } # api_key_env is a documented snake_case alias for key_env (see @@ -4532,7 +4534,7 @@ def _normalize_custom_provider_entry( "provider", "name", "api", "url", "base_url", "api_key", "key_env", "api_key_env", "api_mode", "transport", "model", "default_model", "models", - "context_length", "rate_limit_delay", + "context_length", "max_output_tokens", "max_tokens", "rate_limit_delay", "request_timeout_seconds", "stale_timeout_seconds", "discover_models", "extra_body", "extra_headers", "ssl_ca_cert", "ssl_verify", @@ -4648,6 +4650,16 @@ def _normalize_custom_provider_entry( if isinstance(context_length, int) and context_length > 0: normalized["context_length"] = context_length + # Per-provider output caps are distinct from context_length: they limit the + # response budget for one turn, while context_length is input + output. + # Normalize both documented and legacy spellings to the canonical field so + # runtime provider resolution can safely pass the cap to AIAgent.max_tokens. + for output_key in ("max_output_tokens", "max_tokens"): + output_cap = entry.get(output_key) + if isinstance(output_cap, int) and output_cap > 0: + normalized["max_output_tokens"] = output_cap + break + rate_limit_delay = entry.get("rate_limit_delay") if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0: normalized["rate_limit_delay"] = rate_limit_delay @@ -4701,6 +4713,7 @@ def _custom_provider_entry_to_provider_config( "key_env", "models", "context_length", + "max_output_tokens", "rate_limit_delay", "discover_models", "extra_body", diff --git a/tests/hermes_cli/test_provider_config_validation.py b/tests/hermes_cli/test_provider_config_validation.py index e8892ae7846a..b0fade12d407 100644 --- a/tests/hermes_cli/test_provider_config_validation.py +++ b/tests/hermes_cli/test_provider_config_validation.py @@ -10,6 +10,7 @@ import pytest from hermes_cli.config import ( _PROVIDER_NORMALIZE_WARNED, + _custom_provider_entry_to_provider_config, _normalize_custom_provider_entry, ) @@ -239,6 +240,49 @@ class TestNormalizeCustomProviderEntry: assert result is not None assert "models" not in result + def test_max_output_tokens_preserved(self, caplog): + """Per-provider output caps must survive compatibility normalization. + + Regression: ``custom_providers[].max_output_tokens`` was accepted by + runtime resolution, but the compatibility normalizer stripped it before + the runtime resolver could lift it onto ``AIAgent.max_tokens``. + """ + entry = { + "name": "spark-qwen", + "base_url": "http://spark.local:8001/v1", + "max_output_tokens": 16_384, + } + with caplog.at_level(logging.WARNING): + result = _normalize_custom_provider_entry(entry) + assert result is not None + assert result["max_output_tokens"] == 16_384 + assert not any("unknown config keys" in r.message.lower() for r in caplog.records) + + def test_legacy_max_tokens_normalized_to_max_output_tokens(self): + """Legacy provider-local ``max_tokens`` maps to the canonical output cap.""" + entry = { + "name": "spark-qwen", + "base_url": "http://spark.local:8001/v1", + "max_tokens": 8_192, + } + result = _normalize_custom_provider_entry(entry) + assert result is not None + assert result["max_output_tokens"] == 8_192 + assert "max_tokens" not in result + + def test_max_output_tokens_preserved_when_migrating_to_providers_shape(self): + """Legacy custom-provider migration must not drop output caps.""" + result = _custom_provider_entry_to_provider_config( + { + "name": "spark-qwen", + "base_url": "http://spark.local:8001/v1", + "max_output_tokens": 16_384, + }, + provider_key="spark-qwen", + ) + assert result is not None + assert result["max_output_tokens"] == 16_384 + def test_env_var_placeholder_in_base_url_not_rejected(self): """A base_url that is an un-expanded ${ENV_VAR} placeholder must not be rejected as an invalid URL — it is expanded at runtime, so a caller diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index df47efcf8396..4e5622253346 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -848,6 +848,52 @@ def test_named_custom_provider_uses_saved_credentials(monkeypatch): assert resolved["source"] == "custom_provider:Local" +def test_named_custom_provider_preserves_legacy_list_output_cap(monkeypatch): + """Legacy custom provider caps must reach runtime provider resolution. + + Spark/vLLM workers need this cap so an uncapped Codex parent does not make + the child request the transport default 65K output budget against a 128K + context window. + """ + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "custom_providers": [ + { + "name": "spark-qwen", + "base_url": "http://spark.local:8001/v1", + "api_key": "spark-key", + "api_mode": "chat_completions", + "model": "/models/Qwen3.6-35B-A3B-NVFP4", + "context_length": 131_072, + "max_output_tokens": 16_384, + } + ] + }, + ) + monkeypatch.setattr( + rp, + "resolve_provider", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError( + "resolve_provider should not be called for named custom providers" + ) + ), + ) + + resolved = rp.resolve_runtime_provider(requested="custom:spark-qwen") + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "chat_completions" + assert resolved["base_url"] == "http://spark.local:8001/v1" + assert resolved["api_key"] == "spark-key" + assert resolved["model"] == "/models/Qwen3.6-35B-A3B-NVFP4" + assert resolved["max_output_tokens"] == 16_384 + + def test_bare_custom_resolves_providers_dict_entry_named_custom(monkeypatch): """A request for bare ``provider="custom"`` must resolve a literal ``providers.custom`` entry (e.g. a cliproxy endpoint) instead of falling diff --git a/tests/test_ctx_halving_fix.py b/tests/test_ctx_halving_fix.py index 63c965ac9658..0236626b4a93 100644 --- a/tests/test_ctx_halving_fix.py +++ b/tests/test_ctx_halving_fix.py @@ -74,6 +74,25 @@ class TestParseAvailableOutputTokens: msg = "max_tokens: 9999 > context_window: 10000 - input_tokens: 9999 = available_tokens: 1" assert self._parse(msg) == 1 + def test_vllm_exact_input_tokens_format(self): + msg = ( + "This model's maximum context length is 131072 tokens. However, " + "you requested 65536 output tokens and your prompt contains 70000 " + "input tokens, for a total of 135536 tokens." + ) + assert self._parse(msg) == 61_072 + + def test_vllm_at_least_input_tokens_is_not_exact_available_budget(self): + """vLLM lower-bound wording is not the real prompt token count.""" + msg = ( + "This model's maximum context length is 131072 tokens. However, " + "you requested 65536 output tokens and your prompt contains at least " + "65537 input tokens, for a total of at least 131073 tokens. Please " + "reduce the length of the input prompt or the number of requested " + "output tokens." + ) + assert self._parse(msg) is None + # ── Should NOT detect (returns None) ───────────────────────────────── def test_prompt_too_long_is_not_output_cap_error(self): @@ -263,6 +282,31 @@ class TestEphemeralMaxOutputTokens: assert kwargs["max_tokens"] == 8_192 +class TestChatCompletionsOutputCapClamp: + def _build(self, *, content_chars: int, context_length: int = 131_072, max_tokens: int = 65_536): + from agent.transports.chat_completions import ChatCompletionsTransport + + transport = ChatCompletionsTransport() + return transport.build_kwargs( + model="/models/Qwen3.6-35B-A3B-NVFP4", + messages=[{"role": "user", "content": "x" * content_chars}], + tools=None, + max_tokens=max_tokens, + max_tokens_param_fn=lambda value: {"max_tokens": value}, + context_length=context_length, + ) + + def test_clamps_output_cap_when_prompt_plus_output_would_exceed_context(self): + kwargs = self._build(content_chars=280_000) + + assert 1 <= kwargs["max_tokens"] < 65_536 + + def test_leaves_output_cap_when_prompt_plus_output_fits_context(self): + kwargs = self._build(content_chars=4_000) + + assert kwargs["max_tokens"] == 65_536 + + # --------------------------------------------------------------------------- # Integration: error handler does NOT halve context_length for output-cap errors # --------------------------------------------------------------------------- diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 5830706bf95d..479e3f2110d5 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -411,6 +411,61 @@ class TestDelegateTask(unittest.TestCase): self.assertEqual(kwargs["provider"], parent.provider) self.assertEqual(kwargs["api_mode"], parent.api_mode) + def test_child_uses_override_max_tokens_over_uncapped_parent(self): + """Delegation-provider output caps must reach the child agent. + + Regression: Codex parents often have max_tokens=None, but Spark/vLLM + children need a concrete cap so they do not request a 65K output budget + against a 128K context window. + """ + parent = _make_mock_parent(depth=0) + parent.max_tokens = None + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + MockAgent.return_value = mock_child + + _build_child_agent( + task_index=0, + goal="Use capped Spark child", + context=None, + toolsets=None, + model="/models/Qwen3.6-35B-A3B-NVFP4", + max_iterations=10, + parent_agent=parent, + task_count=1, + override_provider="custom:spark-qwen", + override_base_url="http://spark.local:8001/v1", + override_api_key="spark-test", + override_api_mode="chat_completions", + override_max_tokens=16_384, + ) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["max_tokens"], 16_384) + + def test_child_inherits_parent_max_tokens_without_override(self): + parent = _make_mock_parent(depth=0) + parent.max_tokens = 8_192 + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + MockAgent.return_value = mock_child + + _build_child_agent( + task_index=0, + goal="Inherit parent cap", + context=None, + toolsets=None, + model=None, + max_iterations=10, + parent_agent=parent, + task_count=1, + ) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["max_tokens"], 8_192) + def test_child_inherits_parent_print_fn(self): parent = _make_mock_parent(depth=0) sink = MagicMock() @@ -1112,6 +1167,28 @@ class TestDelegationCredentialResolution(unittest.TestCase): self.assertEqual(creds["api_key"], "local-key") self.assertEqual(creds["api_mode"], "chat_completions") + def test_direct_endpoint_honors_configured_max_tokens(self): + parent = _make_mock_parent(depth=0) + cfg = { + "model": "qwen2.5-coder", + "base_url": "http://localhost:1234/v1", + "api_key": "local-key", + "max_tokens": "8192", + } + creds = _resolve_delegation_credentials(cfg, parent) + self.assertEqual(creds["max_tokens"], 8192) + + def test_direct_endpoint_honors_max_output_tokens_alias(self): + parent = _make_mock_parent(depth=0) + cfg = { + "model": "qwen2.5-coder", + "base_url": "http://localhost:1234/v1", + "api_key": "local-key", + "max_output_tokens": 16_384, + } + creds = _resolve_delegation_credentials(cfg, parent) + self.assertEqual(creds["max_tokens"], 16_384) + def test_direct_endpoint_auto_detects_anthropic_messages_suffix(self): # Issue #10213: Azure AI Foundry exposes Anthropic-compatible models at # a /anthropic URL suffix. Subagents must pick anthropic_messages @@ -1263,6 +1340,48 @@ class TestDelegationCredentialResolution(unittest.TestCase): requested="crof.ai", target_model="deepseek-v4-pro-CEER" ) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_provider_resolution_propagates_runtime_max_output_tokens(self, mock_resolve): + mock_resolve.return_value = { + "provider": "custom", + "model": "/models/Qwen3.6-35B-A3B-NVFP4", + "base_url": "http://spark.local:8001/v1", + "api_key": "spark-test", + "api_mode": "chat_completions", + "max_output_tokens": 16_384, + } + parent = _make_mock_parent(depth=0) + cfg = { + "model": "/models/Qwen3.6-35B-A3B-NVFP4", + "provider": "custom:spark-qwen", + } + + creds = _resolve_delegation_credentials(cfg, parent) + + self.assertEqual(creds["provider"], "custom:spark-qwen") + self.assertEqual(creds["max_tokens"], 16_384) + + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_configured_delegation_max_tokens_overrides_runtime_cap(self, mock_resolve): + mock_resolve.return_value = { + "provider": "custom", + "model": "/models/Qwen3.6-35B-A3B-NVFP4", + "base_url": "http://spark.local:8001/v1", + "api_key": "spark-test", + "api_mode": "chat_completions", + "max_output_tokens": 65_536, + } + parent = _make_mock_parent(depth=0) + cfg = { + "model": "/models/Qwen3.6-35B-A3B-NVFP4", + "provider": "custom:spark-qwen", + "max_tokens": 8_192, + } + + creds = _resolve_delegation_credentials(cfg, parent) + + self.assertEqual(creds["max_tokens"], 8_192) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") def test_standard_provider_not_overwritten_by_configured_name(self, mock_resolve): """Standard (non-custom) providers must still return runtime identity, diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 2baf4da30365..1e322547f000 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -588,6 +588,19 @@ DEFAULT_MAX_ITERATIONS = 50 # headroom budget (see _apply_summary_budget). Belt-and-suspenders for # models that ignore the "be concise" instruction. 0 disables the ceiling. DEFAULT_MAX_SUMMARY_CHARS = 24000 + + +def _positive_int_or_none(value: Any) -> Optional[int]: + """Coerce a config/runtime value to a positive int, else None.""" + if isinstance(value, bool): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + # Fraction of the parent's *remaining* context headroom that the whole batch # of subagent summaries is allowed to consume. The per-summary budget is this # slice divided across the batch, so N children can't collectively blow the @@ -1055,6 +1068,7 @@ def _build_child_agent( override_base_url: Optional[str] = None, override_api_key: Optional[str] = None, override_api_mode: Optional[str] = None, + override_max_tokens: Optional[int] = None, # ACP transport overrides from trusted delegation config. override_acp_command: Optional[str] = None, override_acp_args: Optional[List[str]] = None, @@ -1298,6 +1312,12 @@ def _build_child_agent( # openrouter/pareto-code), so we keep it inherited even when the # provider is overridden — it's a no-op on any other model. + child_max_tokens: Any = ( + override_max_tokens + if override_max_tokens is not None + else getattr(parent_agent, "max_tokens", None) + ) + child = AIAgent( base_url=effective_base_url, api_key=effective_api_key, @@ -1307,7 +1327,7 @@ def _build_child_agent( acp_command=effective_acp_command, acp_args=effective_acp_args, max_iterations=max_iterations, - max_tokens=getattr(parent_agent, "max_tokens", None), + max_tokens=child_max_tokens, reasoning_config=child_reasoning, prefill_messages=getattr(parent_agent, "prefill_messages", None), fallback_model=parent_fallback, @@ -1458,7 +1478,7 @@ def _dump_subagent_timeout_diagnostic( _w("## Child config") for attr in ( "model", "provider", "api_mode", "base_url", "max_iterations", - "quiet_mode", "skip_memory", "skip_context_files", "platform", + "max_tokens", "quiet_mode", "skip_memory", "skip_context_files", "platform", "_delegate_role", "_delegate_depth", ): try: @@ -2500,6 +2520,7 @@ def delegate_task( override_base_url=creds["base_url"], override_api_key=creds["api_key"], override_api_mode=creds["api_mode"], + override_max_tokens=creds.get("max_tokens"), override_acp_command=creds.get("command"), override_acp_args=creds.get("args"), role=effective_role, @@ -2999,6 +3020,10 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: 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_mode = str(cfg.get("api_mode") or "").strip().lower() or None + configured_max_tokens = ( + _positive_int_or_none(cfg.get("max_tokens")) + or _positive_int_or_none(cfg.get("max_output_tokens")) + ) # Native-SDK providers (Bedrock, Vertex, Google GenAI) speak their own # wire protocol — they cannot be reached via OpenAI chat_completions against @@ -3054,6 +3079,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "base_url": configured_base_url, "api_key": api_key, "api_mode": api_mode, + "max_tokens": configured_max_tokens, } if not configured_provider: @@ -3064,6 +3090,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "base_url": None, "api_key": None, "api_mode": None, + "max_tokens": configured_max_tokens, } # Provider is configured — resolve full credentials @@ -3080,6 +3107,11 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: ) from exc api_key = runtime.get("api_key", "") + runtime_max_tokens = ( + configured_max_tokens + or _positive_int_or_none(runtime.get("max_output_tokens")) + or _positive_int_or_none(runtime.get("max_tokens")) + ) if not api_key: raise ValueError( f"Delegation provider '{configured_provider}' resolved but has no API key. " @@ -3092,6 +3124,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "base_url": runtime.get("base_url"), "api_key": api_key, "api_mode": runtime.get("api_mode"), + "max_tokens": runtime_max_tokens, "command": runtime.get("command"), "args": list(runtime.get("args") or []), }