Skip to content
Merged
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
10 changes: 5 additions & 5 deletions docs/docs/usage-guide/changing_a_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,20 +519,20 @@ key = "..." # your openrouter api key

#### Openrouter provider routing, reasoning and output cap

For `openrouter/...` models you can optionally restrict which upstream providers Openrouter uses, control reasoning, and cap the completion length. All keys live in the `[openrouter]` section of `configuration.toml` and default to unset (no change to Openrouter's default behavior):
For `openrouter/...` models you can optionally restrict which upstream providers Openrouter uses, control reasoning, and cap the completion length. All keys live in the `[openrouter]` section of `configuration.toml`. Models listed in [`SUPPORT_REASONING_EFFORT_MODELS`](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py) inherit `config.reasoning_effort` unless an Openrouter-specific effort or token budget is set.

```toml
[openrouter]
# Uncomment and adjust the keys you need; unset keys keep Openrouter's defaults.
# provider_only = ["z-ai"] # hard allowlist of upstream providers; empty = default routing
# provider_order = ["z-ai", "novita"] # preferred order instead of an allowlist; ignored when provider_only is set
# allow_fallbacks = true # when provider_order is set, allow routing beyond the list
# reasoning_effort = "low" # "none" disables reasoning; otherwise "low", "medium" or "high"
# reasoning_max_tokens = 2048 # cap the reasoning budget in tokens
# reasoning_effort = "low" # override global effort: "none", "minimal", "low", "medium", "high", "xhigh" or "max"
# reasoning_max_tokens = 2048 # explicit budget; takes precedence over effort unless effort is "none"
# max_tokens = 16000 # hard cap on completion tokens for the request
```

`provider_only` and `reasoning_effort = "none"` are useful to pin a specific provider and to bound the cost of reasoning models. See the Openrouter [provider routing](https://openrouter.ai/docs/features/provider-routing) and [reasoning tokens](https://openrouter.ai/docs/use-cases/reasoning-tokens) docs.
`provider_only` and `reasoning_effort = "none"` are useful to pin a specific provider and to bound the cost of reasoning models. Because Openrouter treats effort and token budgets as mutually exclusive, an explicit Openrouter-specific `"none"` keeps reasoning disabled; otherwise a positive `reasoning_max_tokens` value takes precedence over the global effort and other Openrouter-specific values. Invalid Openrouter-specific effort values are warned about and treated as unset, so registered reasoning models fall back to `config.reasoning_effort`. Openrouter normalizes `"max"` to `"xhigh"` in this path to match LiteLLM 1.98.0. Supported effort values vary by model, and models whose metadata marks reasoning as mandatory reject `"none"`. For Anthropic models using a reasoning budget, set the effective output `max_tokens` higher than `reasoning_max_tokens` so the final answer has output headroom. See the Openrouter [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection) and [reasoning tokens](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens) docs.

### Neon AI Gateway

Expand Down Expand Up @@ -595,7 +595,7 @@ custom_model_max_tokens= ...

```toml
[config]
reasoning_effort = "medium" # "none", "minimal", "low", "medium", "high", "xhigh"
reasoning_effort = "medium" # "none", "minimal", "low", "medium", "high", "xhigh", "max"
```

With the OpenAI models that support reasoning effort (eg: gpt-5.6-terra), you can specify its reasoning effort via `config` section. The default value is `medium`. You can change it to any supported value based on your usage. Available values depend on the model and provider.
Expand Down
11 changes: 5 additions & 6 deletions pr_agent/algo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,12 +394,11 @@
"o3-2025-04-16",
"o4-mini",
"o4-mini-2025-04-16",
# Gemini 2.5 exposes a thinking budget that LiteLLM maps from reasoning_effort
# (low/medium/high -> thinkingConfig.thinkingBudget). Without these entries a
# configured reasoning_effort is silently dropped for Gemini, so a runaway
# thinking trace can consume the whole output budget and return an empty
# completion. Matched provider-prefix-insensitively in litellm_ai_handler so
# prefixed forms (e.g. "openrouter/google/gemini-2.5-pro") are covered too.
# Gemini 2.5 exposes a thinking budget controlled by reasoning_effort. Without
# these entries a configured effort is silently dropped, so a runaway thinking
# trace can consume the whole output budget and return an empty completion.
# LiteLLM maps native provider paths to thinkingConfig.thinkingBudget, while
# LiteLLMAIHandler routes OpenRouter-prefixed forms through extra_body.reasoning.
"gemini-2.5-pro",
"gemini-2.5-flash",
]
Expand Down
90 changes: 72 additions & 18 deletions pr_agent/algo/ai_handlers/litellm_ai_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,12 +749,18 @@ async def chat_completion(self, model: str, system: str, user: str, temperature:
if 'temperature' in kwargs:
del kwargs['temperature']

openrouter_reasoning_effort = None
reasoning_model = model.rsplit(":", 1)[0] if model.startswith("openrouter/") else model
# Add reasoning_effort if model supports it. Match the bare model
# id as well as any provider-prefixed form (e.g.
# "openrouter/google/gemini-2.5-pro", "gemini/gemini-2.5-pro"), so a
# configured reasoning_effort is not silently dropped for models the
# user references with a provider prefix.
if any(model == m or model.endswith("/" + m) for m in self.support_reasoning_models):
# user references with a provider prefix. OpenRouter routing variants
# such as :nitro and :floor are stripped only for this membership test.
if any(
reasoning_model == m or reasoning_model.endswith("/" + m)
for m in self.support_reasoning_models
):
config_effort = get_settings().config.reasoning_effort
try:
ReasoningEffort(config_effort)
Expand All @@ -767,8 +773,14 @@ async def chat_completion(self, model: str, system: str, user: str, temperature:
f"Using default '{reasoning_effort}'. Valid values: {[e.value for e in ReasoningEffort]}"
)

get_logger().info(f"Adding reasoning_effort with value {reasoning_effort} to model {model}.")
kwargs["reasoning_effort"] = reasoning_effort
if model.startswith("openrouter/"):
# LiteLLM 1.98.0 rejects top-level reasoning_effort for some
# OpenRouter model IDs it does not mark as reasoning-capable;
# defer to OpenRouter's unified reasoning object below.
openrouter_reasoning_effort = reasoning_effort
else:
get_logger().info(f"Adding reasoning_effort with value {reasoning_effort} to model {model}.")
kwargs["reasoning_effort"] = reasoning_effort

# https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking
if (model in self.claude_extended_thinking_models) and get_settings().config.get("enable_claude_extended_thinking", False):
Expand Down Expand Up @@ -863,9 +875,8 @@ async def chat_completion(self, model: str, system: str, user: str, temperature:
get_logger().info(f"Using Bedrock custom inference profile: {model_id}")

# OpenRouter provider routing, reasoning control and output cap.
# Applied only to "openrouter/*" models. Every key defaults to unset in
# the [openrouter] section of configuration.toml, so this block is a
# no-op unless explicitly configured, and never affects other providers.
# Registered reasoning models inherit config.reasoning_effort when
# no OpenRouter-specific effort or token budget is configured.
if isinstance(model, str) and model.startswith("openrouter/"):
openrouter_settings = get_settings().get("openrouter", {})
extra_body = kwargs.get("extra_body") or {}
Expand Down Expand Up @@ -903,20 +914,52 @@ def _as_int(value):
provider["allow_fallbacks"] = _as_bool(openrouter_settings.get("allow_fallbacks", True))

reasoning = {}
reasoning_effort = str(openrouter_settings.get("reasoning_effort", "") or "").strip().lower()
if reasoning_effort == "none":
reasoning["enabled"] = False
elif reasoning_effort in ("low", "medium", "high"):
reasoning["effort"] = reasoning_effort
elif reasoning_effort:
get_logger().warning(
f"Ignoring invalid openrouter.reasoning_effort '{reasoning_effort}'. "
"Valid values: none, low, medium, high."
)
effective_reasoning_effort = str(
openrouter_settings.get("reasoning_effort", "") or ""
).strip().lower()
reasoning_max_tokens = _as_int(openrouter_settings.get("reasoning_max_tokens", 0))
if reasoning_max_tokens > 0 and reasoning.get("enabled") is not False:
if effective_reasoning_effort:
try:
ReasoningEffort(effective_reasoning_effort)
except (TypeError, ValueError):
get_logger().warning(
f"Ignoring invalid openrouter.reasoning_effort '{effective_reasoning_effort}'. "
f"Valid values: {[effort.value for effort in ReasoningEffort]}."
)
effective_reasoning_effort = ""
if not effective_reasoning_effort:
if reasoning_max_tokens > 0 and openrouter_reasoning_effort:
get_logger().warning(
f"Ignoring config.reasoning_effort='{openrouter_reasoning_effort}' because "
"openrouter.reasoning_max_tokens takes precedence."
)
elif reasoning_max_tokens <= 0:
effective_reasoning_effort = openrouter_reasoning_effort or ""
Comment on lines +930 to +937

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openrouter_reasoning_effort carries config.reasoning_effort, which ships as medium, so anyone setting only openrouter.reasoning_max_tokens gets this warning on every request without having configured anything contradictory.

none is the genuine contradiction, and the only case with a test asserting a warning. Demoting the rest keeps the signal; your 62 tests still pass.

Suggested change
if not effective_reasoning_effort:
if reasoning_max_tokens > 0 and openrouter_reasoning_effort:
get_logger().warning(
f"Ignoring config.reasoning_effort='{openrouter_reasoning_effort}' because "
"openrouter.reasoning_max_tokens takes precedence."
)
elif reasoning_max_tokens <= 0:
effective_reasoning_effort = openrouter_reasoning_effort or ""
if not effective_reasoning_effort:
if reasoning_max_tokens > 0 and openrouter_reasoning_effort:
message = (
f"Ignoring config.reasoning_effort='{openrouter_reasoning_effort}' because "
"openrouter.reasoning_max_tokens takes precedence."
)
if openrouter_reasoning_effort == "none":
get_logger().warning(message)
else:
get_logger().info(message)
elif reasoning_max_tokens <= 0:
effective_reasoning_effort = openrouter_reasoning_effort or ""


# Preserve explicit disablement; otherwise keep effort and
# max_tokens mutually exclusive by preferring the token budget.
if effective_reasoning_effort == "none":
if reasoning_max_tokens > 0:
get_logger().warning(
"Ignoring openrouter.reasoning_max_tokens because "
"openrouter.reasoning_effort='none' disables reasoning."
)
reasoning["enabled"] = False
elif reasoning_max_tokens > 0:
if effective_reasoning_effort:
get_logger().warning(
f"Ignoring openrouter.reasoning_effort='{effective_reasoning_effort}' because "
"openrouter.reasoning_max_tokens takes precedence."
)
reasoning["max_tokens"] = reasoning_max_tokens
elif effective_reasoning_effort:
# OpenRouter uses xhigh for the max alias; extra_body bypasses
# LiteLLM's OpenRouter parameter mapping.
reasoning["effort"] = (
"xhigh" if effective_reasoning_effort == "max" else effective_reasoning_effort
)
if reasoning:
get_logger().info(f"Adding OpenRouter reasoning {reasoning} to model {model}.")
extra_body["reasoning"] = reasoning

if extra_body:
Expand All @@ -926,6 +969,17 @@ def _as_int(value):
if max_tokens > 0:
existing = _as_int(kwargs.get("max_tokens", 0))
kwargs["max_tokens"] = min(existing, max_tokens) if existing > 0 else max_tokens
effective_max_tokens = _as_int(kwargs.get("max_tokens", 0))
effective_reasoning_max_tokens = _as_int(reasoning.get("max_tokens", 0))
if (
model.startswith("openrouter/anthropic/")
and effective_reasoning_max_tokens > 0
and 0 < effective_max_tokens <= effective_reasoning_max_tokens
):
get_logger().warning(
f"OpenRouter Anthropic max_tokens ({effective_max_tokens}) must be greater than "
f"reasoning_max_tokens ({effective_reasoning_max_tokens}) to leave output headroom."
)

get_logger().debug("Prompts", artifact={"system": system, "user": user})

Expand Down
16 changes: 12 additions & 4 deletions pr_agent/settings/configuration.toml
Original file line number Diff line number Diff line change
Expand Up @@ -407,13 +407,21 @@ cache_control_injection_points = [] # Optional: enable Anthropic prompt caching
# OpenRouter provider routing, reasoning control and output cap. These apply only
# to models addressed as "openrouter/...". The API key and api_base live in
# .secrets.toml (or the openrouter__key env var); the keys below are non-secret.
# Refs: https://openrouter.ai/docs/features/provider-routing
# https://openrouter.ai/docs/use-cases/reasoning-tokens
# Refs: https://openrouter.ai/docs/guides/routing/provider-selection
# https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
provider_only = [] # restrict routing to these upstream providers only, a hard allowlist (e.g. ["z-ai"]); empty = OpenRouter default routing
provider_order = [] # preferred provider order; ignored when provider_only is set; empty = unset
allow_fallbacks = true # when provider_order is set, allow routing beyond the listed providers
reasoning_effort = "" # "" leaves reasoning to the model default; "none" disables it; otherwise "low", "medium" or "high"
reasoning_max_tokens = 0 # cap the reasoning budget in tokens; 0 = unset
# Invalid reasoning_effort values are warned about and treated as unset.
# Empty inherits config.reasoning_effort for models in SUPPORT_REASONING_EFFORT_MODELS.
# Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max".
# OpenRouter normalizes "max" to "xhigh" to match LiteLLM 1.98.0.
# Model-specific support varies; mandatory reasoning models reject "none".
reasoning_effort = ""
# A positive value overrides global effort and non-none OpenRouter-specific efforts.
# Explicit openrouter.reasoning_effort = "none" keeps reasoning disabled.
# Some providers require max_tokens to be greater than the reasoning budget.
reasoning_max_tokens = 0
max_tokens = 0 # hard cap on completion tokens for the request; 0 = unset

[pr_similar_issue]
Expand Down
Loading
Loading