8103829ef8
* fix(codex-native): honor Max/Ultra reasoning levels instead of coercing to xhigh Codex advertises a per-model reasoning ladder via model/list: Sol reaches `ultra`, Luna reaches `max`, and a turn at those levels completes (Sol's `ultra` runs subagents). Omnigent's picker surfaced them, but the codex-native effort override validated against the xhigh-capped CODEX_EFFORTS ladder, so a web-picked Max/Ultra was silently coerced to xhigh before the wire — and the TUI->web effort mirror stored `ultra` as `xhigh`, so the UI showed the wrong level. Validate codex-native efforts against the full codex ladder (CODEX_NATIVE_EFFORTS) and add `ultra` to the session-metadata vocabulary, so a picked level rides through unchanged and the UI shows the real level. The SDK/Responses codex path keeps the xhigh cap + ultra/max alias, preserving OMNI-1694's defensive fold on that backend. OMNI-4255 Co-authored-by: Isaac Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com> * fix(web): follow the drafted model's effort ladder in the Configure Codex modal The effort dropdown mapped a static effortLevels prop computed from the committed model, so switching the model inside the modal (Sol → Luna) still listed Sol's `ultra`, with the stale level left selected. Recompute the ladder from the drafted model and drop a picked level the new model doesn't offer, so the dropdown never shows a rung the model rejects and Save can't submit one. OMNI-4255 Co-authored-by: Isaac Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com> * chore(codex): ultra parity in REPL /effort, schema doc, and GLM effort cap Address Polly's non-blocking notes on #5217, now that `ultra` is a first-class effort value: - REPL `/effort` accepts and lists `ultra` (matched EFFORT_VALUES). - schemas.py reasoning_effort docstring enumerates `ultra`. - GLM effort cap treats `ultra` as unsupported — every rung above `high` — so a pinned `ultra` clamps to medium like `xhigh`/`max`. OMNI-4255 Co-authored-by: Isaac Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com> --------- Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
209 lines
8.8 KiB
Python
209 lines
8.8 KiB
Python
"""Reasoning-effort validation helpers shared across client/runtime paths."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable, Mapping
|
|
from dataclasses import dataclass, field
|
|
from types import MappingProxyType
|
|
|
|
from omnigent.llms.errors import PermanentLLMError
|
|
|
|
EFFORT_VALUES = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"})
|
|
EFFORT_CLEAR_VALUES = frozenset({"default", "off", "reset"})
|
|
|
|
# Fold a value to a canonical one, but only where the target ladder lacks it:
|
|
# ``validate_effort`` applies an alias only when the raw value is unsupported
|
|
# but the canonical one is. On the SDK/Responses codex ladder (``CODEX_EFFORTS``,
|
|
# capped at ``xhigh``) the ChatGPT app's ``ultra`` / retired ``max`` fold to
|
|
# ``xhigh``; ladders that carry them (codex-native ``CODEX_NATIVE_EFFORTS``,
|
|
# Anthropic's ``max``) keep them unchanged.
|
|
EFFORT_ALIASES: dict[str, str] = {"ultra": "xhigh", "max": "xhigh"}
|
|
|
|
OPENAI_EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
|
|
ANTHROPIC_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
|
|
CLAUDE_EFFORTS = ANTHROPIC_EFFORTS
|
|
CODEX_EFFORTS = OPENAI_EFFORTS
|
|
# Codex-native drives the real codex process, which is the per-model authority
|
|
# on reasoning levels — it advertises them via ``model/list`` and validates the
|
|
# pairing itself. Sol reaches ``ultra``; the picker already gates which levels a
|
|
# model offers, so accept codex's full ladder here rather than re-clamping a
|
|
# valid pick down to ``xhigh``.
|
|
CODEX_NATIVE_EFFORTS = frozenset(
|
|
{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
|
|
)
|
|
OPENAI_AGENTS_EFFORTS = OPENAI_EFFORTS
|
|
GEMINI_EFFORTS = frozenset({"low", "medium", "high"})
|
|
ANTIGRAVITY_EFFORTS = GEMINI_EFFORTS
|
|
# The GitHub Copilot SDK's ``create_session(reasoning_effort=...)`` accepts
|
|
# exactly these levels (``copilot.session.ReasoningEffort`` literal); per-model
|
|
# support is gated by the Copilot backend (``list_models()``).
|
|
COPILOT_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
|
|
|
|
|
def format_supported(values: Iterable[str]) -> str:
|
|
"""Return a stable comma-separated supported-values string."""
|
|
order = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
|
|
values_set = set(values)
|
|
return ", ".join(value for value in order if value in values_set)
|
|
|
|
|
|
def unsupported_effort_message(effort: str, provider: str, supported: Iterable[str]) -> str:
|
|
"""Build a clear unsupported-effort error message."""
|
|
return (
|
|
f"Effort {effort!r} is not supported by {provider}; "
|
|
f"supported values: {format_supported(supported)}"
|
|
)
|
|
|
|
|
|
# Some models served through a harness reject the harness's full effort ladder.
|
|
# GLM on the codex/Responses wire accepts only up to ``high`` (no ``xhigh``), so
|
|
# a user default of ``xhigh``/``max`` 400s the turn. Map such a model to the
|
|
# effort to use instead of failing — GLM falls back to ``medium``.
|
|
#
|
|
# These are probed facts about ONE gateway's serving, not properties of the
|
|
# effort ladders themselves, so a deployment whose gateway caps other models
|
|
# overrides them via ``routing.effort_caps`` (see
|
|
# :class:`~omnigent.server.smart_routing.RoutingSettings`). The provider ladders
|
|
# above stay frozen: they are the wire APIs' own vocabularies.
|
|
_MODEL_EFFORT_FALLBACK: Mapping[str, str] = MappingProxyType({"glm-5-2": "medium"})
|
|
# Efforts a fallback model cannot accept, so a pinned high value coerces down.
|
|
# GLM tops out at ``high``, so every rung above it (``xhigh``/``max``/``ultra``)
|
|
# is unsupported.
|
|
_MODEL_EFFORT_UNSUPPORTED: Mapping[str, frozenset[str]] = MappingProxyType(
|
|
{"glm-5-2": frozenset({"xhigh", "max", "ultra"})}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModelEffortCaps:
|
|
"""Per-model effort ceilings a deployment's gateway imposes.
|
|
|
|
:param fallback: Bare model id → the effort to use when the requested one
|
|
is barred. Also the effort a switch onto that model sends when the
|
|
caller asked for none.
|
|
:param unsupported: Bare model id → the efforts that model's backend
|
|
rejects outright.
|
|
"""
|
|
|
|
# default_factory, not default: a mapping is unhashable and dataclasses
|
|
# rejects an unhashable default outright.
|
|
fallback: Mapping[str, str] = field(default_factory=lambda: _MODEL_EFFORT_FALLBACK)
|
|
unsupported: Mapping[str, frozenset[str]] = field(
|
|
default_factory=lambda: _MODEL_EFFORT_UNSUPPORTED
|
|
)
|
|
|
|
|
|
#: The caps every deployment gets unless its ``routing:`` block overrides them.
|
|
DEFAULT_MODEL_EFFORT_CAPS = ModelEffortCaps()
|
|
|
|
|
|
def model_effort_caps(caps: ModelEffortCaps | None = None) -> ModelEffortCaps:
|
|
"""Resolve which effort caps apply, defaulting to this deployment's.
|
|
|
|
``None`` reads :class:`~omnigent.server.smart_routing.RoutingSettings` off
|
|
the process caps, so a managed gateway that caps a different model set is
|
|
honoured without every caller threading the value. Outside a server process
|
|
(the runner holds no routing settings) that read yields the defaults, which
|
|
are the frozen tables above — so runner-side clamping is unchanged.
|
|
|
|
:param caps: Explicit caps, or ``None`` to read the deployment's.
|
|
:returns: The caps to clamp with; never ``None``.
|
|
"""
|
|
if caps is not None:
|
|
return caps
|
|
try:
|
|
from omnigent.server.smart_routing import routing_settings
|
|
except ImportError: # pragma: no cover — a build without the server extra
|
|
return DEFAULT_MODEL_EFFORT_CAPS
|
|
return routing_settings().model_effort_caps or DEFAULT_MODEL_EFFORT_CAPS
|
|
|
|
|
|
def _bare_model(model: str) -> str:
|
|
"""Strip a catalog/gateway prefix and fold to the comparison spelling."""
|
|
bare = model.rsplit("/", 1)[-1]
|
|
for prefix in ("databricks-", "system.ai."):
|
|
if bare.startswith(prefix):
|
|
bare = bare[len(prefix) :]
|
|
return bare.replace(".", "-").lower()
|
|
|
|
|
|
def clamp_effort_for_model(
|
|
effort: str | None,
|
|
model: str | None,
|
|
*,
|
|
caps: ModelEffortCaps | None = None,
|
|
) -> str | None:
|
|
"""Coerce *effort* to one *model* accepts, keeping the user's pick otherwise.
|
|
|
|
A model whose backend rejects a high effort (e.g. GLM has no ``xhigh``)
|
|
falls back to a supported value rather than 400-ing the turn. Any other
|
|
model, or an already-accepted effort, is returned unchanged.
|
|
|
|
:param caps: Effort ceilings to clamp against; ``None`` uses this
|
|
deployment's (see :func:`model_effort_caps`).
|
|
"""
|
|
if effort is None or model is None:
|
|
return effort
|
|
resolved = model_effort_caps(caps)
|
|
key = _bare_model(model)
|
|
unsupported = resolved.unsupported.get(key)
|
|
if unsupported is not None and effort in unsupported:
|
|
return resolved.fallback.get(key, effort)
|
|
return effort
|
|
|
|
|
|
def effort_for_model_switch(
|
|
effort: str | None,
|
|
model: str | None,
|
|
*,
|
|
caps: ModelEffortCaps | None = None,
|
|
) -> str | None:
|
|
"""Effort to send when switching to *model*, guarding a rejected default.
|
|
|
|
Like :func:`clamp_effort_for_model` for an explicit *effort*. When *effort*
|
|
is ``None`` (no effort requested), a model that caps its ladder still needs
|
|
guarding, because the switched-to thread inherits the config's default
|
|
(which may be too high): return that model's fallback so the live turn does
|
|
not 400. A model with no cap and no requested effort returns ``None``.
|
|
|
|
:param caps: Effort ceilings to clamp against; ``None`` uses this
|
|
deployment's (see :func:`model_effort_caps`).
|
|
"""
|
|
if effort is not None:
|
|
return clamp_effort_for_model(effort, model, caps=caps)
|
|
if model is None:
|
|
return None
|
|
return model_effort_caps(caps).fallback.get(_bare_model(model))
|
|
|
|
|
|
def validate_effort(effort: object, provider: str, supported: Iterable[str]) -> str | None:
|
|
"""Validate *effort* against *supported*, returning a string or None.
|
|
|
|
A deprecated alias (see :data:`EFFORT_ALIASES`) is coerced to its
|
|
canonical value when the raw value is unsupported but the canonical one
|
|
is — e.g. the ChatGPT app's ``ultra`` becomes ``xhigh`` for codex, while
|
|
``max`` stays ``max`` for providers that still support it (Anthropic).
|
|
"""
|
|
if effort is None or effort == "":
|
|
return None
|
|
effort_str = str(effort)
|
|
supported_set = set(supported)
|
|
if effort_str not in supported_set:
|
|
alias = EFFORT_ALIASES.get(effort_str)
|
|
if alias is not None and alias in supported_set:
|
|
return alias
|
|
raise ValueError(unsupported_effort_message(effort_str, provider, supported_set))
|
|
return effort_str
|
|
|
|
|
|
def validate_effort_or_llm_error(
|
|
effort: object,
|
|
provider: str,
|
|
supported: Iterable[str],
|
|
) -> str | None:
|
|
"""Validate for native LLM paths, raising non-retryable PermanentLLMError."""
|
|
try:
|
|
return validate_effort(effort, provider, supported)
|
|
except ValueError as exc:
|
|
raise PermanentLLMError(str(exc), code="unsupported_reasoning_effort") from exc
|