Compare commits

...
Author SHA1 Message Date
MervinPraisonandCursor c8c3172ab4 fix(llm): coerce invalid max_output_tokens metadata to None
Keep the merged accessor aligned with PR #5020 regression coverage when
LiteLLM returns non-numeric output-limit metadata.

Co-authored-by: Cursor <[email protected]>
2026-09-17 12:43:12 +01:00
MervinPraisonandCursor f007958dfb merge: reconcile fix/5017-model-output-pr with origin/main
Resolve conflicts in run.py (keep PR budget semantics), model_capabilities
(single max_output_tokens accessor from main), and fallback tests (main suite
plus PR invalid-metadata regression).

Co-authored-by: Cursor <[email protected]>
2026-09-17 12:42:35 +01:00
dajiaohuang 8c83470a3a fix(cli): preserve custom and YAML output budgets 2026-09-09 19:49:09 +08:00
dajiaohuang a88399456a fix(cli): address model budget review findings 2026-09-09 19:32:30 +08:00
dajiaohuang 0e02c871f2 fix(cli): honor model output limits and YAML LLM options 2026-09-09 19:29:23 +08:00
dajiaohuang c5879fa54b fix(cli): derive output budget from model limits 2026-09-09 19:15:21 +08:00
9 changed files with 504 additions and 98 deletions

No files matched your search

@@ -87,6 +87,10 @@ def __getattr__(name):
from .model_capabilities import supports_streaming_with_tools
_lazy_cache[name] = supports_streaming_with_tools
return supports_streaming_with_tools
elif name == "max_output_tokens":
from .model_capabilities import max_output_tokens
_lazy_cache[name] = max_output_tokens
return max_output_tokens
elif name == "ModelRouter":
from .model_router import ModelRouter
_lazy_cache[name] = ModelRouter
@@ -243,6 +247,7 @@ __all__ = [
"process_stream_chunks",
"supports_structured_outputs",
"supports_streaming_with_tools",
"max_output_tokens",
"ModelRouter",
"ModelProfile",
"TaskComplexity",
@@ -38,6 +38,17 @@ def _base_model_name(model_name: str) -> str:
return name
def _coerce_output_token_limit(value) -> int | None:
"""Return a positive integer output ceiling, or ``None`` when invalid."""
if isinstance(value, bool) or value is None:
return None
try:
coerced = int(value)
except (TypeError, ValueError, OverflowError):
return None
return coerced if coerced > 0 else None
def _fallback_supports_structured_outputs(model_name: str) -> bool:
"""Static heuristic used only when litellm is unavailable.
@@ -577,8 +588,8 @@ def max_output_tokens(model_name: str):
try:
info = litellm.get_model_info(model=model_name)
if info:
out = info.get("max_output_tokens")
if out:
out = _coerce_output_token_limit(info.get("max_output_tokens"))
if out is not None:
return out
except Exception:
pass
@@ -594,7 +605,7 @@ def max_output_tokens(model_name: str):
if info is None and "/" in model_name:
info = model_cost.get(model_name.split("/")[-1].lower())
if info:
return info.get("max_output_tokens")
return _coerce_output_token_limit(info.get("max_output_tokens"))
except Exception:
pass
@@ -617,4 +628,4 @@ def is_gemini_internal_tool(tool) -> bool:
if isinstance(tool, dict) and len(tool) == 1:
tool_name = next(iter(tool.keys()))
return tool_name in GEMINI_INTERNAL_TOOLS
return False
return False
@@ -40,6 +40,7 @@ def _clear_caches():
mc.supports_parallel_function_calling,
mc.supports_web_search,
mc.supports_prompt_caching,
mc.max_output_tokens,
):
fn.cache_clear()
@@ -204,3 +205,13 @@ def test_max_output_tokens_unknown_model_is_none():
with _litellm_with(get_info=_raise, model_cost={}):
assert mc.max_output_tokens("totally-unknown-model") is None
mc.max_output_tokens.cache_clear()
def test_max_output_tokens_unknown_or_invalid_is_none():
mc.max_output_tokens.cache_clear()
fake = SimpleNamespace(
get_model_info=lambda *, model: {"max_output_tokens": "not-a-number"}
)
with patch.object(mc, "_get_litellm", return_value=fake):
assert mc.max_output_tokens("unknown-model") is None
mc.max_output_tokens.cache_clear()
@@ -20,65 +20,81 @@ app = typer.Typer(help="Run agents")
_FRAMEWORK_HELP = "Framework: praisonai, crewai, autogen"
_ALLOW_LOCAL_TOOLS_ENV = "PRAISONAI_ALLOW_LOCAL_TOOLS"
# Model-independent fallback used only when the resolved model's real output
# ceiling is unknown (litellm absent or model not in its registry). Keeping this
# equal to the historical CLI default makes the derivation backward-compatible.
_PREFERRED_DEFAULT_MAX_TOKENS = 16000
DEFAULT_MAX_TOKENS = 16000
def _resolve_max_tokens(
model_name: Optional[str],
max_tokens: int,
was_explicit: bool,
output,
model: Optional[str],
requested: Optional[int],
*,
output: Any = None,
) -> int:
"""Derive/clamp ``--max-tokens`` against the resolved model's output ceiling.
"""Resolve the output budget against the selected model's known limit.
- Unset by the user/config: default to ``min(preferred, ceiling)`` so a
large-output model is not silently capped at the constant and a small one
is not sent an over-limit request.
- Explicitly set above the ceiling: clamp down with a one-line diagnostic so
the provider does not 400.
- Ceiling unknown (no litellm / unknown model): return the value unchanged,
preserving today's behaviour.
LiteLLM metadata is optional and best-effort. Unknown models retain the
historical default, while an omitted budget uses the known model limit and
an explicit oversized value is capped with a diagnostic.
"""
if not model_name:
return max_tokens
explicit = requested is not None
value = DEFAULT_MAX_TOKENS if requested is None else requested
if not model or value <= 0:
return value
try:
from praisonaiagents.llm.model_capabilities import max_output_tokens
ceiling = max_output_tokens(model_name)
ceiling = max_output_tokens(model)
except Exception:
ceiling = None
if not ceiling:
return max_tokens
if was_explicit:
if max_tokens > ceiling:
output.print_warning(
f"--max-tokens {max_tokens} exceeds {model_name} limit; "
f"clamping to {ceiling}"
if not ceiling or ceiling <= 0:
return value
if not explicit:
# A known limit is authoritative. Returning the full ceiling avoids
# retaining the historical 16k cap on models that can emit more.
return ceiling
if value <= ceiling:
return value
message = (
f"--max-tokens {value} exceeds {model}'s output limit ({ceiling}); "
f"clamping to {ceiling}"
)
if output is not None:
output_mode = getattr(getattr(output, "mode", None), "value", None)
if output_mode == "stream-json" and hasattr(output, "emit_event"):
# stream-json is an NDJSON protocol: never use print_json(), whose
# indentation emits a multi-line object and corrupts framing.
output.emit_event(
"warning",
message=message,
data={"code": "max_tokens_clamped"},
)
return ceiling
return max_tokens
return min(_PREFERRED_DEFAULT_MAX_TOKENS, ceiling)
elif getattr(output, "is_json_mode", False) and hasattr(output, "print_json"):
# ``print_warning`` intentionally suppresses human text in JSON
# mode. Emit a structured diagnostic so automation can see that
# the requested value was lowered without corrupting stderr.
output.print_json({"warning": message, "code": "max_tokens_clamped"})
else:
output.print_warning(message)
return ceiling
def _apply_max_tokens(agent_config: Dict[str, Any], max_tokens: Optional[int]) -> None:
"""Fold a resolved output budget into an ``Agent(**agent_config)`` dict.
Mirrors the direct-prompt handler, which passes ``max_tokens`` inside the
``llm`` config dict (``{"model": ..., "max_tokens": ...}``). Normalising a
plain ``llm`` string into that dict here means the ceiling-derived/clamped
budget reaches the agent on the ``--agent`` and ``actions`` paths too, so
they no longer send an over-limit request the default prompt path avoids.
"""
if not max_tokens:
return
llm = agent_config.get("llm")
if isinstance(llm, dict):
llm.setdefault("max_tokens", max_tokens)
elif isinstance(llm, str):
agent_config["llm"] = {"model": llm, "max_tokens": max_tokens}
def _llm_spec_with_max_tokens(
llm_spec: Any,
model: Optional[str],
max_tokens: Optional[int],
) -> Any:
"""Add a resolved output budget to an LLM spec without losing options."""
if max_tokens is None:
return llm_spec
spec = dict(llm_spec) if isinstance(llm_spec, dict) else {}
if not spec.get("model"):
spec["model"] = llm_spec if isinstance(llm_spec, str) else model
if not spec.get("model"):
return llm_spec
spec["max_tokens"] = max_tokens
return spec
def _run_succeeded(result: Any) -> bool:
@@ -1217,7 +1233,7 @@ def run_main(
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool names (e.g. web_search,github) or a tools.py file path"),
toolset: Optional[str] = typer.Option(None, "--toolset", help="Named toolset groups (comma-separated, e.g., web,files)"),
allow_local_tools: bool = typer.Option(False, "--allow-local-tools", help="Load project-local .praisonai/tools/*.py (equivalent to PRAISONAI_ALLOW_LOCAL_TOOLS=true)"),
max_tokens: int = typer.Option(16000, "--max-tokens", help="Maximum output tokens"),
max_tokens: Optional[int] = typer.Option(None, "--max-tokens", help="Maximum output tokens (defaults to the known model limit; 16000 when unknown)"),
profile: bool = typer.Option(False, "--profile", help="Enable CLI profiling (timing breakdown)"),
profile_deep: bool = typer.Option(False, "--profile-deep", help="Enable deep profiling (cProfile stats, higher overhead)"),
output_mode: Optional[str] = typer.Option(None, "--output", "-o", help="Output mode: silent (default), actions, verbose, json, stream"),
@@ -1430,24 +1446,64 @@ def run_main(
# identically instead of dead-ending on a raw provider error. Headless
# (non-TTY / --output json) fails fast with the actionable hint; interactive
# offers the wizard and re-checks.
if target: # Only check if we actually have something to run
# YAML agents may select a different model (or an explicit per-agent
# max_tokens), so leave their raw option untouched and let the YAML
# generator resolve each agent against its own ceiling. Prompt/custom-agent
# paths use the resolved top-level model here.
is_yaml_target = bool(target and _is_yaml_file(target))
# Loading a named custom definition performs a complete discovery pass
# (including opt-in local tool modules). Keep the preview result so the
# execution path does not discover and execute the same definitions twice.
preloaded_agent_config = None
custom_agent_model = None
if target or agent or command: # Any execution surface may need model/budget resolution
import sys
from praisonai_code.llm.credentials import ensure_configured_or_onboard
_headless = (not sys.stdin.isatty()) or output.is_json_mode
model = ensure_configured_or_onboard(model=model, interactive=not _headless)
# A custom agent may declare its model in frontmatter. Resolve that
# model before deriving the default output budget; otherwise an omitted
# ``--model`` is budgeted at the generic 16k fallback and then attached
# to the frontmatter model, which can have a different ceiling.
if agent and model is None:
try:
from praisonai_code.cli.features.custom_definitions import (
load_agent_from_name,
)
# Derive/clamp the output budget from the now-resolved model's real ceiling
# (Issue #5017). A bare `run` no longer 400s on lower-ceiling models nor
# truncates silently on higher-ceiling ones; an explicit over-limit value is
# clamped with a diagnostic. Unknown-metadata models keep today's constant.
try:
import click as _click
_mt_src = _click.get_current_context().get_parameter_source("max_tokens")
_max_tokens_explicit = _mt_src is not None and _mt_src.name != "DEFAULT"
except Exception:
_max_tokens_explicit = False
max_tokens = _resolve_max_tokens(model, max_tokens, _max_tokens_explicit, output)
_preview_config = load_agent_from_name(agent)
preloaded_agent_config = _preview_config
_preview_llm = (_preview_config or {}).get("llm")
if isinstance(_preview_llm, dict):
custom_agent_model = _preview_llm.get("model")
else:
custom_agent_model = _preview_llm
if isinstance(custom_agent_model, str):
custom_agent_model = custom_agent_model.strip() or None
except Exception:
# The normal custom-agent load below reports missing/invalid
# definitions; preview is only for model-aware budgeting.
pass
_headless = (not sys.stdin.isatty()) or output.is_json_mode
# A frontmatter model is used for credential checks and budget
# resolution, but must not be passed as a synthetic ``--model``
# override: doing so replaces a mapping-valued LLM spec and discards
# endpoint, credentials, sampling, and retry options.
credential_model = model or custom_agent_model
resolved_model = ensure_configured_or_onboard(
model=credential_model, interactive=not _headless
)
if not (agent and model is None and custom_agent_model):
model = resolved_model
# A YAML file is a workflow target only when no named custom agent or
# command is selected. In the latter cases the file is merely the
# prompt/argument source and the named definition still needs its own
# model-aware budget resolution.
if not is_yaml_target or agent or command:
budget_model = custom_agent_model or model
max_tokens = _resolve_max_tokens(
budget_model, max_tokens, output=output
)
# Worktree isolation runs the agent in a chdir'd worktree in-process; the
# warm runtime is a separate process whose cwd we can't redirect, so reject
@@ -1513,7 +1569,11 @@ def run_main(
# Handle custom agent or command
if agent:
from praisonai_code.cli.features.custom_definitions import load_agent_from_name
agent_config = load_agent_from_name(agent)
agent_config = (
preloaded_agent_config
if preloaded_agent_config is not None
else load_agent_from_name(agent)
)
if not agent_config:
output.print_error(f"Agent '{agent}' not found")
raise typer.Exit(1)
@@ -1596,7 +1656,6 @@ def run_main(
instructions=merged_instructions,
append_system_prompt=resolved_append_prompt,
image=image,
max_tokens_explicit=_max_tokens_explicit,
)
return
@@ -1686,6 +1745,7 @@ def run_main(
approve_all_tools=approve_all_tools,
approval_timeout=approval_timeout,
output_mode=output_mode,
max_tokens=max_tokens,
)
else:
# Profiling for direct prompt
@@ -1703,6 +1763,7 @@ def run_main(
approval=approval,
approve_all_tools=approve_all_tools,
approval_timeout=approval_timeout,
max_tokens=max_tokens,
)
return
@@ -1783,7 +1844,6 @@ def run_main(
instructions=merged_instructions,
append_system_prompt=resolved_append_prompt,
image=image,
max_tokens_explicit=_max_tokens_explicit,
)
@@ -1797,7 +1857,7 @@ def _run_from_file(
trace: bool = False,
memory: bool = False,
tools: Optional[str] = None,
max_tokens: int = 16000,
max_tokens: Optional[int] = None,
output_mode: Optional[str] = None,
continue_session: bool = False,
session: Optional[str] = None,
@@ -1825,14 +1885,6 @@ def _run_from_file(
# Set model if provided
if model:
praison.config_list[0]['model'] = model
# Carry the ceiling-derived/clamped output budget on the model config so
# generators that honour ``max_tokens`` from ``config_list`` pick it up.
# Note: a multi-model YAML file can pin a different ``llm:`` per agent;
# the CLI ceiling was resolved once against the effective ``--model`` (or
# the process default), so per-agent YAML models are budgeted by the core
# context layer rather than re-clamped here.
if max_tokens:
praison.config_list[0]['max_tokens'] = max_tokens
# Handle session continuity for YAML files
session_id = None
@@ -1893,6 +1945,7 @@ def _run_from_file(
or effective_approval
or approve_all_tools
or output_mode
or max_tokens is not None
):
class Args:
pass
@@ -1902,6 +1955,8 @@ def _run_from_file(
args.resume_session = session_id
args.cli_project_sessions = bool(session_id or auto_save_name)
args.output = output_mode
args.max_tokens = max_tokens
args._max_tokens_explicit = max_tokens is not None
if effective_approval:
args.approval = effective_approval
if approve_all_tools:
@@ -1967,7 +2022,6 @@ def _run_prompt(
instructions: Optional[List[str]] = None,
append_system_prompt: Optional[str] = None,
image: Optional[List[str]] = None,
max_tokens_explicit: bool = False,
):
"""Run a direct prompt."""
output = get_output_controller()
@@ -2057,19 +2111,17 @@ def _run_prompt(
# PRAISONAI_APPEND_SYSTEM_PROMPT export, and it reuses a cached agent
# whose system prompt is already assembled — so attaching would silently
# drop the requested suffix. The in-process path applies it correctly.
# An explicit/derived per-call --max-tokens must also stay in-process:
# the warm runtime is a separate process reusing a cached agent whose
# output budget was fixed at startup, so attaching would silently drop
# the requested (possibly ceiling-clamped) budget — the same failure
# mode as thinking_budget/append_system_prompt above.
stateful_attach = bool(session_id) and not fork
runtime_eligible = (
(no_save or stateful_attach)
and thinking_budget is None
# The warm runtime API has no per-request output-budget field. A
# resolved non-default budget must stay in-process so it cannot be
# silently replaced by the runtime's own default.
and max_tokens == DEFAULT_MAX_TOKENS
and not isolated
and not append_system_prompt
and not image
and not max_tokens_explicit
and not any([
mcp, mcp_servers, tools, toolset, approval, approve_all_tools,
memory, permissions_config, fork, instructions,
@@ -2109,9 +2161,9 @@ def _run_prompt(
}
if model:
agent_config["llm"] = model
# Apply the ceiling-derived/clamped output budget so the actions
# fast path honours the same limit as the default prompt path.
_apply_max_tokens(agent_config, max_tokens)
agent_config["llm"] = _llm_spec_with_max_tokens(
agent_config.get("llm"), model, max_tokens
)
# Resolve approval backend if specified
if approval:
@@ -2224,6 +2276,7 @@ def _run_prompt(
args.tools = tools
args.toolset = toolset
args.max_tokens = max_tokens
args._max_tokens_explicit = max_tokens is not None
args.web_search = False
args.web_fetch = False
args.prompt_caching = False
@@ -2343,6 +2396,7 @@ def _run_from_file_profiled(
approve_all_tools: bool = False,
approval_timeout: Optional[str] = None,
output_mode: Optional[str] = None,
max_tokens: Optional[int] = None,
):
"""Run agents from a YAML file with profiling enabled."""
from praisonai_code.cli.features.cli_profiler import (
@@ -2410,7 +2464,7 @@ def _run_from_file_profiled(
# the same ``args`` the legacy YAML path reads, so a profiled YAML run is
# permission-gated identically to the non-profiled path instead of silently
# dropping the deny policy.
if session_id or auto_save_name or approval or approve_all_tools or output_mode:
if session_id or auto_save_name or approval or approve_all_tools or output_mode or max_tokens is not None:
class Args:
pass
@@ -2419,6 +2473,8 @@ def _run_from_file_profiled(
args.resume_session = session_id
args.cli_project_sessions = bool(session_id or auto_save_name)
args.output = output_mode
args.max_tokens = max_tokens
args._max_tokens_explicit = max_tokens is not None
if approval:
args.approval = approval
if approve_all_tools:
@@ -2627,14 +2683,20 @@ def _run_custom_agent(
try:
from praisonaiagents import Agent
# Override model if specified
# Override only the model field when the definition supplies a full
# LLM mapping. Replacing the mapping wholesale would discard endpoint,
# credentials, sampling, retry, and provider-specific options.
if model:
agent_config["llm"] = model
# Fold the ceiling-derived/clamped output budget into the agent's llm
# config so a `--agent` run honours the same limit as the default
# prompt path (only when the definition did not already pin one).
_apply_max_tokens(agent_config, max_tokens)
if isinstance(agent_config.get("llm"), dict):
agent_config["llm"] = {
**agent_config["llm"],
"model": model,
}
else:
agent_config["llm"] = model
agent_config["llm"] = _llm_spec_with_max_tokens(
agent_config.get("llm"), model, max_tokens
)
# Compose the agent's toolset: frontmatter ``tools:`` (name strings)
# + explicit --tools/--toolset + auto-discovered project-local
@@ -2830,6 +2892,7 @@ def _run_prompt_profiled(
approval: Optional[str] = None,
approve_all_tools: bool = False,
approval_timeout: Optional[str] = None,
max_tokens: Optional[int] = None,
):
"""Run a direct prompt with profiling enabled."""
from praisonai_code.cli.features.cli_profiler import (
@@ -2866,6 +2929,9 @@ def _run_prompt_profiled(
}
if model:
agent_config["llm"] = model
agent_config["llm"] = _llm_spec_with_max_tokens(
agent_config.get("llm"), model, max_tokens
)
# Thread the approval backend (e.g. --plan -> PermissionMode.PLAN) into the
# profiled agent so a read-only planning run stays read-only under
@@ -2053,6 +2053,16 @@ class PraisonAI:
if output_mode is not None:
cli_config['output'] = output_mode
max_tokens = getattr(self.args, 'max_tokens', None)
if max_tokens is not None:
cli_config['max_tokens'] = max_tokens
# Newer callers mark whether the CLI option was explicitly supplied;
# retain that bit so YAML agent-level budgets are not shadowed by a
# parser default of 16000.
explicit_max_tokens = getattr(self.args, '_max_tokens_explicit', None)
if explicit_max_tokens is not None:
cli_config['_max_tokens_explicit'] = bool(explicit_max_tokens)
# Extract handoff configuration for YAML CLI parity
handoff = getattr(self.args, 'handoff', None)
if handoff:
@@ -0,0 +1,130 @@
"""Tests for model-aware ``run --max-tokens`` resolution."""
from types import SimpleNamespace
from praisonai_code.cli.commands import run as run_command
def test_default_budget_is_capped_to_known_model_limit(monkeypatch):
monkeypatch.setattr(
"praisonaiagents.llm.model_capabilities.max_output_tokens",
lambda model: 8192,
)
assert run_command._resolve_max_tokens("provider/model", None) == 8192
def test_default_budget_uses_large_known_model_limit(monkeypatch):
monkeypatch.setattr(
"praisonaiagents.llm.model_capabilities.max_output_tokens",
lambda model: 32768,
)
assert run_command._resolve_max_tokens("provider/model", None) == 32768
def test_explicit_budget_is_capped_with_warning(monkeypatch):
monkeypatch.setattr(
"praisonaiagents.llm.model_capabilities.max_output_tokens",
lambda model: 8192,
)
warnings = []
output = SimpleNamespace(print_warning=warnings.append)
assert run_command._resolve_max_tokens(
"provider/model", 12000, output=output
) == 8192
assert warnings == [
"--max-tokens 12000 exceeds provider/model's output limit (8192); "
"clamping to 8192"
]
def test_explicit_budget_warning_is_single_ndjson_event_in_stream_mode(monkeypatch):
monkeypatch.setattr(
"praisonaiagents.llm.model_capabilities.max_output_tokens",
lambda model: 8192,
)
events = []
output = SimpleNamespace(
mode=SimpleNamespace(value="stream-json"),
emit_event=lambda *args, **kwargs: events.append((args, kwargs)),
is_json_mode=True,
)
assert run_command._resolve_max_tokens(
"provider/model", 12000, output=output
) == 8192
assert events == [
(
("warning",),
{
"message": "--max-tokens 12000 exceeds provider/model's output limit (8192); clamping to 8192",
"data": {"code": "max_tokens_clamped"},
},
)
]
def test_unknown_model_preserves_historical_default(monkeypatch):
monkeypatch.setattr(
"praisonaiagents.llm.model_capabilities.max_output_tokens",
lambda model: None,
)
assert run_command._resolve_max_tokens("unknown/model", None) == 16000
def test_llm_spec_budget_preserves_existing_options():
spec = {"model": "provider/model", "temperature": 0.2}
resolved = run_command._llm_spec_with_max_tokens(spec, None, 4096)
assert resolved == {
"model": "provider/model",
"temperature": 0.2,
"max_tokens": 4096,
}
assert spec == {"model": "provider/model", "temperature": 0.2}
def test_yaml_budget_resolution_preserves_nested_llm_value(monkeypatch):
from praisonai.framework_adapters.praisonai_adapter import (
_build_agent_llm_spec,
_requested_output_budget,
_resolve_output_budget,
)
monkeypatch.setattr(
"praisonaiagents.llm.model_capabilities.max_output_tokens",
lambda model: 8192,
)
details = {
"llm": {
"model": "provider/model",
"temperature": 0.2,
"max_tokens": 2048,
}
}
# A legacy parser default must not shadow an explicit nested YAML value.
assert _requested_output_budget(details, {"max_tokens": 16000}) == 2048
assert _resolve_output_budget("provider/model", 2048) == 2048
spec = _build_agent_llm_spec(details["llm"], "provider/model", 2048)
assert spec == {
"model": "provider/model",
"temperature": 0.2,
"max_tokens": 2048,
}
def test_yaml_explicit_cli_budget_wins_over_nested_value():
from praisonai.framework_adapters.praisonai_adapter import (
_requested_output_budget,
)
details = {"llm": {"model": "provider/model", "max_tokens": 2048}}
assert _requested_output_budget(
details,
{"max_tokens": 4096, "_max_tokens_explicit": True},
) == 4096
+1 -1
View File
@@ -729,7 +729,7 @@ class AgentsGenerator:
break
# Handle agent-level overrides using unified approach
agent_level_fields = ['tool_timeout', 'tool_retry_policy', 'planning_tools', 'autonomy', 'planning', 'web', 'web_fetch']
agent_level_fields = ['tool_timeout', 'tool_retry_policy', 'planning_tools', 'autonomy', 'planning', 'web', 'web_fetch', 'max_tokens']
agent_overrides = {k: v for k, v in cli_config.items() if k in agent_level_fields}
if "tool_retry_policy" in agent_overrides:
@@ -16,6 +16,118 @@ from .base import BaseFrameworkAdapter
logger = logging.getLogger(__name__)
_DEFAULT_MAX_TOKENS = 16000
def _active_correlation_id() -> Optional[str]:
"""Return the current workflow id for budget diagnostics when available."""
try:
from praisonaiagents.trace.context_events import get_context_emitter
session_id = getattr(get_context_emitter(), "_session_id", None)
if session_id:
return str(session_id)
except Exception:
pass
try:
from praisonaiagents.session.context import get_session_context
context = get_session_context()
for field in ("thread_id", "chat_id", "user_id"):
value = getattr(context, field, None)
if value:
return str(value)
except Exception:
pass
return None
def _resolve_output_budget(model: str, requested: Any = None) -> Optional[int]:
"""Resolve a YAML agent budget against LiteLLM's model ceiling."""
try:
value = _DEFAULT_MAX_TOKENS if requested is None else int(requested)
except (TypeError, ValueError, OverflowError):
correlation_id = _active_correlation_id()
logger.warning(
"Ignoring invalid max_tokens=%r for model %r",
requested,
model,
extra={"extra_data": {"correlation_id": correlation_id}}
if correlation_id
else {},
)
value = _DEFAULT_MAX_TOKENS
if not model or value <= 0:
return value
try:
from praisonaiagents.llm.model_capabilities import max_output_tokens
ceiling = max_output_tokens(model)
except Exception:
ceiling = None
if not ceiling or ceiling <= 0:
return value
if value > ceiling:
if requested is not None:
correlation_id = _active_correlation_id()
logger.warning(
"max_tokens=%s exceeds %s's output limit (%s); clamping to %s",
value, model, ceiling, ceiling,
extra={"extra_data": {"correlation_id": correlation_id}}
if correlation_id
else {},
)
return ceiling
# An omitted YAML budget follows the model's full known ceiling, including
# ceilings above the historical 16k default.
return ceiling if requested is None else value
def _build_agent_llm_spec(
raw_llm_spec: Any,
model: str,
max_tokens: Optional[int],
) -> Dict[str, Any]:
"""Overlay resolved model/budget fields without dropping YAML options."""
spec = dict(raw_llm_spec) if isinstance(raw_llm_spec, dict) else {}
spec["model"] = model
spec["max_tokens"] = max_tokens
return spec
def _requested_output_budget(
details: Dict[str, Any], cli_config: Optional[Dict[str, Any]] = None
) -> Any:
"""Select the raw per-agent budget before model-ceiling resolution.
An explicit CLI value wins over YAML. When the CLI option is omitted,
preserve the agent-level value and then a nested ``llm.max_tokens`` value;
the latter must not be mistaken for an omitted budget and overwritten by
the model ceiling. Older callers may pass the parser's historical 16k
default without an explicitness marker, so that value is treated as an
omission when a YAML value is available.
"""
details = details or {}
cli = cli_config or {}
marker = cli.get("_max_tokens_explicit")
cli_value = cli.get("max_tokens")
if marker is None:
marker = (
"max_tokens" in cli
and cli_value is not None
and cli_value != _DEFAULT_MAX_TOKENS
)
if marker:
return cli_value
agent_value = details.get("max_tokens")
if agent_value is not None:
return agent_value
llm_spec = details.get("llm")
if isinstance(llm_spec, dict):
return llm_spec.get("max_tokens")
return None
class PraisonAIAdapter(BaseFrameworkAdapter):
"""
@@ -345,7 +457,17 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
return llm_config[0]['model']
return "gpt-4o-mini"
def _build_agents_and_tasks(self, config, topic, tools_dict, agent_callback, task_callback, model_name, agent_tool_wrap_resolver=None):
def _build_agents_and_tasks(
self,
config,
topic,
tools_dict,
agent_callback,
task_callback,
model_name,
agent_tool_wrap_resolver=None,
cli_config: Optional[Dict[str, Any]] = None,
):
"""Build agents and tasks from configuration."""
from praisonaiagents import Agent as PraisonAgent, Task as PraisonTask
from ._config_builder import build_agent_specs
@@ -378,6 +500,15 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
# Resolve per-agent LLM model
agent_model = self._resolve_agent_model(details, model_name)
# CLI max_tokens is an explicit global override when present;
# otherwise honour each YAML agent's own value (including a nested
# ``llm.max_tokens``) and resolve an omitted value from that
# agent's model ceiling.
requested_max_tokens = _requested_output_budget(details, cli_config)
resolved_max_tokens = _resolve_output_budget(
agent_model, requested_max_tokens
)
# Resolve per-agent runtime configuration
agent_runtime = self._resolve_agent_runtime(details, config)
@@ -385,6 +516,14 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
# Resolve approval configuration
agent_approval = self._resolve_agent_approval(details, config)
# Preserve the complete YAML LLM mapping (endpoint, credentials,
# sampling, retries, and provider-specific options) while adding
# the resolved model/budget fields. Rebuilding from only ``model``
# silently discarded those settings.
agent_llm = _build_agent_llm_spec(
details.get('llm'), agent_model, resolved_max_tokens
)
# Create basic agent (pass both tools and toolsets)
agent_kwargs = {
'name': role_filled,
@@ -392,7 +531,7 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
'goal': goal_filled,
'backstory': backstory_filled,
'instructions': details.get('instructions'),
'llm': agent_model,
'llm': agent_llm,
'allow_delegation': details.get('allow_delegation', False),
'tools': agent_tool_list,
'toolsets': agent_toolsets,
@@ -780,6 +919,7 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
agents, tasks = self._build_agents_and_tasks(
config, topic, tools_dict, agent_callback, task_callback, model_name,
agent_tool_wrap_resolver=agent_tool_wrap_resolver,
cli_config=cli_config,
)
# Resolve CLI session continuity (--continue/--session/--fork) that the
@@ -0,0 +1,33 @@
"""Regression tests for YAML model-aware output budgets."""
from praisonai.framework_adapters.praisonai_adapter import (
_build_agent_llm_spec,
_resolve_output_budget,
)
def test_yaml_budget_uses_output_ceiling_for_omitted_value(monkeypatch):
monkeypatch.setattr(
"praisonaiagents.llm.model_capabilities.max_output_tokens",
lambda model: 8192,
)
assert _resolve_output_budget("provider/model") == 8192
assert _resolve_output_budget("provider/model", 12000) == 8192
def test_yaml_llm_spec_preserves_provider_options():
raw = {
"model": "provider/model",
"temperature": 0.2,
"api_base": "http://localhost:11434",
"retry": {"max_attempts": 2},
}
resolved = _build_agent_llm_spec(raw, "provider/model", 4096)
assert resolved == {
**raw,
"max_tokens": 4096,
}
assert resolved is not raw