Skip to content
Open
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
28 changes: 25 additions & 3 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2860,6 +2860,8 @@ async def format_in_executor():
should_retry = ex_info.retry
if ex_info.name == "ServiceUnavailableError":
should_retry = should_retry or retry_config["retry_on_unavailable"]
if ex_info.name == "PermissionDeniedError":
should_retry = should_retry or retry_config["retry_on_forbidden"]

if should_retry:
retry_delay *= retry_config["retry_backoff_factor"]
Expand Down Expand Up @@ -3986,7 +3988,7 @@ async def show_send_output(self, completion):
if (
not len(self.partial_response_content)
and not len(self.partial_response_tool_calls)
and not len(self.partial_response_reasoning_content)
and not _is_meaningful_reasoning(self.partial_response_reasoning_content)
):
self.empty_response = True
return
Expand Down Expand Up @@ -4116,7 +4118,8 @@ async def show_send_output_stream(self, completion):

text += reasoning_content
self.got_reasoning_content = True
received_content = True
if _is_meaningful_reasoning(reasoning_content):
received_content = True

self.token_profiler.on_token()
self.io.update_spinner_suffix(reasoning_content)
Expand Down Expand Up @@ -4192,7 +4195,14 @@ async def show_send_output_stream(self, completion):
self.io.tool_warning("Execution stopped by on message hook")
return

if not received_content and len(self.partial_response_tool_calls) == 0:
# Treat the response as empty when nothing was received, or when the
# only thing received was reasoning made entirely of non-alphanumeric
# characters (e.g. moonshotai/kimi-k3 returning "!!!!").
if (
not received_content
and len(self.partial_response_tool_calls) == 0
and not _is_meaningful_reasoning(self.partial_response_reasoning_content)
):
self.empty_response = True
return

Expand Down Expand Up @@ -5381,3 +5391,15 @@ def _first_usage_tokens(usage: object, paths: list[str], default: int = 0) -> in
if value is not None:
return value
return default


def _is_meaningful_reasoning(text):
"""Return True if reasoning text contains at least one alphanumeric character.

Some providers (e.g. moonshotai/kimi-k3) occasionally return completions
with empty ``content`` and a ``reasoning_content`` made entirely of
punctuation (e.g. ``"!!!!"``). Those responses are effectively empty, so
the empty-response detector only lets reasoning count as response
content when it holds at least one alphanumeric character.
"""
return bool(text) and any(ch.isalnum() for ch in text)
28 changes: 24 additions & 4 deletions cecli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ class ModelSettings:
retries: Optional[dict] = None
retry_backoff_factor: float = 1.5
retry_on_unavailable: bool = True
retry_on_forbidden: bool = False
retry_timeout: float = 30
request_timeout: int = request_timeout
debug: bool = False
Expand Down Expand Up @@ -1452,10 +1453,22 @@ async def send_completion(
litellm_ex = LiteLLMExceptions()
retry_delay = 0.125

retry_config = parse_retry_config(self.retries)
self.retry_on_unavailable = retry_config["retry_on_unavailable"]
self.retry_backoff_factor = retry_config["retry_backoff_factor"]
self.retry_timeout = retry_config["retry_timeout"]
if self.retries:
retry_config = dict()
try:
retry_config = json.loads(self.retries)
except (json.JSONDecodeError, TypeError, ValueError):
retry_config = dict()
pass

self.retry_on_unavailable = bool(
nested.getter(retry_config, "retry-on-unavailable", True)
)
self.retry_on_forbidden = bool(nested.getter(retry_config, "retry-on-forbidden", False))
self.retry_backoff_factor = float(
nested.getter(retry_config, "retry-backoff-factor", 1.5)
)
self.retry_timeout = float(nested.getter(retry_config, "retry-timeout", 30))

while True:
try:
Expand Down Expand Up @@ -1486,6 +1499,8 @@ async def send_completion(
should_retry = ex_info.retry
if ex_info.name == "ServiceUnavailableError":
should_retry = should_retry or self.retry_on_unavailable
elif ex_info.name == "PermissionDeniedError":
should_retry = should_retry or self.retry_on_forbidden

custom_retry_delay = self._extract_retry_delay(err)
if custom_retry_delay is not None:
Expand Down Expand Up @@ -1550,6 +1565,7 @@ async def simple_send_with_retries(
retry_backoff_factor = retry_config["retry_backoff_factor"]
retry_timeout = retry_config["retry_timeout"]
retry_on_unavailable = retry_config["retry_on_unavailable"]
retry_on_forbidden = retry_config["retry_on_forbidden"]

if self.verbose:
dump(messages)
Expand Down Expand Up @@ -1603,6 +1619,8 @@ async def simple_send_with_retries(
should_retry = ex_info.retry
if ex_info.name == "ServiceUnavailableError":
should_retry = should_retry or retry_on_unavailable
elif ex_info.name == "PermissionDeniedError":
should_retry = should_retry or retry_on_forbidden

custom_retry_delay = self._extract_retry_delay(err)
if custom_retry_delay is not None:
Expand Down Expand Up @@ -1796,6 +1814,7 @@ def parse_retry_config(retries_input):
retry_timeout: 30
retry_backoff_factor: 1.5
retry_on_unavailable: True
retry_on_forbidden: False
retry_on_empty: False
"""
config = dict()
Expand All @@ -1821,6 +1840,7 @@ def _get(key, default):
"retry_timeout": float(_get("retry_timeout", 30)),
"retry_backoff_factor": float(_get("retry_backoff_factor", 1.5)),
"retry_on_unavailable": bool(_get("retry_on_unavailable", True)),
"retry_on_forbidden": bool(_get("retry_on_forbidden", False)),
"retry_on_empty": bool(_get("retry_on_empty", False)),
}

Expand Down
Loading
Loading