Skip to content
Merged
9 changes: 2 additions & 7 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
)
from cecli.helpers.skills import SkillsManager
from cecli.hooks import HookIntegration
from cecli.llm import litellm
from cecli.mcp import LocalServer, McpServerManager
from cecli.tools.utils.base_tool import BaseTool
from cecli.tools.utils.registry import ToolRegistry
Expand Down Expand Up @@ -364,16 +363,12 @@ async def _exec_async():
}
try:
session = await server.connect()
call_result = await litellm.experimental_mcp_client.call_openai_tool(
session=session, openai_tool=tool_call_dict
)
call_result = await self.call_mcp_tool_from_session(session, tool_call_dict)
except Exception as e:
if server.is_session_expired_error(e):
try:
session = await server.reconnect()
call_result = await litellm.experimental_mcp_client.call_openai_tool(
session=session, openai_tool=tool_call_dict
)
call_result = await self.call_mcp_tool_from_session(session, tool_call_dict)
except Exception as retry_exc:
self.io.tool_warning(
f"Executing {tool_name} on {server.name} failed after reconnect:\n"
Expand Down
258 changes: 178 additions & 80 deletions cecli/coders/base_coder.py

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion cecli/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,24 @@ async def execute(cls, io, coder, args, **kwargs):
f" {active_model.name} does not support images."
)
continue
content = io.read_text(abs_file_path)
try:
content = io.read_text(abs_file_path)
except (ValueError, UnicodeError, UnicodeDecodeError, OSError) as exc:
# Binary or undecodable files (e.g. .git/objects/pack/*.rev)
# raise ValueError("Could not determine text encoding ...")
# from decoding.safe_open. Skip them gracefully instead of
# crashing the session.
msg = str(exc)
if "Could not determine text encoding" in msg or isinstance(
exc, (UnicodeError, UnicodeDecodeError)
):
io.tool_warning(
f"Skipping {matched_file}: not decodable as text "
"(binary or unknown encoding)"
)
else:
io.tool_error(f"Skipping {matched_file}: {exc}")
continue
if content is None:
io.tool_error(f"Unable to read {matched_file}")
else:
Expand Down
2 changes: 1 addition & 1 deletion cecli/commands/reasoning_effort.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class ReasoningEffortCommand(BaseCommand):
@classmethod
async def execute(cls, io, coder, args, **kwargs):
"""Execute the reasoning-effort command with given parameters."""
model = coder.main_model
model = coder.get_active_model()

if not args.strip():
# Display current value if no args are provided
Expand Down
2 changes: 1 addition & 1 deletion cecli/commands/think_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class ThinkTokensCommand(BaseCommand):
@classmethod
async def execute(cls, io, coder, args, **kwargs):
"""Execute the think-tokens command with given parameters."""
model = coder.main_model
model = coder.get_active_model()

if not args.strip():
# Display current value if no args are provided
Expand Down
6 changes: 5 additions & 1 deletion cecli/helpers/io_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ def __init__(self, target: T, coder: Any) -> None:
super().__setattr__("_coder", weakref.ref(coder))
# Per-coder task storage: {coder_uuid: {attr_name: asyncio.Task}}
super().__setattr__("_per_coder", {coder_uuid: {}})
# Last tool `type` emitted via tool_output — lives on the proxy,
# never on the shared target (like coder_uuid)
super().__setattr__("_last_type", None)

# Register a per-coder input queue (TUI mode only)
# Allows the TUI to push input directly to this coder's queue,
Expand All @@ -76,6 +79,7 @@ def tool_output(self, *messages: Any, **kwargs: Any) -> Any:
"""Forward tool_output with coder_uuid injected."""
if "coder_uuid" not in kwargs:
kwargs["coder_uuid"] = self._coder_uuid
self._last_type = kwargs.get("type")
return self._target.tool_output(*messages, **kwargs)

def tool_error(self, message: str = "", strip: bool = True, **kwargs: Any) -> Any:
Expand Down Expand Up @@ -265,7 +269,7 @@ def __getattr__(self, name: str) -> Any:

def __setattr__(self, name: str, value: Any) -> None:
# Proxy-internal attributes — store on proxy instance only
if name in ("_target", "_coder_uuid", "_coder", "_per_coder"):
if name in ("_target", "_coder_uuid", "_coder", "_per_coder", "_last_type"):
super().__setattr__(name, value)
# Per-coder task attributes — isolate per-coder so coders don't
# compete for the same promise on the shared InputOutput instance
Expand Down
10 changes: 10 additions & 0 deletions cecli/helpers/model_config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Derive default per-model configuration from model metadata.

The model config package turns a flat litellm-style model metadata file into the
``{api, llm, agent}`` override blocks that :mod:`cecli.models` consumes,
mirroring the ``model-overrides`` section of ``.cecli.conf.yml``.
"""

from .pipeline import ModelConfigPipeline, get_default_config

__all__ = ["ModelConfigPipeline", "get_default_config"]
40 changes: 40 additions & 0 deletions cecli/helpers/model_config/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Derive the ``agent`` override block for a model.

The agent block holds ModelSettings overrides. :mod:`cecli.models` applies each
of these directly (``setattr``) the same way the ``agent`` section of a
``model-overrides`` entry is applied.
"""

from __future__ import annotations

from typing import Dict, Optional

from .identifiers import is_anthropic
from .utils import supports_reasoning


def derive_agent_config(provider: Optional[str], route: str, record: Optional[Dict]) -> Dict:
"""Return the ``agent`` config block for a model.

Args:
provider: Provider portion of the model name (may be ``None``).
route: Model route (name after the provider prefix).
record: The matched model metadata record, or ``None`` for unknown models.

Returns:
A dict of ModelSettings overrides (caching, temperature handling).
"""
reasoning = supports_reasoning(record)
record = record or {}
agent: Dict = {
"cache_control": is_anthropic(provider, route, record),
# ``cache_read_input_token_cost`` in the metadata is the determinant for
# whether a model supports prompt caching. Unknown models default to
# assuming caching support.
"caches_by_default": bool(record.get("cache_read_input_token_cost")) if record else True,
}

if reasoning or record.get("supports_adaptive_thinking"):
agent["use_temperature"] = False

return agent
62 changes: 62 additions & 0 deletions cecli/helpers/model_config/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Derive the ``api`` override block for a model.

The api block holds request-level parameters. :mod:`cecli.models` merges each
of these keys into ``extra_params`` the same way the ``api`` section of a
``model-overrides`` entry is applied.
"""

from __future__ import annotations

from typing import Dict, Optional

from .identifiers import is_anthropic, is_claude_5_plus, is_gemini_2_5
from .utils import supports_reasoning

_THINKING_BUDGET_TOKENS = 2048
#: Default thinking budget for Gemini 2.5 models (Gemini 2.5 Pro's default).
_GEMINI_THINKING_BUDGET_TOKENS = 8192


def derive_api_config(provider: Optional[str], route: str, record: Optional[Dict]) -> Dict:
"""Return the ``api`` config block for a model.

Args:
provider: Provider portion of the model name (may be ``None``).
route: Model route (name after the provider prefix).
record: The matched model metadata record, or ``None`` for unknown models.

Returns:
A dict of request-level params (reasoning format, thinking, tool calls).
"""
reasoning = supports_reasoning(record)
record = record or {}
gemini_2_5 = is_gemini_2_5(provider, route, record)
api: Dict = {}

if reasoning and not gemini_2_5:
effort = _default_reasoning_effort(record)

if effort:
api["reasoning_effort"] = effort

if is_anthropic(provider, route, record) and not is_claude_5_plus(provider, route, record):
# Claude 5+ uses adaptive thinking via ``reasoning_effort`` instead of
# the ``thinking.type.enabled`` budget block.
api["thinking"] = {"type": "enabled", "budget_tokens": _THINKING_BUDGET_TOKENS}
elif gemini_2_5:
# Gemini 2.5 configures thinking via a token budget; litellm maps the
# generic ``thinking`` param onto ``thinkingBudget`` + ``includeThoughts``.
api["thinking"] = {"type": "enabled", "budget_tokens": _GEMINI_THINKING_BUDGET_TOKENS}

if record.get("supports_parallel_function_calling", True):
api["parallel_tool_calls"] = True

return api


def _default_reasoning_effort(record):
"""Default reasoning effort for a reasoning-capable model.

Always ``medium``; the metadata effort flags are intentionally not used.
"""
return "medium"
20 changes: 20 additions & 0 deletions cecli/helpers/model_config/formatters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Provider-specific helper overrides for the model config pipeline."""

from .reasoning import anthropic_reasoning, format_reasoning, gemini_reasoning, noop
from .thinking import (
anthropic_5_thinking,
anthropic_thinking,
format_thinking,
gemini_thinking,
)

__all__ = [
"format_reasoning",
"anthropic_reasoning",
"gemini_reasoning",
"noop",
"format_thinking",
"anthropic_thinking",
"anthropic_5_thinking",
"gemini_thinking",
]
83 changes: 83 additions & 0 deletions cecli/helpers/model_config/formatters/reasoning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Reasoning formatters for the model config pipeline.

These helpers rewrite the generic reasoning effort shape onto the params
litellm understands for a provider. ``extra_params`` are the kwargs passed to
``litellm.acompletion``, so a formatter exposes the effort as a top-level
litellm param and lets litellm map it onto the provider's own field. For
example, Gemini uses ``thinkingConfig`` under the hood: litellm maps a
top-level ``reasoning_effort`` to ``thinkingLevel`` (Gemini 3) or
``thinkingBudget`` (Gemini 2.5). ``set_reasoning_effort`` in
:mod:`cecli.models` invokes the formatter chosen by the pipeline
(``helpers.format_reasoning``) after it has applied the generic shape.
"""

from __future__ import annotations

from typing import Callable, Dict, Optional

from ..identifiers import is_claude_5_plus, is_gemini


def format_reasoning(provider: Optional[str], route: str, record: Optional[Dict]) -> Callable:
"""Return the reasoning formatter for a model.

Args:
provider: Provider portion of the model name (may be ``None``).
route: Model route (name after the provider prefix).
record: The matched model metadata record, or ``None`` for unknown models.

Returns:
A callable that mutates ``extra_params`` in place. Unknown models get
a noop so ``set_reasoning_effort`` keeps its default behavior.
"""
if is_gemini(provider, route, record):
return gemini_reasoning

if is_claude_5_plus(provider, route, record):
return anthropic_reasoning

return noop


def noop(extra_params: Dict) -> Dict:
"""Default formatter: leave ``extra_params`` untouched."""
return extra_params


def gemini_reasoning(extra_params: Dict) -> Dict:
"""Gemini models configure thinking via litellm's ``reasoning_effort``.

litellm maps the top-level ``reasoning_effort`` kwarg onto Gemini's
``thinkingConfig`` (``thinkingLevel`` for Gemini 3, ``thinkingBudget`` for
Gemini 2.5) and sets ``includeThoughts``, so the generic effort is lifted
out of ``extra_body``.
"""
return _lift_reasoning_effort(extra_params)


def anthropic_reasoning(extra_params: Dict) -> Dict:
"""Claude 5+ models configure thinking via litellm's ``reasoning_effort``.

litellm maps the top-level ``reasoning_effort`` kwarg onto
``thinking.type.adaptive`` + ``output_config.effort``, so the generic
effort is lifted out of ``extra_body`` and ``extra_body`` is dropped
(Anthropic does not accept extra inputs).
"""
params = _lift_reasoning_effort(extra_params)
params.pop("extra_body", None)
return params


def _lift_reasoning_effort(extra_params: Dict) -> Dict:
"""Move the generic ``reasoning_effort`` out of ``extra_body`` to top level."""
extra_body = extra_params.get("extra_body")

if not isinstance(extra_body, dict):
return extra_params

effort = extra_body.pop("reasoning_effort", None)

if effort is not None:
extra_params["reasoning_effort"] = effort

return extra_params
Loading
Loading