diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 679cba8f3e5..462d78f4812 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -3440,11 +3440,17 @@ async def add_assistant_reply_to_cur_messages(self): to be `None` when `tool_calls` are present. """ msg = dict(role="assistant") - response = ( - self.partial_response_chunks[0] - if not self.stream - else litellm.stream_chunk_builder(self.partial_response_chunks) - ) + + # Prefer the response already produced by consolidate_chunks(): it carries + # the provider-specific fields (e.g. reasoning_items) that we preserved + # across all chunks, which a fresh litellm.stream_chunk_builder() pass + # alone would drop or truncate. + if self.partial_response_consolidated: + response = self.partial_response_consolidated[0] + elif not self.stream: + response = self.partial_response_chunks[0] + else: + response = litellm.stream_chunk_builder(self.partial_response_chunks) try: # Use response_dict as a regular dictionary @@ -3963,63 +3969,51 @@ def consolidate_chunks(self): if getattr(last_chunk, "usage", None): response.usage = last_chunk.usage - # Collect provider-specific fields from chunks to preserve them - # We need to track both by ID (primary) and index (fallback) since - # early chunks might not have IDs established yet - provider_specific_fields_by_id = {} - provider_specific_fields_by_index = {} - + # Collect message-level provider-specific fields (e.g. `reasoning_items` + # for reasoning models) from ALL chunks. litellm's stream_chunk_builder() + # merges these with last-wins semantics for list fields, silently dropping + # every reasoning item except the final one. Reasoning models depend on the + # full ordered item list being present in the assistant message so that + # exact-prefix prompt caching keeps working across turns, so we collect the + # fields ourselves and concatenate list-valued entries. + message_provider_specific_fields = {} for chunk in self.partial_response_chunks: try: - if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.tool_calls: - for tool_call in chunk.choices[0].delta.tool_calls: - if ( - hasattr(tool_call, "provider_specific_fields") - and tool_call.provider_specific_fields - ): - # Ensure provider_specific_fields is a dictionary - psf = tool_call.provider_specific_fields - if not isinstance(psf, dict): - continue - - # Try to use ID first - if hasattr(tool_call, "id") and tool_call.id: - tool_id = tool_call.id - if tool_id not in provider_specific_fields_by_id: - provider_specific_fields_by_id[tool_id] = {} - # Merge provider-specific fields for this tool ID - provider_specific_fields_by_id[tool_id].update(psf) - # Also track by index as fallback - elif hasattr(tool_call, "index"): - tool_index = tool_call.index - if tool_index not in provider_specific_fields_by_index: - provider_specific_fields_by_index[tool_index] = {} - provider_specific_fields_by_index[tool_index].update(psf) + if chunk.choices and chunk.choices[0].delta: + psf = getattr(chunk.choices[0].delta, "provider_specific_fields", None) + if psf and isinstance(psf, dict): + for key, value in psf.items(): + if isinstance(value, list): + message_provider_specific_fields.setdefault(key, []).extend(value) + elif value is not None: + message_provider_specific_fields[key] = value except (AttributeError, IndexError): continue + if message_provider_specific_fields: + message_psf = getattr(response.choices[0].message, "provider_specific_fields", None) + if not isinstance(message_psf, dict): + message_psf = {} + message_psf.update(message_provider_specific_fields) + response.choices[0].message.provider_specific_fields = message_psf + try: - if response.choices[0].message.tool_calls: - for i, tool_call in enumerate(response.choices[0].message.tool_calls): - # Add provider-specific fields if we collected any for this tool - tool_id = tool_call.id - - # Try ID first - if tool_id in provider_specific_fields_by_id: - # Add provider-specific fields directly to the tool call object - tool_call.provider_specific_fields = provider_specific_fields_by_id[tool_id] - # Fall back to index - elif i in provider_specific_fields_by_index: - # Add provider-specific fields directly to the tool call object - tool_call.provider_specific_fields = provider_specific_fields_by_index[i] - - # Only append to partial_response_tool_calls if it's empty - if len(self.partial_response_tool_calls) == 0: - self.partial_response_tool_calls.append(tool_call) - - self.partial_response_function_call = ( - response.choices[0].message.tool_calls[0].function - ) + message_tool_calls = response.choices[0].message.tool_calls + if message_tool_calls and len(message_tool_calls): + if self.stream: + built_tool_calls = self._build_tool_calls_from_chunks() + if built_tool_calls: + response.choices[0].message.tool_calls = built_tool_calls + self.partial_response_tool_calls = built_tool_calls + else: + # Fall back to litellm's merged list, keeping every call + self.partial_response_tool_calls = list(message_tool_calls) + else: + # Non-streaming: the single response chunk already carries the + # full tool_calls list + self.partial_response_tool_calls = list(message_tool_calls) + + self.partial_response_function_call = self.partial_response_tool_calls[0].function except AttributeError as e: func_err = e @@ -4075,6 +4069,85 @@ def consolidate_chunks(self): self.partial_response_consolidated = (response, func_err, content_err) return response, func_err, content_err + def _build_tool_calls_from_chunks(self): + """Rebuild tool calls from the raw streaming chunks, keyed by delta index. + + Streaming deltas for parallel tool calls arrive interleaved and may start + at any index (not necessarily 0). Indexing into a dict by the delta's + tool-call ``index`` before converting it back to a list ensures every + parallel call is preserved, correctly ordered, and keeps its + provider-specific fields (e.g. thought signatures) attached. + """ + from litellm.types.utils import ChatCompletionMessageToolCall, Function + + tool_calls_dict = {} + + for chunk in self.partial_response_chunks: + try: + if not (chunk.choices and chunk.choices[0].delta): + continue + + delta = chunk.choices[0].delta + for tool_call in delta.tool_calls or []: + if tool_call is None: + continue + + if nested.getter(tool_call, "function") is None: + continue + + index = nested.getter(tool_call, "index") + if index is None: + index = len(tool_calls_dict) + + entry = tool_calls_dict.setdefault( + index, + { + "id": None, + "name": None, + "type": "function", + "arguments": [], + "provider_specific_fields": {}, + }, + ) + + entry["id"] = nested.getter(tool_call, "id") or entry["id"] + entry["type"] = nested.getter(tool_call, "type") or entry["type"] + entry["name"] = nested.getter(tool_call, "function.name") or entry["name"] + + arguments = nested.getter(tool_call, "function.arguments") + if arguments: + entry["arguments"].append(arguments) + + psf = nested.getter(tool_call, "provider_specific_fields") + if not psf: + psf = nested.getter(tool_call, "function.provider_specific_fields") + if psf and isinstance(psf, dict): + entry["provider_specific_fields"].update(psf) + except (AttributeError, IndexError): + continue + + tool_calls = [] + for index in sorted(tool_calls_dict.keys()): + data = tool_calls_dict[index] + if not (data["id"] and data["name"]): + continue + + function = Function( + arguments="".join(data["arguments"]) or "{}", + name=data["name"], + ) + params = { + "id": data["id"], + "function": function, + "type": data["type"] or "function", + } + if data["provider_specific_fields"]: + params["provider_specific_fields"] = data["provider_specific_fields"] + + tool_calls.append(ChatCompletionMessageToolCall(**params)) + + return tool_calls + def stream_wrapper(self, content, final): if not hasattr(self, "_streaming_buffer_length"): self._streaming_buffer_length = 0 diff --git a/cecli/commands/reasoning_effort.py b/cecli/commands/reasoning_effort.py index e2dd29c1366..f784360e46c 100644 --- a/cecli/commands/reasoning_effort.py +++ b/cecli/commands/reasoning_effort.py @@ -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 diff --git a/cecli/commands/think_tokens.py b/cecli/commands/think_tokens.py index 71b77e31166..ae30d024c73 100644 --- a/cecli/commands/think_tokens.py +++ b/cecli/commands/think_tokens.py @@ -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 diff --git a/cecli/helpers/io_proxy.py b/cecli/helpers/io_proxy.py index bc5754f4e1a..0dfb87154b4 100644 --- a/cecli/helpers/io_proxy.py +++ b/cecli/helpers/io_proxy.py @@ -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, @@ -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: @@ -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 diff --git a/cecli/helpers/model_config/__init__.py b/cecli/helpers/model_config/__init__.py new file mode 100644 index 00000000000..d1eb259782f --- /dev/null +++ b/cecli/helpers/model_config/__init__.py @@ -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"] diff --git a/cecli/helpers/model_config/agent.py b/cecli/helpers/model_config/agent.py new file mode 100644 index 00000000000..f25a41da3ee --- /dev/null +++ b/cecli/helpers/model_config/agent.py @@ -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 diff --git a/cecli/helpers/model_config/api.py b/cecli/helpers/model_config/api.py new file mode 100644 index 00000000000..93327de73a2 --- /dev/null +++ b/cecli/helpers/model_config/api.py @@ -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" diff --git a/cecli/helpers/model_config/formatters/__init__.py b/cecli/helpers/model_config/formatters/__init__.py new file mode 100644 index 00000000000..9d818a462c4 --- /dev/null +++ b/cecli/helpers/model_config/formatters/__init__.py @@ -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", +] diff --git a/cecli/helpers/model_config/formatters/reasoning.py b/cecli/helpers/model_config/formatters/reasoning.py new file mode 100644 index 00000000000..36a5952d5d8 --- /dev/null +++ b/cecli/helpers/model_config/formatters/reasoning.py @@ -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 diff --git a/cecli/helpers/model_config/formatters/thinking.py b/cecli/helpers/model_config/formatters/thinking.py new file mode 100644 index 00000000000..d0655738398 --- /dev/null +++ b/cecli/helpers/model_config/formatters/thinking.py @@ -0,0 +1,108 @@ +"""Thinking formatters for the model config pipeline. + +These helpers rewrite the generic thinking shape onto the params litellm +understands for a provider. ``extra_params`` are the kwargs passed to +``litellm.acompletion``, so a formatter exposes thinking 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 + anthropic-style ``thinking`` param to ``thinkingBudget`` (Gemini 2.5) or + ``thinkingLevel`` (Gemini 3) and sets ``includeThoughts``. +- Anthropic consumes the top-level ``thinking`` kwarg directly; riding inside + ``extra_body`` makes the API reject it as extra inputs. + +``set_thinking_tokens`` in :mod:`cecli.models` invokes the formatter chosen by +the pipeline (``helpers.format_thinking``) after it has applied the generic +shape. +""" + +from __future__ import annotations + +from typing import Callable, Dict, Optional + +from ..identifiers import is_anthropic, is_claude_5_plus, is_gemini + + +def format_thinking(provider: Optional[str], route: str, record: Optional[Dict]) -> Callable: + """Return the thinking 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_thinking_tokens`` keeps its default behavior. + """ + if is_gemini(provider, route, record): + return gemini_thinking + + if is_anthropic(provider, route, record): + if is_claude_5_plus(provider, route, record): + # Claude 5+ cannot use thinking.type.enabled; remove it entirely. + return anthropic_5_thinking + + return anthropic_thinking + + return noop + + +def noop(extra_params: Dict) -> Dict: + """Default formatter: leave ``extra_params`` untouched.""" + return extra_params + + +def gemini_thinking(extra_params: Dict) -> Dict: + """Gemini models configure thinking via litellm's ``thinking`` kwarg. + + litellm maps the top-level anthropic-style ``thinking`` param onto + Gemini's ``thinkingConfig`` (``thinkingBudget`` for Gemini 2.5, + ``thinkingLevel`` for Gemini 3) and sets ``includeThoughts``, so the + generic shape is lifted out of ``extra_body``. + """ + return _lift_thinking(extra_params) + + +def anthropic_thinking(extra_params: Dict) -> Dict: + """Anthropic (pre-5) models consume the top-level ``thinking`` kwarg. + + litellm maps the top-level anthropic-style ``thinking`` param into the + request body, so it must not ride inside ``extra_body`` (which the + Anthropic API rejects as extra inputs). + """ + params = _lift_thinking(extra_params) + params.pop("extra_body", None) + return params + + +def anthropic_5_thinking(extra_params: Dict) -> Dict: + """Claude 5+ models cannot use ``thinking.type.enabled``. + + Thinking is instead controlled via ``reasoning_effort`` (which litellm maps + to ``thinking.type.adaptive`` + ``output_config.effort``), so any thinking + block is removed entirely and ``extra_body`` is dropped. + """ + extra_params.pop("thinking", None) + extra_body = extra_params.get("extra_body") + + if isinstance(extra_body, dict): + extra_body.pop("thinking", None) + + extra_params.pop("extra_body", None) + return extra_params + + +def _lift_thinking(extra_params: Dict) -> Dict: + """Move the generic ``thinking`` shape out of ``extra_body`` to top level.""" + extra_body = extra_params.get("extra_body") + + if not isinstance(extra_body, dict): + return extra_params + + thinking = extra_body.pop("thinking", None) + + if thinking is not None: + extra_params["thinking"] = thinking + + return extra_params diff --git a/cecli/helpers/model_config/identifiers.py b/cecli/helpers/model_config/identifiers.py new file mode 100644 index 00000000000..911ca31a517 --- /dev/null +++ b/cecli/helpers/model_config/identifiers.py @@ -0,0 +1,77 @@ +"""Model identifier helpers for the model config pipeline. + +These predicates classify a model by family/provider from its name prefix, +route, and metadata record. Centralizing them keeps the config modules +(api.py, llm.py, agent.py) and formatters free of repeated provider matching. +""" + +from __future__ import annotations + +import re + + +def _haystack(provider, route, record): + """Lowercased, space-joined provider/route/record-provider for matching.""" + provider = (provider or "").lower() + route = (route or "").lower() + record_provider = ((record or {}).get("litellm_provider") or "").lower() + return " ".join([provider, route, record_provider]) + + +def is_anthropic(provider, route, record): + """True when the model is an Anthropic-family model (Claude).""" + haystack = _haystack(provider, route, record) + return "anthropic" in haystack or "claude" in (route or "").lower() + + +def is_gemini(provider, route, record): + """True when the model is a Gemini-series model.""" + return "gemini" in _haystack(provider, route, record) + + +def is_gemini_2_5(provider, route, record): + """True when the model is a Gemini 2.5-series model.""" + return "gemini-2.5" in (route or "").lower() + + +def is_claude_5_plus(provider, route, record): + """True for Claude 5+ models, which use adaptive thinking + output_config. + + Claude 5+ does not accept ``thinking.type.enabled``; thinking is controlled + via ``thinking.type.adaptive`` and ``output_config.effort`` (which litellm + derives from a top-level ``reasoning_effort`` param). + """ + route = (route or "").lower() + match = re.search(r"claude[^\d]*(\d+)", route) + + if not match: + return False + + return int(match.group(1)) >= 5 + + +def is_github_copilot(provider, route, record): + """True when the model is served through GitHub Copilot.""" + provider = (provider or "").lower() + record_provider = ((record or {}).get("litellm_provider") or "").lower() + return provider == "github_copilot" or record_provider == "github_copilot" + + +def is_meta(provider, route, record): + """True when the model is a Meta-provider model.""" + provider = (provider or "").lower() + record_provider = ((record or {}).get("litellm_provider") or "").lower() + return provider == "meta" or record_provider == "meta" + + +def gpt_version(route): + """Return the leading ``gpt-`` model version, or 0 when not a gpt model. + + e.g. ``gpt-5.6-luna`` -> 5.6, ``gpt-5`` -> 5, ``claude-3`` -> 0. + """ + match = re.match(r"^gpt-(\d+(?:\.\d+)?)", (route or "").lower()) + + if not match: + return 0 + + return float(match.group(1)) diff --git a/cecli/helpers/model_config/llm.py b/cecli/helpers/model_config/llm.py new file mode 100644 index 00000000000..7321f4f56d9 --- /dev/null +++ b/cecli/helpers/model_config/llm.py @@ -0,0 +1,108 @@ +"""Derive the ``llm`` override block for a model. + +The llm block holds model metadata overrides. :mod:`cecli.models` merges these +into ``model.info`` the same way the ``llm`` section of a ``model-overrides`` +entry is applied. +""" + +from __future__ import annotations + +from typing import Dict, Optional + +from .identifiers import gpt_version, is_anthropic, is_github_copilot, is_meta +from .utils import supports_reasoning + +RESPONSES_ENDPOINT = "/v1/responses" +CHAT_ENDPOINT = "/v1/chat/completions" +MESSAGES_ENDPOINT = "/v1/messages" + + +def derive_llm_config(provider: Optional[str], route: str, record: Optional[Dict]) -> Dict: + """Return the ``llm`` 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 model-info overrides (provider, limits, mode, capabilities). + """ + reasoning = supports_reasoning(record) + record = record or {} + endpoints = record.get("supported_endpoints") or [] + mode = _endpoint_mode(endpoints, provider, route, record) + llm: Dict = { + "litellm_provider": record.get("litellm_provider") or provider, + # Token limits are taken verbatim from the metadata record; no guessing + # or cross-key fallbacks between max_tokens/max_input_tokens/ + # max_output_tokens. + "max_input_tokens": record.get("max_input_tokens"), + "max_output_tokens": record.get("max_output_tokens"), + "max_tokens": record.get("max_tokens"), + "mode": mode, + # All models are assumed to support tool calling. + "supports_function_calling": True, + # Streaming is assumed to be supported unless the metadata says otherwise. + "supports_stream": bool(record.get("supports_stream", True)), + "supports_parallel_function_calling": bool( + record.get("supports_parallel_function_calling", True) + ), + "supports_response_schema": bool(record.get("supports_response_schema", True)), + "supports_reasoning": reasoning, + "supports_tool_choice": bool(record.get("supports_tool_choice", True)), + "supports_vision": bool(record.get("supports_vision", False)), + } + endpoint = _endpoint_for_mode(mode, endpoints, provider, route, record) + + if endpoint: + llm["supported_endpoints"] = [endpoint] + + return {k: v for k, v in llm.items() if v is not None} + + +def _endpoint_mode(endpoints, provider, route, record): + """Pick responses vs chat mode based on the supported endpoints. + + Two rules force responses mode regardless of the record's endpoint list: + 1. GitHub Copilot gpt models newer than 5 (excluding ``mini`` variants). + 2. Meta-provider models. + """ + if RESPONSES_ENDPOINT in (endpoints or []): + return "responses" + + if _should_use_responses(provider, route, record): + return "responses" + + return "chat" + + +def _endpoint_for_mode(mode, endpoints, provider, route, record): + """Return the single endpoint that matches the derived endpoint type.""" + if mode == "responses": + return RESPONSES_ENDPOINT + + if MESSAGES_ENDPOINT in (endpoints or []) and is_anthropic(provider, route, record): + return MESSAGES_ENDPOINT + + if CHAT_ENDPOINT in (endpoints or []): + return CHAT_ENDPOINT + + return None + + +def _should_use_responses(provider, route, record): + """True when the model should use the responses API. + + Two rules force responses mode regardless of the record's endpoint list: + 1. GitHub Copilot gpt models newer than 5 (excluding ``mini`` variants). + 2. Meta-provider models. + """ + if is_github_copilot(provider, route, record): + if "mini" not in (route or "").lower() and gpt_version(route) >= 5: + return True + + if is_meta(provider, route, record): + return True + + return False diff --git a/cecli/helpers/model_config/pipeline.py b/cecli/helpers/model_config/pipeline.py new file mode 100644 index 00000000000..2c95ee83dcd --- /dev/null +++ b/cecli/helpers/model_config/pipeline.py @@ -0,0 +1,345 @@ +"""Metadata-driven model config pipeline. + +This module mirrors the request pipeline in ``cecli/helpers/requests.py``: +``get_default_config`` feeds a small context dict through a chain of step +functions, each of which transforms the context and returns it. + +Like ``cecli/models.py``, large metadata files are scanned as raw JSON strings +(one entry at a time) instead of being ``json.loads``-ed wholesale, so a model +lookup never materializes the full metadata dict in memory. +""" + +from __future__ import annotations + +import importlib.resources as importlib_resources +import re +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +from cecli.helpers.config_utils import deep_merge + +from .agent import derive_agent_config +from .api import derive_api_config +from .formatters.reasoning import format_reasoning +from .formatters.thinking import format_thinking +from .llm import derive_llm_config +from .utils import get_entry_from_raw, top_level_keys + +RESOURCE_FILE = "model-metadata.json" + +#: Suffix segments dropped when looking for a nearby model family. +#: ``gpt-5.6-luna`` -> ``gpt-5.6`` -> ``gpt-5`` -> ``gpt`` +_TRAILING_SEGMENT_RE = re.compile(r"[-.][^.]+$") + +#: Lazily loaded raw text of the bundled metadata file (never json.loads-ed). +_BUNDLED_RAW_CACHE: Optional[str] = None + +MetadataSource = Union[str, Path, Dict[str, Any]] + + +class ModelConfigPipeline: + """Chain a model name and metadata sources through the config steps.""" + + def __init__(self, metadata_files: Optional[List[MetadataSource]] = None) -> None: + self.metadata_files = metadata_files + + def get_default_config( + self, model_name: str, metadata_files: Optional[List[MetadataSource]] = None + ) -> Dict[str, Any]: + """Return the ``{api, llm, agent}`` default config for ``model_name``. + + Args: + model_name: Fully qualified model name, e.g. ``openai/gpt-5``. + metadata_files: Optional list of JSON file paths, raw JSON strings, + or already-parsed metadata dicts. Defaults to the bundled + ``cecli/resources/model-metadata.json``. + + Returns: + A dict with ``api``, ``llm`` and ``agent`` blocks in the same shape + as the ``model-overrides`` entries of ``.cecli.conf.yml``. + """ + if metadata_files is None: + metadata_files = self.metadata_files + + context = dict(model_name=model_name, metadata_files=metadata_files) + context = _split_model_name(context) + context = _load_metadata(context) + context = _find_model_record(context) + context = _build_config(context) + return context["config"] + + +def get_default_config( + model_name: str, metadata_files: Optional[List[MetadataSource]] = None +) -> Dict[str, Any]: + """Convenience wrapper around a default :class:`ModelConfigPipeline`.""" + return ModelConfigPipeline().get_default_config(model_name, metadata_files) + + +# --------------------------------------------------------------------------- +# Pipeline steps +# --------------------------------------------------------------------------- + + +def _split_model_name(context): + """Split the model name into (provider, route) on the first slash.""" + model = context["model_name"] + provider, route = None, model + + if "/" in model: + provider, route = model.split("/", 1) + + context["provider"] = provider + context["route"] = route + return context + + +def _load_metadata(context): + """Normalize the metadata sources into scan-able source dicts.""" + files = context["metadata_files"] + + if files is None: + context["sources"] = [{"kind": "raw", "text": _bundled_metadata_raw()}] + return context + + context["sources"] = [_normalize_source(source) for source in _as_list(files)] + return context + + +def _find_model_record(context): + """Resolve the metadata record for the model (exact or closest match).""" + context["record"] = _find_record( + context["sources"], + context["model_name"], + context["provider"], + context["route"], + ) + return context + + +def _build_config(context): + """Derive the api/llm/agent blocks from the resolved metadata record.""" + provider = context["provider"] + route = context["route"] + record = context["record"] + llm = derive_llm_config(provider, route, record) + api = derive_api_config(provider, route, record) + agent = derive_agent_config(provider, route, record) + + if llm.get("mode") == "responses": + # Responses-mode models do not use sampling temperature, and reasoning + # is returned via the encrypted content path rather than stored. Deep + # merge so an existing extra_body "include" list is combined, not + # replaced. + agent["use_temperature"] = False + api["extra_body"] = deep_merge( + api.get("extra_body", {}), + {"store": False, "include": ["reasoning.encrypted_content"]}, + ) + + context["config"] = { + "api": api, + "llm": llm, + "agent": agent, + "helpers": { + "format_reasoning": format_reasoning(provider, route, record), + "format_thinking": format_thinking(provider, route, record), + }, + } + return context + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + + +def _bundled_metadata_raw() -> str: + """Return the raw text of the packaged metadata file (cached, not parsed).""" + global _BUNDLED_RAW_CACHE + + if _BUNDLED_RAW_CACHE is None: + try: + resource = importlib_resources.files("cecli.resources").joinpath(RESOURCE_FILE) + _BUNDLED_RAW_CACHE = resource.read_text() + + except Exception: + _BUNDLED_RAW_CACHE = "" + + return _BUNDLED_RAW_CACHE + + +def _as_list(value): + """Normalize a single source or a list of sources into a list.""" + if isinstance(value, (list, tuple)): + return list(value) + + return [value] + + +def _normalize_source(source): + """Return a scan-able source: ``{"kind": "dict", ...}`` or ``{"kind": "raw", ...}``.""" + if isinstance(source, dict): + return {"kind": "dict", "data": source} + + if isinstance(source, (str, Path)): + path = Path(source) + + if path.exists(): + try: + if path.name == RESOURCE_FILE: + return {"kind": "raw", "text": _bundled_metadata_raw()} + + return {"kind": "raw", "text": path.read_text()} + + except OSError: + return {"kind": "dict", "data": {}} + + # Not a file path: treat it as a raw JSON string. + return {"kind": "raw", "text": source} + + return {"kind": "dict", "data": {}} + + +def _find_record(sources, model_name, provider, route): + """Find the best metadata record for a model name. + + Lookup order: + 1. Exact match on the full model name. + 2. Exact match on the route (name after the provider prefix). + 3. Progressively shortened routes, preferring newer model families + (``gpt-5.6-luna`` -> ``gpt-5.6`` -> ``gpt-5`` -> ``gpt``). + 4. Closest same-provider match by longest shared route prefix. + """ + if not sources: + return None + + for key in _candidate_keys(model_name, route, provider): + record = _lookup_entry(sources, key) + + if record: + return record + + return _closest_provider_match(sources, provider, route) + + +def _candidate_keys(model_name, route, provider): + """Ordered lookup keys: full name, same-provider families, route, bare families. + + Same-provider family candidates come before the bare route so a provider + prefix is never silently dropped for a bare (possibly different-provider) + record, e.g. ``github_copilot/gpt-5.6-luna`` should resolve to the + ``github_copilot/gpt-5`` family rather than the bare ``gpt-5.6-luna`` + (openai) record. + """ + keys = [model_name] + shortened = _shorten_route(route) if provider and route else [] + + if provider and route: + keys.extend(f"{provider}/{candidate}" for candidate in shortened) + + if route and route != model_name: + keys.append(route) + + if provider and route: + keys.extend(shortened) + + return keys + + +def _lookup_entry(sources, key): + """Return the entry for ``key`` from the sources (later sources win).""" + for source in reversed(sources): + if source["kind"] == "dict": + record = source["data"].get(key) + + if record: + return record + + else: + record = get_entry_from_raw(source["text"], key) + + if record: + return record + + return None + + +def _shorten_route(route): + """Yield progressively shorter routes, e.g. ``gpt-5.6-luna`` -> ``gpt-5``.""" + candidates = [] + current = route + + while current: + shortened = _TRAILING_SEGMENT_RE.sub("", current) + + if not shortened or shortened == current: + break + + candidates.append(shortened) + current = shortened + + return candidates + + +def _closest_provider_match(sources, provider, route): + """Return the same-provider record with the longest shared route prefix. + + Raw sources are enumerated via a lightweight top-level key scan (a string + pass, never a full parse) and only ``provider/``-prefixed keys are parsed + for scoring, so the fallback stays memory friendly. + """ + if not provider: + return None + + best = None + best_score = -1 + prefix = provider + "/" + + for source in reversed(sources): + if source["kind"] == "dict": + for key, record in source["data"].items(): + if not isinstance(record, dict): + continue + + key_route = key.split("/", 1)[-1] if "/" in key else key + same_provider = record.get("litellm_provider") == provider or key.startswith(prefix) + + if not same_provider: + continue + + score = _prefix_score(route or "", key_route) + + if score > best_score: + best_score = score + best = record + + else: + for key in top_level_keys(source["text"]): + if not key.startswith(prefix): + continue + + record = get_entry_from_raw(source["text"], key) + + if not record: + continue + + score = _prefix_score(route or "", key[len(prefix) :]) + + if score > best_score: + best_score = score + best = record + + return best + + +def _prefix_score(left, right): + """Count the number of matching leading characters between two strings.""" + score = 0 + + for a, b in zip(left, right): + if a != b: + break + score += 1 + + return score diff --git a/cecli/helpers/model_config/utils.py b/cecli/helpers/model_config/utils.py new file mode 100644 index 00000000000..0c3de0b0262 --- /dev/null +++ b/cecli/helpers/model_config/utils.py @@ -0,0 +1,163 @@ +"""Shared helpers for the model config system. + +These string-scanning helpers mirror ``cecli/models.py``'s approach of working +with raw JSON text so model metadata is never materialized into a full dict. +""" + +from __future__ import annotations + + +def get_entry_from_raw(raw, key): + """Parse a single ``key`` entry from a raw JSON string (never the whole dict). + + Mirrors ``ModelInfoManager._get_entry_from_raw`` in ``cecli/models.py``, + with a tighter lookbehind so ``provider/...``-prefixed keys never shadow a + bare route lookup (e.g. ``github_copilot/gpt-5`` vs ``gpt-5``). + """ + import json + import re + + if not raw: + return None + + escaped_key = re.escape(key) + match = re.search(rf'(?= len(raw) or raw[start] != "{": + return None + + depth = 1 + pos = start + 1 + in_string = False + escape = False + + while pos < len(raw) and depth > 0: + ch = raw[pos] + + if escape: + escape = False + + elif ch == "\\": + escape = True + + elif ch == '"': + in_string = not in_string + + elif not in_string: + if ch == "{": + depth += 1 + + elif ch == "}": + depth -= 1 + + pos += 1 + + if depth != 0: + return None + + try: + return json.loads(raw[start:pos]) + + except json.JSONDecodeError: + return None + + +def top_level_keys(raw): + """Yield the top-level keys of a raw JSON object without parsing values.""" + keys = [] + depth = 0 + in_string = False + escape = False + i = 0 + n = len(raw) + + while i < n: + ch = raw[i] + + if escape: + escape = False + i += 1 + continue + + if ch == "\\": + escape = True + i += 1 + continue + + if ch == '"': + if depth == 1 and not in_string: + key, after = _peek_key(raw, i) + + if after is not None: + keys.append(key) + i = after + continue + + in_string = not in_string + i += 1 + continue + + if not in_string: + if ch == "{": + depth += 1 + + elif ch == "}": + depth -= 1 + + i += 1 + + return keys + + +def supports_reasoning(record): + """Whether a model supports reasoning. + + Unknown models (``record`` is ``None``) are assumed to support reasoning; + known records are only considered reasoning models when the metadata + explicitly sets ``supports_reasoning`` to true. + """ + if record is None: + return True + + return bool(record.get("supports_reasoning")) + + +def _peek_key(raw, i): + """If ``raw[i]`` starts a top-level ``"key":``, return (key, index after colon).""" + start = i + 1 + j = start + n = len(raw) + + while j < n: + c = raw[j] + + if c == "\\": + j += 2 + continue + + if c == '"': + break + + j += 1 + + if j >= n: + return None, None + + key = raw[start:j] + k = j + 1 + + while k < n and raw[k] in " \t\n\r": + k += 1 + + if k < n and raw[k] == ":": + return key, k + + return None, None diff --git a/cecli/helpers/requests.py b/cecli/helpers/requests.py index 84634906f95..fed1e2affcb 100644 --- a/cecli/helpers/requests.py +++ b/cecli/helpers/requests.py @@ -42,6 +42,9 @@ def add_reasoning_content(messages): ) msg.pop("reasoning_content", None) + if msg.get("reasoning_content", None) == "": + msg.pop("reasoning_content", None) + return messages @@ -172,7 +175,7 @@ def add_continue_for_no_prefill(model, messages, tools): if not model.info.get("supports_assistant_prefill", False): # Only add "Continue" if the last message is not a user message - if not messages or messages[-1].get("role") != "user": + if not messages or messages[-1].get("role") not in ("user", "tool"): # Add a user message with content "Continue" to the messages list append_message = True @@ -214,6 +217,7 @@ def prevent_consecutive_assistant_messages(messages): def model_request_parser(model, messages, tools): + messages = _copy_messages_for_request(messages) messages = thought_signature(model, messages) messages = remove_empty_tool_calls(messages) messages = concatenate_user_messages(messages) @@ -222,3 +226,52 @@ def model_request_parser(model, messages, tools): messages = add_continue_for_no_prefill(model, messages, tools) messages = prevent_consecutive_assistant_messages(messages) return messages + + +def _copy_psf_container(container): + """Shallow-copy a container dict and give its provider_specific_fields a fresh dict.""" + container = dict(container) + + psf = container.get("provider_specific_fields") + if isinstance(psf, dict): + container["provider_specific_fields"] = dict(psf) + + return container + + +def _copy_messages_for_request(messages): + """Return a shallow copy of the message list that request formatting can safely mutate. + + The formatters in this module (``thought_signature``, ``add_reasoning_content``, + ``add_continue_for_no_prefill``, ``ensure_alternating_roles``, ...) mutate message + dicts in place. The messages passed in come straight from the conversation store via + ``BaseMessage.to_dict()``, which shares the nested ``provider_specific_fields`` dicts + with the stored history, so a bare top-level copy would corrupt the stored messages — + e.g. ``reasoning_items`` (relied on for exact-prefix prompt caching) would be popped + out of ``provider_specific_fields`` and lost from the store. + + Shallow-copy the top-level message dict plus the nested ``provider_specific_fields`` + dicts (message-level and per tool-call) — the only structures the formatters mutate — + instead of deep-copying the whole message. + """ + copied = [] + for msg in messages: + msg = dict(msg) + + psf = msg.get("provider_specific_fields") + if isinstance(psf, dict): + msg["provider_specific_fields"] = dict(psf) + + tool_calls = msg.get("tool_calls") + if isinstance(tool_calls, list): + msg["tool_calls"] = [ + _copy_psf_container(call) if isinstance(call, dict) else call for call in tool_calls + ] + + function_call = msg.get("function_call") + if isinstance(function_call, dict): + msg["function_call"] = _copy_psf_container(function_call) + + copied.append(msg) + + return copied diff --git a/cecli/models.py b/cecli/models.py index 4c66c838dc4..6e51c7adec6 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -8,6 +8,7 @@ from dataclasses import dataclass, fields from pathlib import Path from typing import Optional, Union +from uuid import uuid4 as generate_unique_id import yaml from PIL import Image @@ -18,6 +19,8 @@ from cecli.exceptions import LiteLLMExceptions from cecli.helpers import coroutines, nested from cecli.helpers.file_searcher import generate_search_path_list, handle_core_files +from cecli.helpers.model_config import get_default_config +from cecli.helpers.model_config.utils import get_entry_from_raw from cecli.helpers.model_providers import ModelProviderManager from cecli.helpers.nested import deep_merge from cecli.helpers.requests import model_request_parser @@ -25,6 +28,7 @@ from cecli.sendchat import sanity_check_messages from cecli.utils import check_pip_install_extra +GLOBAL_ID = str(generate_unique_id()) RETRY_TIMEOUT = 60 COPY_PASTE_PREFIX = "cp:" request_timeout = 600 @@ -147,6 +151,7 @@ def __init__(self): self.content = None self._raw_content = None self.local_model_metadata = {} + self.metadata_files = [] self.verify_ssl = True self._cache_loaded = False self.provider_manager = ModelProviderManager() @@ -196,41 +201,7 @@ def _update_cache(self): def _get_entry_from_raw(self, key): """Parse a single model entry from raw JSON string without loading the entire dict.""" - if not self._raw_content: - return None - import re - - escaped_key = re.escape(key) - pattern = rf'(? 0: - ch = self._raw_content[pos] - if escape: - escape = False - elif ch == "\\": - escape = True - elif ch == '"': - in_string = not in_string - elif not in_string: - if ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - pos += 1 - if depth == 0: - entry_str = self._raw_content[start:pos] - return json.loads(entry_str) - return None + return get_entry_from_raw(self._raw_content, key) def get_model_from_cached_json_db(self, model): data = self.local_model_metadata.get(model) @@ -251,6 +222,29 @@ def get_model_from_cached_json_db(self, model): return info return dict() + def get_raw_metadata(self): + """Return the cached raw model metadata JSON string, or ``None``. + + The model config pipeline consumes this raw string directly so the + metadata file is only ever held in memory once (no duplicate storage). + """ + self._load_cache() + return self._raw_content + + def get_metadata_sources(self): + """Return the metadata sources the model config pipeline should scan. + + Prefers the model metadata files loaded via ``register_litellm_models`` + (later files win, so user-supplied files override the bundled + resource), then the cached raw metadata string, else ``None`` so the + pipeline falls back to its bundled resource. + """ + if self.metadata_files: + return list(self.metadata_files) + + self._load_cache() + return self._raw_content + def get_model_info(self, model): cached_info = self.get_model_from_cached_json_db(model) if cached_info: @@ -539,6 +533,8 @@ def __init__( self.io = io self.verbose = verbose self.override_kwargs = override_kwargs or {} + self._default_reasoning_effort = None + self._default_thinking_budget = None self.copy_paste_mode = False self.copy_paste_transport = "api" if provided_model.startswith(COPY_PASTE_PREFIX): @@ -570,6 +566,12 @@ def __init__( (ms for ms in MODEL_SETTINGS if ms.name == "cecli/extra_params"), None ) self.info = self.get_model_info(model) + self.model_config_defaults = get_default_config( + model, model_info_manager.get_metadata_sources() + ) + # Fill any gaps in the model info from the metadata-derived defaults; + # values already known (e.g. from litellm) win. + self.info = {**self.model_config_defaults.get("llm", {}), **self.info} self.litellm_provider = (self.info.get("litellm_provider") or "").lower() res = self.validate_environment() self.missing_keys = res.get("missing_keys") @@ -578,6 +580,7 @@ def __init__( self.max_chat_history_tokens = min(max(max_input_tokens / 16, 1024), 8192) self.configure_model_settings(model) self._apply_provider_defaults() + self._apply_reasoning_defaults() self.get_weak_model(weak_model) self.get_agent_model(agent_model) self.retries = retries @@ -632,70 +635,107 @@ def configure_model_settings(self, model): self.accepts_settings.append("thinking_tokens") if "reasoning_effort" not in self.accepts_settings: self.accepts_settings.append("reasoning_effort") + # Apply metadata-derived defaults from the model config pipeline before + # user-supplied override kwargs so explicit configuration wins. The llm + # block was already merged into ``self.info`` (existing values win), so + # only the api/agent defaults are applied here. + pipeline_defaults = dict(self.model_config_defaults) + # helpers carries callables (e.g. format_reasoning), not extra_params. + pipeline_defaults.pop("helpers", None) + self._apply_structured_kwargs(pipeline_defaults, self.name) + if self.override_kwargs: - if not self.extra_params: - self.extra_params = {} + self._apply_structured_kwargs(self.override_kwargs, model) - valid_model_settings_fields = {f.name for f in fields(ModelSettings)} - - # Detect structured keys: api_settings, api, llm_settings, or llm keys indicate the new format - has_structured_keys = any( - k in self.override_kwargs - for k in ( - "api_settings", - "api-settings", - "llm_settings", - "llm-settings", - "api", - "llm", - "agent", - ) + def _apply_structured_kwargs(self, config, model_name): + """Apply api/llm/agent override groups to info, extra_params and settings. + + Used for both the metadata-derived defaults (model config pipeline) and + user-supplied override kwargs so both share the same application rules. + """ + if not config: + return + + if not self.extra_params: + self.extra_params = {} + + valid_model_settings_fields = {f.name for f in fields(ModelSettings)} + + # Detect structured keys: api_settings, api, llm_settings, or llm keys + # indicate the new format. + has_structured_keys = any( + k in config + for k in ( + "api_settings", + "api-settings", + "llm_settings", + "llm-settings", + "api", + "llm", + "agent", ) + ) - for key, value in self.override_kwargs.items(): - if key in ("agent", "model_settings", "model-settings"): - if not isinstance(value, dict): - raise ValueError( - f"override_kwargs '{key}' must be a dict, got {type(value)}" - ) - for setting_key, setting_value in value.items(): - if setting_key not in valid_model_settings_fields: - raise ValueError( - f"Invalid model_settings key '{setting_key}'. " - f"Must be one of: {sorted(valid_model_settings_fields)}" - ) - setattr(self, setting_key, setting_value) - elif has_structured_keys and key in ("api", "api_settings", "api-settings"): - # api_settings: merge each sub-key into extra_params - if not isinstance(value, dict): + for key, value in config.items(): + if key in ("agent", "model_settings", "model-settings"): + if not isinstance(value, dict): + raise ValueError(f"override_kwargs '{key}' must be a dict, got {type(value)}") + + for setting_key, setting_value in value.items(): + if setting_key not in valid_model_settings_fields: raise ValueError( - f"override_kwargs '{key}' must be a dict, got {type(value)}" + f"Invalid model_settings key '{setting_key}'. " + f"Must be one of: {sorted(valid_model_settings_fields)}" ) - for api_key, api_value in value.items(): - if isinstance(api_value, dict) and isinstance( - self.extra_params.get(api_key), dict - ): - self.extra_params[api_key] = {**self.extra_params[api_key], **api_value} + + setattr(self, setting_key, setting_value) + + elif has_structured_keys and key in ("api", "api_settings", "api-settings"): + # api_settings: merge each sub-key into extra_params + if not isinstance(value, dict): + raise ValueError(f"override_kwargs '{key}' must be a dict, got {type(value)}") + + for api_key, api_value in value.items(): + if api_key == "reasoning_effort": + # Applied via set_reasoning_effort at init; remember the + # default so later (user-supplied) values win. + self._default_reasoning_effort = api_value + continue + + if api_key == "thinking": + if isinstance(api_value, dict): + self._default_thinking_budget = api_value.get("budget_tokens") else: - self.extra_params[api_key] = api_value - elif has_structured_keys and key in ("llm", "llm_settings", "llm-settings"): - # llm_settings: merge into self.info - if not isinstance(value, dict): - raise ValueError( - f"override_kwargs '{key}' must be a dict, got {type(value)}" - ) - self.info = {**self.info, **value} + self._default_thinking_budget = api_value + continue + + if isinstance(api_value, dict) and isinstance( + self.extra_params.get(api_key), dict + ): + self.extra_params[api_key] = {**self.extra_params[api_key], **api_value} + else: + self.extra_params[api_key] = api_value + + elif has_structured_keys and key in ("llm", "llm_settings", "llm-settings"): + # llm_settings: merge into self.info + if not isinstance(value, dict): + raise ValueError(f"override_kwargs '{key}' must be a dict, got {type(value)}") + + self.info = {**self.info, **value} - if not litellm.model_cost.get(model): - litellm.model_cost[model] = {} + if getattr(litellm, "model_cost", None) is not None: + if not litellm.model_cost.get(model_name): + litellm.model_cost[model_name] = {} - litellm.model_cost[model].update(self.info) + litellm.model_cost[model_name].update(self.info) litellm.utils._invalidate_model_cost_lowercase_map() litellm.add_known_models(model_cost_map=litellm.model_cost) - elif isinstance(value, dict) and isinstance(self.extra_params.get(key), dict): - self.extra_params[key] = {**self.extra_params[key], **value} - else: - self.extra_params[key] = value + + elif isinstance(value, dict) and isinstance(self.extra_params.get(key), dict): + self.extra_params[key] = {**self.extra_params[key], **value} + + else: + self.extra_params[key] = value def apply_generic_model_settings(self, model): if "/o3-mini" in model: @@ -920,6 +960,8 @@ def _ensure_extra_params_dict(self): def _apply_provider_defaults(self): provider = (self.info.get("litellm_provider") or "").lower() self.litellm_provider = provider or None + if self.info.get("supports_stream") is False: + self.streaming = False if not provider: return provider_config = model_info_manager.provider_manager.get_provider_config(provider) @@ -942,6 +984,19 @@ def _apply_provider_defaults(self): if key not in self.extra_params: self.extra_params[key] = value + def _apply_reasoning_defaults(self): + """Apply the default thinking/reasoning configuration at init time. + + The model config pipeline's ``api`` block decides which mechanism a + model uses: anthropic-family models configure ``thinking`` tokens while + everything else uses a reasoning effort. ``override_kwargs`` applied + later in ``_apply_structured_kwargs`` win over these defaults. + """ + if self._default_thinking_budget is not None: + self.set_thinking_tokens(self._default_thinking_budget) + elif self._default_reasoning_effort is not None: + self.set_reasoning_effort(self._default_reasoning_effort) + def tokenizer(self, text): return litellm.encode(model=self.name, text=text) @@ -1080,20 +1135,62 @@ def get_repo_map_tokens(self): return map_tokens def set_reasoning_effort(self, effort): - """Set the reasoning effort parameter for models that support it""" - if effort is not None: - if self.name.startswith("openrouter/"): - if not self.extra_params: - self.extra_params = {} - if "extra_body" not in self.extra_params: - self.extra_params["extra_body"] = {} - self.extra_params["extra_body"]["reasoning"] = {"effort": effort} + """Set the reasoning effort parameter for models that support it. + + ``None`` or ``"none"`` clears any previously applied effort. OpenRouter + models and models using the responses mode configure the nested + ``reasoning.effort`` field; everything else uses the flat + ``reasoning_effort`` field. A provider-specific formatter supplied by + the model config pipeline (``helpers.format_reasoning``) then rewrites + the effort onto the provider's own field (e.g. Gemini's + ``thinking_level``). + """ + if not self.extra_params: + self.extra_params = {} + + extra_body = self.extra_params.setdefault("extra_body", {}) + + if effort is None or effort == "none": + # Unset any previously applied effort (flat and nested forms). + extra_body.pop("reasoning_effort", None) + reasoning = extra_body.get("reasoning") + if ( + isinstance(reasoning, dict) + and "effort" in reasoning + and "max_tokens" not in reasoning + ): + extra_body.pop("reasoning", None) + else: + # Response mode comes from the model config pipeline's metadata-derived + # llm block (litellm's own info can disagree, e.g. gpt-5). + mode = nested.getter(self.model_config_defaults, "llm.mode") or self.info.get("mode") + if self.name.startswith("openrouter/") or mode == "responses": + # store/include for responses-mode reasoning are injected by the + # model config pipeline (deep-merged into extra_body). + extra_body["reasoning"] = {"effort": effort} else: - if not self.extra_params: - self.extra_params = {} - if "extra_body" not in self.extra_params: - self.extra_params["extra_body"] = {} - self.extra_params["extra_body"]["reasoning_effort"] = effort + extra_body["reasoning_effort"] = effort + + # Let the model config pipeline rewrite the generic reasoning shape + # onto the provider-specific litellm param (noop by default). + format_reasoning = nested.getter(self.model_config_defaults, "helpers.format_reasoning") + if callable(format_reasoning): + format_reasoning(self.extra_params) + + def get_reasoning_effort(self): + """Get reasoning effort value if available""" + effort = nested.getter(self.extra_params, "extra_body.reasoning.effort") + if effort is not None: + return effort + effort = nested.getter(self.extra_params, "extra_body.reasoning_effort") + if effort is not None: + return effort + effort = nested.getter(self.extra_params, "extra_body.thinking_level") + if effort is not None: + return effort + # Top-level litellm param written by the pipeline's format_reasoning + # helper (e.g. Gemini's reasoning_effort -> thinkingConfig). + return nested.getter(self.extra_params, "reasoning_effort") def parse_token_value(self, value): """ @@ -1132,33 +1229,43 @@ def set_thinking_tokens(self, value): self.use_temperature = False if not self.extra_params: self.extra_params = {} + + extra_body = self.extra_params.setdefault("extra_body", {}) + if self.name.startswith("openrouter/"): - if "extra_body" not in self.extra_params: - self.extra_params["extra_body"] = {} if num_tokens > 0: - self.extra_params["extra_body"]["reasoning"] = {"max_tokens": num_tokens} - elif "reasoning" in self.extra_params["extra_body"]: - del self.extra_params["extra_body"]["reasoning"] + extra_body["reasoning"] = {"max_tokens": num_tokens} + elif "reasoning" in extra_body: + del extra_body["reasoning"] elif num_tokens > 0: - self.extra_params["thinking"] = {"type": "enabled", "budget_tokens": num_tokens} - elif "thinking" in self.extra_params: - del self.extra_params["thinking"] + extra_body["thinking"] = {"type": "enabled", "budget_tokens": num_tokens} + # extra_body is authoritative; drop any legacy top-level copy. + self.extra_params.pop("thinking", None) + else: + extra_body.pop("thinking", None) + self.extra_params.pop("thinking", None) + + # Let the model config pipeline rewrite the generic thinking shape onto + # the provider-specific litellm param (noop by default). + format_thinking = nested.getter(self.model_config_defaults, "helpers.format_thinking") + if callable(format_thinking): + format_thinking(self.extra_params) def get_raw_thinking_tokens(self): """Get formatted thinking token budget if available""" budget = None if self.extra_params: if self.name.startswith("openrouter/"): - if ( - "extra_body" in self.extra_params - and "reasoning" in self.extra_params["extra_body"] - and "max_tokens" in self.extra_params["extra_body"]["reasoning"] - ): - budget = self.extra_params["extra_body"]["reasoning"]["max_tokens"] - elif ( - "thinking" in self.extra_params and "budget_tokens" in self.extra_params["thinking"] - ): - budget = self.extra_params["thinking"]["budget_tokens"] + reasoning = nested.getter(self.extra_params, "extra_body.reasoning") + if isinstance(reasoning, dict) and "max_tokens" in reasoning: + budget = reasoning["max_tokens"] + else: + thinking = nested.getter(self.extra_params, "extra_body.thinking") + if isinstance(thinking, dict) and "budget_tokens" in thinking: + budget = thinking["budget_tokens"] + elif isinstance(self.extra_params.get("thinking"), dict): + # Legacy location (e.g. a flat override_kwargs entry). + budget = self.extra_params["thinking"].get("budget_tokens") return budget def get_thinking_tokens(self): @@ -1178,29 +1285,6 @@ def get_thinking_tokens(self): return f"{value:.1f}k" return None - def get_reasoning_effort(self): - """Get reasoning effort value if available""" - if self.extra_params: - if self.name.startswith("openrouter/"): - if ( - "extra_body" in self.extra_params - and "reasoning" in self.extra_params["extra_body"] - and "effort" in self.extra_params["extra_body"]["reasoning"] - ): - return self.extra_params["extra_body"]["reasoning"]["effort"] - elif ( - "extra_body" in self.extra_params - and "reasoning_effort" in self.extra_params["extra_body"] - ): - return self.extra_params["extra_body"]["reasoning_effort"] - return None - - def is_deepseek(self): - name = self.name.lower() - if "deepseek" not in name: - return - return True - def is_anthropic(self): name = self.name.lower() if "claude" not in name: @@ -1318,6 +1402,7 @@ async def send_completion( self._log_messages(messages) kwargs["messages"] = messages + kwargs["prompt_cache_key"] = GLOBAL_ID if not self.is_anthropic() and not self.caches_by_default: kwargs["cache_control_injection_points"] = [ @@ -1330,13 +1415,11 @@ async def send_completion( kwargs["headers"].update( { "User-Agent": f"cecli/{__version__}", - "Connection": "close", } ) else: kwargs["headers"] = { "User-Agent": f"cecli/{__version__}", - "Connection": "close", } if "GITHUB_COPILOT_TOKEN" in os.environ or self.name.startswith("github_copilot/"): @@ -1590,6 +1673,8 @@ def register_litellm_models(model_fnames): except Exception as e: raise Exception(f"Error loading model definition from {model_fname}: {e}") files_loaded.append(model_fname) + + model_info_manager.metadata_files = model_fnames return files_loaded @@ -1638,7 +1723,7 @@ async def sanity_check_model(io, model): show = True io.tool_warning(f"Warning for {model}: Unknown which environment variables are required.") await check_for_dependencies(io, model.name) - if not model.info: + if not (model.info.get("max_input_tokens") or model.info.get("max_tokens")): show = True io.tool_warning( f"Warning for {model}: Unknown context window size and costs, using sane defaults." diff --git a/cecli/resources/model-metadata.json b/cecli/resources/model-metadata.json index 6e1d71f5e43..38281944f96 100644 --- a/cecli/resources/model-metadata.json +++ b/cecli/resources/model-metadata.json @@ -785,6 +785,7 @@ "prompt_cache_min_tokens": 1024 }, "anthropic.claude-sonnet-5": { + "bedrock_converse_supports_strict_tools": false, "cache_creation_input_token_cost": 0.0000025, "cache_creation_input_token_cost_above_1hr": 0.000004, "cache_read_input_token_cost": 2e-7, @@ -1388,6 +1389,7 @@ "prompt_cache_min_tokens": 1024 }, "au.anthropic.claude-sonnet-5": { + "bedrock_converse_supports_strict_tools": false, "cache_creation_input_token_cost": 0.00000275, "cache_creation_input_token_cost_above_1hr": 0.0000044, "cache_read_input_token_cost": 2.2e-7, @@ -1872,20 +1874,20 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6-luna": { - "cache_read_input_token_cost": 1.1e-7, - "cache_read_input_token_cost_above_272k_tokens": 2.2e-7, - "cache_read_input_token_cost_priority": 2.75e-7, - "input_cost_per_token": 0.0000011, - "input_cost_per_token_above_272k_tokens": 0.0000022, - "input_cost_per_token_priority": 0.00000275, + "cache_read_input_token_cost": 2.2e-8, + "cache_read_input_token_cost_above_272k_tokens": 4.4e-8, + "cache_read_input_token_cost_priority": 5.5e-8, + "input_cost_per_token": 2.2e-7, + "input_cost_per_token_above_272k_tokens": 4.4e-7, + "input_cost_per_token_priority": 5.5e-7, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 0.0000066, - "output_cost_per_token_above_272k_tokens": 0.0000099, - "output_cost_per_token_priority": 0.0000165, + "output_cost_per_token": 0.00000132, + "output_cost_per_token_above_272k_tokens": 0.00000198, + "output_cost_per_token_priority": 0.0000033, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -1956,20 +1958,20 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6-terra": { - "cache_read_input_token_cost": 2.75e-7, - "cache_read_input_token_cost_above_272k_tokens": 5.5e-7, - "cache_read_input_token_cost_priority": 6.875e-7, - "input_cost_per_token": 0.00000275, - "input_cost_per_token_above_272k_tokens": 0.0000055, - "input_cost_per_token_priority": 0.000006875, + "cache_read_input_token_cost": 2.2e-7, + "cache_read_input_token_cost_above_272k_tokens": 4.4e-7, + "cache_read_input_token_cost_priority": 5.5e-7, + "input_cost_per_token": 0.0000022, + "input_cost_per_token_above_272k_tokens": 0.0000044, + "input_cost_per_token_priority": 0.0000055, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 0.0000165, - "output_cost_per_token_above_272k_tokens": 0.00002475, - "output_cost_per_token_priority": 0.00004125, + "output_cost_per_token": 0.0000132, + "output_cost_per_token_above_272k_tokens": 0.0000198, + "output_cost_per_token_priority": 0.000033, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -3422,7 +3424,7 @@ "cache_read_input_token_cost": 7.5e-8, "input_cost_per_token": 7.5e-7, "litellm_provider": "azure", - "max_input_tokens": 1050000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -3457,7 +3459,7 @@ "cache_read_input_token_cost": 7.5e-8, "input_cost_per_token": 7.5e-7, "litellm_provider": "azure", - "max_input_tokens": 1050000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -3492,7 +3494,7 @@ "cache_read_input_token_cost": 2e-8, "input_cost_per_token": 2e-7, "litellm_provider": "azure", - "max_input_tokens": 1050000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -3527,7 +3529,7 @@ "cache_read_input_token_cost": 2e-8, "input_cost_per_token": 2e-7, "litellm_provider": "azure", - "max_input_tokens": 1050000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -3691,23 +3693,23 @@ "supports_minimal_reasoning_effort": false }, "azure/gpt-5.6-luna": { - "cache_read_input_token_cost": 1e-7, - "cache_read_input_token_cost_above_272k_tokens": 2e-7, - "cache_read_input_token_cost_priority": 2e-7, - "cache_read_input_token_cost_above_272k_tokens_priority": 4e-7, - "input_cost_per_token": 0.000001, - "input_cost_per_token_above_272k_tokens": 0.000002, - "input_cost_per_token_priority": 0.000002, - "input_cost_per_token_above_272k_tokens_priority": 0.000004, + "cache_read_input_token_cost": 2e-8, + "cache_read_input_token_cost_above_272k_tokens": 4e-8, + "cache_read_input_token_cost_priority": 4e-8, + "cache_read_input_token_cost_above_272k_tokens_priority": 8e-8, + "input_cost_per_token": 2e-7, + "input_cost_per_token_above_272k_tokens": 4e-7, + "input_cost_per_token_priority": 4e-7, + "input_cost_per_token_above_272k_tokens_priority": 8e-7, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 0.000006, - "output_cost_per_token_above_272k_tokens": 0.000009, - "output_cost_per_token_priority": 0.000012, - "output_cost_per_token_above_272k_tokens_priority": 0.000018, + "output_cost_per_token": 0.0000012, + "output_cost_per_token_above_272k_tokens": 0.0000018, + "output_cost_per_token_priority": 0.0000024, + "output_cost_per_token_above_272k_tokens_priority": 0.0000036, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -3781,23 +3783,23 @@ "supports_minimal_reasoning_effort": false }, "azure/gpt-5.6-terra": { - "cache_read_input_token_cost": 2.5e-7, - "cache_read_input_token_cost_above_272k_tokens": 5e-7, - "cache_read_input_token_cost_priority": 5e-7, - "cache_read_input_token_cost_above_272k_tokens_priority": 0.000001, - "input_cost_per_token": 0.0000025, - "input_cost_per_token_above_272k_tokens": 0.000005, - "input_cost_per_token_priority": 0.000005, - "input_cost_per_token_above_272k_tokens_priority": 0.00001, + "cache_read_input_token_cost": 2e-7, + "cache_read_input_token_cost_above_272k_tokens": 4e-7, + "cache_read_input_token_cost_priority": 4e-7, + "cache_read_input_token_cost_above_272k_tokens_priority": 8e-7, + "input_cost_per_token": 0.000002, + "input_cost_per_token_above_272k_tokens": 0.000004, + "input_cost_per_token_priority": 0.000004, + "input_cost_per_token_above_272k_tokens_priority": 0.000008, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 0.000015, - "output_cost_per_token_above_272k_tokens": 0.0000225, - "output_cost_per_token_priority": 0.00003, - "output_cost_per_token_above_272k_tokens_priority": 0.000045, + "output_cost_per_token": 0.000012, + "output_cost_per_token_above_272k_tokens": 0.000018, + "output_cost_per_token_priority": 0.000024, + "output_cost_per_token_above_272k_tokens_priority": 0.000036, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -4763,20 +4765,20 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6-luna": { - "cache_read_input_token_cost": 1.1e-7, - "cache_read_input_token_cost_above_272k_tokens": 2.2e-7, - "cache_read_input_token_cost_priority": 2.75e-7, - "input_cost_per_token": 0.0000011, - "input_cost_per_token_above_272k_tokens": 0.0000022, - "input_cost_per_token_priority": 0.00000275, + "cache_read_input_token_cost": 2.2e-8, + "cache_read_input_token_cost_above_272k_tokens": 4.4e-8, + "cache_read_input_token_cost_priority": 5.5e-8, + "input_cost_per_token": 2.2e-7, + "input_cost_per_token_above_272k_tokens": 4.4e-7, + "input_cost_per_token_priority": 5.5e-7, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 0.0000066, - "output_cost_per_token_above_272k_tokens": 0.0000099, - "output_cost_per_token_priority": 0.0000165, + "output_cost_per_token": 0.00000132, + "output_cost_per_token_above_272k_tokens": 0.00000198, + "output_cost_per_token_priority": 0.0000033, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -4847,20 +4849,20 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6-terra": { - "cache_read_input_token_cost": 2.75e-7, - "cache_read_input_token_cost_above_272k_tokens": 5.5e-7, - "cache_read_input_token_cost_priority": 6.875e-7, - "input_cost_per_token": 0.00000275, - "input_cost_per_token_above_272k_tokens": 0.0000055, - "input_cost_per_token_priority": 0.000006875, + "cache_read_input_token_cost": 2.2e-7, + "cache_read_input_token_cost_above_272k_tokens": 4.4e-7, + "cache_read_input_token_cost_priority": 5.5e-7, + "input_cost_per_token": 0.0000022, + "input_cost_per_token_above_272k_tokens": 0.0000044, + "input_cost_per_token_priority": 0.0000055, "litellm_provider": "azure", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 0.0000165, - "output_cost_per_token_above_272k_tokens": 0.00002475, - "output_cost_per_token_priority": 0.00004125, + "output_cost_per_token": 0.0000132, + "output_cost_per_token_above_272k_tokens": 0.0000198, + "output_cost_per_token_priority": 0.000033, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5805,22 +5807,16 @@ }, "azure_ai/gpt-5.4-mini": { "cache_read_input_token_cost": 7.5e-8, - "cache_read_input_token_cost_above_272k_tokens": 1.5e-7, "cache_read_input_token_cost_priority": 1.5e-7, - "cache_read_input_token_cost_above_272k_tokens_priority": 3e-7, "input_cost_per_token": 7.5e-7, - "input_cost_per_token_above_272k_tokens": 0.0000015, "input_cost_per_token_priority": 0.0000015, - "input_cost_per_token_above_272k_tokens_priority": 0.000003, "litellm_provider": "azure_ai", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.0000045, - "output_cost_per_token_above_272k_tokens": 0.00000675, "output_cost_per_token_priority": 0.000009, - "output_cost_per_token_above_272k_tokens_priority": 0.0000135, "source": "https://ai.azure.com/catalog/models/gpt-5.4-mini", "supported_endpoints": [ "/v1/chat/completions", @@ -5851,22 +5847,16 @@ }, "azure_ai/gpt-5.4-mini-2026-03-17": { "cache_read_input_token_cost": 7.5e-8, - "cache_read_input_token_cost_above_272k_tokens": 1.5e-7, "cache_read_input_token_cost_priority": 1.5e-7, - "cache_read_input_token_cost_above_272k_tokens_priority": 3e-7, "input_cost_per_token": 7.5e-7, - "input_cost_per_token_above_272k_tokens": 0.0000015, "input_cost_per_token_priority": 0.0000015, - "input_cost_per_token_above_272k_tokens_priority": 0.000003, "litellm_provider": "azure_ai", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.0000045, - "output_cost_per_token_above_272k_tokens": 0.00000675, "output_cost_per_token_priority": 0.000009, - "output_cost_per_token_above_272k_tokens_priority": 0.0000135, "source": "https://ai.azure.com/catalog/models/gpt-5.4-mini", "supported_endpoints": [ "/v1/chat/completions", @@ -5897,22 +5887,16 @@ }, "azure_ai/gpt-5.4-nano": { "cache_read_input_token_cost": 2e-8, - "cache_read_input_token_cost_above_272k_tokens": 4e-8, "cache_read_input_token_cost_priority": 4e-8, - "cache_read_input_token_cost_above_272k_tokens_priority": 8e-8, "input_cost_per_token": 2e-7, - "input_cost_per_token_above_272k_tokens": 4e-7, "input_cost_per_token_priority": 4e-7, - "input_cost_per_token_above_272k_tokens_priority": 8e-7, "litellm_provider": "azure_ai", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.00000125, - "output_cost_per_token_above_272k_tokens": 0.000001875, "output_cost_per_token_priority": 0.0000025, - "output_cost_per_token_above_272k_tokens_priority": 0.00000375, "source": "https://ai.azure.com/catalog/models/gpt-5.4-nano", "supported_endpoints": [ "/v1/chat/completions", @@ -5943,22 +5927,16 @@ }, "azure_ai/gpt-5.4-nano-2026-03-17": { "cache_read_input_token_cost": 2e-8, - "cache_read_input_token_cost_above_272k_tokens": 4e-8, "cache_read_input_token_cost_priority": 4e-8, - "cache_read_input_token_cost_above_272k_tokens_priority": 8e-8, "input_cost_per_token": 2e-7, - "input_cost_per_token_above_272k_tokens": 4e-7, "input_cost_per_token_priority": 4e-7, - "input_cost_per_token_above_272k_tokens_priority": 8e-7, "litellm_provider": "azure_ai", - "max_input_tokens": 400000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.00000125, - "output_cost_per_token_above_272k_tokens": 0.000001875, "output_cost_per_token_priority": 0.0000025, - "output_cost_per_token_above_272k_tokens_priority": 0.00000375, "source": "https://ai.azure.com/catalog/models/gpt-5.4-nano", "supported_endpoints": [ "/v1/chat/completions", @@ -10667,6 +10645,56 @@ } ] }, + "dashscope/qwen3.7-max": { + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.0000025, + "litellm_provider": "dashscope", + "max_input_tokens": 991808, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 0.0000075, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "dashscope/qwen3.7-plus": { + "litellm_provider": "dashscope", + "max_input_tokens": 991808, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tiered_pricing": [ + { + "cache_read_input_token_cost": 8e-8, + "input_cost_per_token": 4e-7, + "output_cost_per_token": 0.0000016, + "range": [ + 0, + 256000 + ] + }, + { + "cache_read_input_token_cost": 2.4e-7, + "input_cost_per_token": 0.0000012, + "output_cost_per_token": 0.0000048, + "range": [ + 256000, + 1000000 + ] + } + ] + }, "dashscope/qwq-plus": { "input_cost_per_token": 8e-7, "litellm_provider": "dashscope", @@ -12760,6 +12788,7 @@ "prompt_cache_min_tokens": 1024 }, "eu.anthropic.claude-sonnet-5": { + "bedrock_converse_supports_strict_tools": false, "cache_creation_input_token_cost": 0.00000275, "cache_creation_input_token_cost_above_1hr": 0.0000044, "cache_read_input_token_cost": 2.2e-7, @@ -13807,8 +13836,8 @@ "input_cost_per_token": 6e-7, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.000003, "source": "https://fireworks.ai/pricing", @@ -13821,8 +13850,8 @@ "input_cost_per_token": 9.5e-7, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.000004, "source": "https://docs.fireworks.ai/serverless/pricing", @@ -13837,8 +13866,8 @@ "input_cost_per_token": 9.5e-7, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.000004, "source": "https://docs.fireworks.ai/serverless/pricing", @@ -15340,8 +15369,8 @@ "input_cost_per_token": 0.000002, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.000008, "source": "https://docs.fireworks.ai/serverless/pricing", @@ -15356,8 +15385,8 @@ "input_cost_per_token": 0.0000019, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.000008, "source": "https://docs.fireworks.ai/serverless/pricing", @@ -15499,8 +15528,8 @@ "input_cost_per_token": 6e-7, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.000003, "source": "https://fireworks.ai/pricing", @@ -15513,8 +15542,8 @@ "input_cost_per_token": 9.5e-7, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.000004, "source": "https://docs.fireworks.ai/serverless/pricing", @@ -15529,8 +15558,8 @@ "input_cost_per_token": 0.000002, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.000008, "source": "https://docs.fireworks.ai/serverless/pricing", @@ -15545,8 +15574,8 @@ "input_cost_per_token": 9.5e-7, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.000004, "source": "https://docs.fireworks.ai/serverless/pricing", @@ -15561,8 +15590,8 @@ "input_cost_per_token": 0.0000019, "litellm_provider": "fireworks_ai", "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.000008, "source": "https://docs.fireworks.ai/serverless/pricing", @@ -19026,6 +19055,99 @@ "search_context_size_high": 0.035 } }, + "gemini/gemini-robotics-er-1.6-preview": { + "input_cost_per_audio_token": 0.000002, + "input_cost_per_token": 0.000001, + "litellm_provider": "gemini", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_reasoning_token": 0.000005, + "output_cost_per_token": 0.000005, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-robotics-er", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "web_search_billing_unit": "per_query" + }, + "gemini/gemini-robotics-er-2-preview": { + "cache_read_input_token_cost": 2e-7, + "input_cost_per_audio_token": 0.000002, + "input_cost_per_token": 0.000002, + "litellm_provider": "gemini", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_reasoning_token": 0.00001, + "output_cost_per_token": 0.00001, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-robotics-er-2", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "web_search_billing_unit": "per_query" + }, "gemini/gemma-3-27b-it": { "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, @@ -19835,6 +19957,7 @@ "prompt_cache_min_tokens": 1024 }, "global.anthropic.claude-sonnet-5": { + "bedrock_converse_supports_strict_tools": false, "cache_creation_input_token_cost": 0.0000025, "cache_creation_input_token_cost_above_1hr": 0.000004, "cache_read_input_token_cost": 2e-7, @@ -21503,7 +21626,7 @@ "input_cost_per_token_batches": 3.75e-7, "input_cost_per_token_priority": 0.0000015, "litellm_provider": "openai", - "max_input_tokens": 1050000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -21549,7 +21672,7 @@ "input_cost_per_token_batches": 3.75e-7, "input_cost_per_token_priority": 0.0000015, "litellm_provider": "openai", - "max_input_tokens": 1050000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -21593,7 +21716,7 @@ "input_cost_per_token_flex": 1e-7, "input_cost_per_token_batches": 1e-7, "litellm_provider": "openai", - "max_input_tokens": 1050000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -21636,7 +21759,7 @@ "input_cost_per_token_flex": 1e-7, "input_cost_per_token_batches": 1e-7, "litellm_provider": "openai", - "max_input_tokens": 1050000, + "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", @@ -21773,14 +21896,17 @@ "gpt-5.6": { "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_272k_tokens": 0.0000125, + "cache_creation_input_token_cost_above_272k_tokens_flex": 0.00000625, "cache_creation_input_token_cost_flex": 0.000003125, "cache_creation_input_token_cost_priority": 0.0000125, "cache_read_input_token_cost": 5e-7, "cache_read_input_token_cost_above_272k_tokens": 0.000001, + "cache_read_input_token_cost_above_272k_tokens_flex": 5e-7, "cache_read_input_token_cost_flex": 2.5e-7, "cache_read_input_token_cost_priority": 0.000001, "input_cost_per_token": 0.000005, "input_cost_per_token_above_272k_tokens": 0.00001, + "input_cost_per_token_above_272k_tokens_flex": 0.000005, "input_cost_per_token_batches": 0.0000025, "input_cost_per_token_flex": 0.0000025, "input_cost_per_token_priority": 0.00001, @@ -21791,6 +21917,7 @@ "mode": "chat", "output_cost_per_token": 0.00003, "output_cost_per_token_above_272k_tokens": 0.000045, + "output_cost_per_token_above_272k_tokens_flex": 0.0000225, "output_cost_per_token_batches": 0.000015, "output_cost_per_token_flex": 0.000015, "output_cost_per_token_priority": 0.00006, @@ -21824,29 +21951,33 @@ "supports_xhigh_reasoning_effort": true }, "gpt-5.6-luna": { - "cache_creation_input_token_cost": 0.00000125, - "cache_creation_input_token_cost_above_272k_tokens": 0.0000025, - "cache_creation_input_token_cost_flex": 6.25e-7, - "cache_creation_input_token_cost_priority": 0.0000025, - "cache_read_input_token_cost": 1e-7, - "cache_read_input_token_cost_above_272k_tokens": 2e-7, - "cache_read_input_token_cost_flex": 5e-8, - "cache_read_input_token_cost_priority": 2e-7, - "input_cost_per_token": 0.000001, - "input_cost_per_token_above_272k_tokens": 0.000002, - "input_cost_per_token_batches": 5e-7, - "input_cost_per_token_flex": 5e-7, - "input_cost_per_token_priority": 0.000002, + "cache_creation_input_token_cost": 2.5e-7, + "cache_creation_input_token_cost_above_272k_tokens": 5e-7, + "cache_creation_input_token_cost_above_272k_tokens_flex": 2.5e-7, + "cache_creation_input_token_cost_flex": 1.25e-7, + "cache_creation_input_token_cost_priority": 5e-7, + "cache_read_input_token_cost": 2e-8, + "cache_read_input_token_cost_above_272k_tokens": 4e-8, + "cache_read_input_token_cost_above_272k_tokens_flex": 2e-8, + "cache_read_input_token_cost_flex": 1e-8, + "cache_read_input_token_cost_priority": 4e-8, + "input_cost_per_token": 2e-7, + "input_cost_per_token_above_272k_tokens": 4e-7, + "input_cost_per_token_above_272k_tokens_flex": 2e-7, + "input_cost_per_token_batches": 1e-7, + "input_cost_per_token_flex": 1e-7, + "input_cost_per_token_priority": 4e-7, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 0.000006, - "output_cost_per_token_above_272k_tokens": 0.000009, - "output_cost_per_token_batches": 0.000003, - "output_cost_per_token_flex": 0.000003, - "output_cost_per_token_priority": 0.000012, + "output_cost_per_token": 0.0000012, + "output_cost_per_token_above_272k_tokens": 0.0000018, + "output_cost_per_token_above_272k_tokens_flex": 9e-7, + "output_cost_per_token_batches": 6e-7, + "output_cost_per_token_flex": 6e-7, + "output_cost_per_token_priority": 0.0000024, "regional_processing_uplift_multiplier_eu": 1.1, "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ @@ -21879,14 +22010,17 @@ "gpt-5.6-sol": { "cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost_above_272k_tokens": 0.0000125, + "cache_creation_input_token_cost_above_272k_tokens_flex": 0.00000625, "cache_creation_input_token_cost_flex": 0.000003125, "cache_creation_input_token_cost_priority": 0.0000125, "cache_read_input_token_cost": 5e-7, "cache_read_input_token_cost_above_272k_tokens": 0.000001, + "cache_read_input_token_cost_above_272k_tokens_flex": 5e-7, "cache_read_input_token_cost_flex": 2.5e-7, "cache_read_input_token_cost_priority": 0.000001, "input_cost_per_token": 0.000005, "input_cost_per_token_above_272k_tokens": 0.00001, + "input_cost_per_token_above_272k_tokens_flex": 0.000005, "input_cost_per_token_batches": 0.0000025, "input_cost_per_token_flex": 0.0000025, "input_cost_per_token_priority": 0.00001, @@ -21897,6 +22031,7 @@ "mode": "chat", "output_cost_per_token": 0.00003, "output_cost_per_token_above_272k_tokens": 0.000045, + "output_cost_per_token_above_272k_tokens_flex": 0.0000225, "output_cost_per_token_batches": 0.000015, "output_cost_per_token_flex": 0.000015, "output_cost_per_token_priority": 0.00006, @@ -21930,29 +22065,33 @@ "supports_xhigh_reasoning_effort": true }, "gpt-5.6-terra": { - "cache_creation_input_token_cost": 0.000003125, - "cache_creation_input_token_cost_above_272k_tokens": 0.00000625, - "cache_creation_input_token_cost_flex": 0.0000015625, - "cache_creation_input_token_cost_priority": 0.00000625, - "cache_read_input_token_cost": 2.5e-7, - "cache_read_input_token_cost_above_272k_tokens": 5e-7, - "cache_read_input_token_cost_flex": 1.25e-7, - "cache_read_input_token_cost_priority": 5e-7, - "input_cost_per_token": 0.0000025, - "input_cost_per_token_above_272k_tokens": 0.000005, - "input_cost_per_token_batches": 0.00000125, - "input_cost_per_token_flex": 0.00000125, - "input_cost_per_token_priority": 0.000005, + "cache_creation_input_token_cost": 0.0000025, + "cache_creation_input_token_cost_above_272k_tokens": 0.000005, + "cache_creation_input_token_cost_above_272k_tokens_flex": 0.0000025, + "cache_creation_input_token_cost_flex": 0.00000125, + "cache_creation_input_token_cost_priority": 0.000005, + "cache_read_input_token_cost": 2e-7, + "cache_read_input_token_cost_above_272k_tokens": 4e-7, + "cache_read_input_token_cost_above_272k_tokens_flex": 2e-7, + "cache_read_input_token_cost_flex": 1e-7, + "cache_read_input_token_cost_priority": 4e-7, + "input_cost_per_token": 0.000002, + "input_cost_per_token_above_272k_tokens": 0.000004, + "input_cost_per_token_above_272k_tokens_flex": 0.000002, + "input_cost_per_token_batches": 0.000001, + "input_cost_per_token_flex": 0.000001, + "input_cost_per_token_priority": 0.000004, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 0.000015, - "output_cost_per_token_above_272k_tokens": 0.0000225, - "output_cost_per_token_batches": 0.0000075, - "output_cost_per_token_flex": 0.0000075, - "output_cost_per_token_priority": 0.00003, + "output_cost_per_token": 0.000012, + "output_cost_per_token_above_272k_tokens": 0.000018, + "output_cost_per_token_above_272k_tokens_flex": 0.000009, + "output_cost_per_token_batches": 0.000006, + "output_cost_per_token_flex": 0.000006, + "output_cost_per_token_priority": 0.000024, "regional_processing_uplift_multiplier_eu": 1.1, "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ @@ -22490,6 +22629,11 @@ "max_tokens": 32766, "mode": "chat", "output_cost_per_token": 6e-7, + "search_context_cost_per_query": { + "search_context_size_high": 0.005, + "search_context_size_low": 0.005, + "search_context_size_medium": 0.005 + }, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -22506,6 +22650,11 @@ "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-7, + "search_context_cost_per_query": { + "search_context_size_high": 0.005, + "search_context_size_low": 0.005, + "search_context_size_medium": 0.005 + }, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -22522,6 +22671,11 @@ "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 3e-7, + "search_context_cost_per_query": { + "search_context_size_high": 0.005, + "search_context_size_low": 0.005, + "search_context_size_medium": 0.005 + }, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -23093,6 +23247,7 @@ "prompt_cache_min_tokens": 1024 }, "jp.anthropic.claude-sonnet-5": { + "bedrock_converse_supports_strict_tools": false, "cache_creation_input_token_cost": 0.00000275, "cache_creation_input_token_cost_above_1hr": 0.0000044, "cache_read_input_token_cost": 2.2e-7, @@ -32767,6 +32922,7 @@ "prompt_cache_min_tokens": 1024 }, "us.anthropic.claude-sonnet-5": { + "bedrock_converse_supports_strict_tools": false, "cache_creation_input_token_cost": 0.00000275, "cache_creation_input_token_cost_above_1hr": 0.0000044, "cache_read_input_token_cost": 2.2e-7, diff --git a/cecli/resources/model-settings-condensed.yml b/cecli/resources/model-settings-condensed.yml new file mode 100644 index 00000000000..2710e1c160a --- /dev/null +++ b/cecli/resources/model-settings-condensed.yml @@ -0,0 +1,502 @@ +defaults: + openai: + weak_model_name: openai/gpt-5.1-codex-mini + + openrouter/anthropic: + weak_model_name: openrouter/anthropic/claude-haiku-4.5 + + anthropic: + weak_model_name: anthropic/claude-3-5-haiku-20241022 + editor_model_name: anthropic/claude-sonnet-4-20250514 + + bedrock: + weak_model_name: "bedrock/anthropic.claude-haiku-4-5-20251001-v1:0" + + bedrock_converse: + weak_model_name: "bedrock_converse/eu.anthropic.claude-3-5-haiku-20241022-v1:0" + editor_model_name: "bedrock_converse/eu.anthropic.claude-sonnet-4-20250514-v1:0" + + vertex_ai: {} + + groq: + weak_model_name: groq/qwen-2.5-coder-32b + editor_model_name: groq/qwen-2.5-coder-32b + + openrouter/meta-llama: + weak_model_name: openrouter/meta-llama/llama-3-70b-instruct + + gemini: {} + + openrouter/deepseek: + weak_model_name: openrouter/deepseek/deepseek-chat + editor_model_name: openrouter/deepseek/deepseek-chat + + deepseek: {} + + openrouter/openai: + weak_model_name: openrouter/openai/gpt-5.1-codex-mini + + azure: + weak_model_name: azure/gpt-5-nano + + openrouter/qwen: + weak_model_name: openrouter/qwen/qwen-2.5-coder-32b-instruct + editor_model_name: openrouter/qwen/qwen-2.5-coder-32b-instruct + + openrouter/moonshotai: {} + + fireworks_ai/accounts/fireworks/models: + weak_model_name: fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct + editor_model_name: fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct + + cohere_chat: {} + + openrouter/cohere: {} + + openrouter/google: {} + + openrouter/openrouter: {} + + openrouter/x-ai: {} + + xai: {} + +templates: + anthropic/a: + cache_control: true + editor_edit_format: editor-diff + extra_params: + extra_headers: + anthropic-beta: "prompt-caching-2024-07-31,pdfs-2024-09-25" + + anthropic/b: + accepts_settings: + - thinking_tokens + cache_control: true + editor_edit_format: editor-diff + extra_params: + extra_headers: + anthropic-beta: "prompt-caching-2024-07-31,pdfs-2024-09-25,output-128k-2025-02-19" + overeager: true + + anthropic/c: + cache_control: true + extra_params: + extra_headers: + anthropic-beta: "prompt-caching-2024-07-31,pdfs-2024-09-25" + + anthropic/d: + accepts_settings: + - thinking_tokens + cache_control: true + editor_edit_format: editor-diff + extra_params: + extra_headers: + anthropic-beta: "prompt-caching-2024-07-31,pdfs-2024-09-25,output-128k-2025-02-19" + + azure/a: + accepts_settings: + - reasoning_effort + editor_edit_format: editor-diff + streaming: false + use_temperature: false + + azure/b: + accepts_settings: + - reasoning_effort + use_temperature: false + + bedrock/a: + accepts_settings: + - thinking_tokens + cache_control: true + editor_edit_format: editor-diff + overeager: true + + bedrock/b: + cache_control: true + editor_edit_format: editor-diff + overeager: true + use_temperature: false + + bedrock/c: + accepts_settings: + - thinking_tokens + cache_control: true + editor_edit_format: editor-diff + + bedrock/d: + cache_control: true + + fireworks_ai/accounts/fireworks/models/a: + editor_edit_format: editor-diff + reasoning_tag: think + streaming: true + use_temperature: false + + fireworks_ai/accounts/fireworks/models/b: + editor_edit_format: editor-diff + extra_params: + top_p: 0.95 + reasoning_tag: think + use_temperature: 0.6 + + gemini/a: + use_system_prompt: false + + gemini/b: + overeager: true + + gemini/c: + accepts_settings: + - reasoning_effort + - thinking_tokens + + gemini/d: + accepts_settings: + - thinking_tokens + overeager: true + + gemini/e: + accepts_settings: + - thinking_tokens + overeager: true + use_temperature: false + + openai/a: + editor_edit_format: editor-diff + lazy: true + + openai/b: + lazy: true + + openai/c: + editor_edit_format: editor-diff + use_system_prompt: false + use_temperature: false + + openai/d: + accepts_settings: + - reasoning_effort + editor_edit_format: editor-diff + system_prompt_prefix: "Formatting re-enabled. " + use_temperature: false + + openai/e: + accepts_settings: + - reasoning_effort + editor_edit_format: editor-diff + streaming: false + system_prompt_prefix: "Formatting re-enabled. " + + openai/f: + accepts_settings: + - reasoning_effort + overeager: true + use_temperature: false + + openrouter/anthropic/a: {} + + openrouter/anthropic/b: + cache_control: true + editor_edit_format: editor-diff + + openrouter/deepseek/a: + caches_by_default: true + editor_edit_format: editor-diff + extra_params: + include_reasoning: true + + openrouter/deepseek/b: + caches_by_default: true + + openrouter/deepseek/c: + caches_by_default: true + editor_edit_format: editor-diff + use_temperature: false + + openrouter/moonshotai/a: + extra_params: + temperature: 0.6 + + openrouter/openai/a: + editor_edit_format: editor-diff + streaming: false + use_system_prompt: false + use_temperature: false + + openrouter/openai/b: + accepts_settings: + - reasoning_effort + editor_edit_format: editor-diff + streaming: false + system_prompt_prefix: "Formatting re-enabled. " + use_temperature: false + + openrouter/x-ai/a: + accepts_settings: + - reasoning_effort + + vertex_ai/a: + accepts_settings: + - thinking_tokens + editor_edit_format: editor-diff + overeager: true + + vertex_ai/b: + editor_edit_format: editor-diff + + vertex_ai/c: + accepts_settings: + - thinking_tokens + editor_edit_format: editor-diff + +mapping: + anthropic/claude-3-5-haiku-20241022: anthropic/c + anthropic/claude-3-5-sonnet-20240620: anthropic/a + anthropic/claude-3-5-sonnet-20241022: anthropic/a + anthropic/claude-3-5-sonnet-latest: anthropic/a + anthropic/claude-3-7-sonnet-20250219: anthropic/b + anthropic/claude-3-7-sonnet-latest: anthropic/b + anthropic/claude-3-haiku-20240307: anthropic/c + anthropic/claude-opus-4-20250514: anthropic/d + anthropic/claude-sonnet-4-20250514: anthropic/d + azure/gpt-4.1: openrouter/anthropic/a + azure/gpt-4.1-mini: openrouter/anthropic/a + azure/gpt-5: azure/b + azure/gpt-5-2025-08-07: azure/b + azure/gpt-5-chat: azure/b + azure/gpt-5-chat-latest: azure/b + azure/gpt-5-mini: azure/b + azure/gpt-5-mini-2025-08-07: azure/b + azure/gpt-5-nano: azure/b + azure/gpt-5-nano-2025-08-07: azure/b + azure/gpt-5-pro: openrouter/openai/b + azure/gpt-5.1: azure/b + azure/gpt-5.1-2025-11-13: azure/b + azure/gpt-5.1-chat: azure/b + azure/gpt-5.1-chat-latest: azure/b + azure/gpt-5.2: azure/b + azure/gpt-5.2-2025-12-11: azure/b + azure/gpt-5.2-chat-latest: azure/b + azure/gpt-5.3: azure/b + azure/gpt-5.3-chat-latest: azure/b + azure/gpt-5.3-pro: openrouter/openai/b + azure/gpt-5.4: azure/b + azure/gpt-5.4-chat-latest: azure/b + azure/gpt-5.4-pro: openrouter/openai/b + azure/gpt-5.5: azure/b + azure/gpt-5.5-chat-latest: azure/b + azure/gpt-5.5-pro: openrouter/openai/b + azure/o1: azure/a + azure/o1-mini: openai/c + azure/o1-preview: openai/c + azure/o3: openai/e + azure/o3-mini: openai/d + azure/o3-pro: openai/e + azure/o4-mini: openai/d + "bedrock/anthropic.claude-3-5-haiku-20241022-v1:0": anthropic/c + "bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0": anthropic/a + "bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0": anthropic/b + "bedrock/anthropic.claude-haiku-4-5-20251001-v1:0": bedrock/d + bedrock/anthropic.claude-opus-4-6-v1: bedrock/a + bedrock/anthropic.claude-opus-4-7-v1: bedrock/b + "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0": bedrock/c + "bedrock/anthropic.claude-sonnet-4-20250514-v1:0": anthropic/d + "bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0": anthropic/d + "bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0": anthropic/b + "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0": anthropic/d + "bedrock_converse/anthropic.claude-3-7-sonnet-20250219-v1:0": anthropic/b + "bedrock_converse/anthropic.claude-opus-4-20250514-v1:0": anthropic/d + "bedrock_converse/anthropic.claude-sonnet-4-20250514-v1:0": anthropic/d + "bedrock_converse/eu.anthropic.claude-opus-4-20250514-v1:0": anthropic/d + "bedrock_converse/eu.anthropic.claude-sonnet-4-20250514-v1:0": anthropic/d + "bedrock_converse/us.anthropic.claude-3-7-sonnet-20250219-v1:0": anthropic/b + "bedrock_converse/us.anthropic.claude-opus-4-20250514-v1:0": anthropic/d + "bedrock_converse/us.anthropic.claude-sonnet-4-20250514-v1:0": anthropic/d + cohere_chat/command-a-03-2025: openrouter/anthropic/a + deepseek/deepseek-chat: openrouter/deepseek/b + deepseek/deepseek-coder: openrouter/deepseek/b + deepseek/deepseek-reasoner: openrouter/deepseek/c + fireworks_ai/accounts/fireworks/models/deepseek-r1: fireworks_ai/accounts/fireworks/models/a + fireworks_ai/accounts/fireworks/models/deepseek-v3: openrouter/anthropic/a + fireworks_ai/accounts/fireworks/models/deepseek-v3-0324: openrouter/anthropic/a + fireworks_ai/accounts/fireworks/models/qwq-32b: fireworks_ai/accounts/fireworks/models/b + gemini/gemini-1.5-flash-002: openrouter/anthropic/a + gemini/gemini-1.5-pro: openrouter/anthropic/a + gemini/gemini-1.5-pro-002: openrouter/anthropic/a + gemini/gemini-1.5-pro-latest: openrouter/anthropic/a + gemini/gemini-2.0-flash: openrouter/anthropic/a + gemini/gemini-2.0-flash-exp: openrouter/anthropic/a + gemini/gemini-2.5-flash: gemini/e + gemini/gemini-2.5-flash-lite: gemini/e + gemini/gemini-2.5-flash-lite-preview-06-17: gemini/e + gemini/gemini-2.5-flash-lite-preview-09-2025: gemini/e + gemini/gemini-2.5-flash-preview-04-17: gemini/c + gemini/gemini-2.5-flash-preview-09-2025: gemini/e + gemini/gemini-2.5-pro: gemini/e + gemini/gemini-2.5-pro-exp-03-25: gemini/b + gemini/gemini-2.5-pro-preview-03-25: gemini/b + gemini/gemini-2.5-pro-preview-05-06: gemini/b + gemini/gemini-2.5-pro-preview-06-05: gemini/d + gemini/gemini-3-flash-preview: gemini/e + gemini/gemini-3-pro-preview: gemini/e + gemini/gemini-exp-1206: openrouter/anthropic/a + gemini/gemini-flash-latest: gemini/e + gemini/gemma-3-27b-it: gemini/a + groq/llama3-70b-8192: openrouter/anthropic/a + groq/qwen-qwq-32b: fireworks_ai/accounts/fireworks/models/b + openai/gpt-4.1: openrouter/anthropic/a + openai/gpt-4.1-mini: openrouter/anthropic/a + openai/gpt-4.1-nano: openrouter/anthropic/a + openai/gpt-4.5-preview: openai/a + openai/gpt-4o: openai/a + openai/gpt-4o-2024-08-06: openai/b + openai/gpt-4o-2024-11-20: openai/b + openai/gpt-4o-mini: openai/b + openai/gpt-5: azure/b + openai/gpt-5-2025-08-07: azure/b + openai/gpt-5-chat: azure/b + openai/gpt-5-chat-latest: azure/b + openai/gpt-5-mini: azure/b + openai/gpt-5-mini-2025-08-07: azure/b + openai/gpt-5-nano: azure/b + openai/gpt-5-nano-2025-08-07: azure/b + openai/gpt-5-pro: openrouter/openai/b + openai/gpt-5.1: azure/b + openai/gpt-5.1-2025-11-13: azure/b + openai/gpt-5.1-chat: azure/b + openai/gpt-5.1-chat-latest: azure/b + openai/gpt-5.1-codex-max: azure/b + openai/gpt-5.1-codex-mini: azure/b + openai/gpt-5.2: azure/b + openai/gpt-5.2-2025-12-11: azure/b + openai/gpt-5.2-chat-latest: azure/b + openai/gpt-5.2-codex: openai/f + openai/gpt-5.2-pro: openrouter/openai/b + openai/gpt-5.3: azure/b + openai/gpt-5.3-chat-latest: azure/b + openai/gpt-5.3-codex: openai/f + openai/gpt-5.3-pro: openrouter/openai/b + openai/gpt-5.4: azure/b + openai/gpt-5.4-chat-latest: azure/b + openai/gpt-5.4-pro: openrouter/openai/b + openai/gpt-5.5: azure/b + openai/gpt-5.5-chat-latest: azure/b + openai/gpt-5.5-pro: openrouter/openai/b + openai/o1: openrouter/openai/b + openai/o1-mini: openai/c + openai/o1-preview: openai/c + openai/o1-pro: openrouter/openai/b + openai/o3: openai/e + openai/o3-mini: openai/d + openai/o3-pro: openai/e + openai/o4-mini: openai/d + openrouter/anthropic/claude-3-opus: openrouter/anthropic/a + openrouter/anthropic/claude-3.5-sonnet: openrouter/anthropic/b + "openrouter/anthropic/claude-3.5-sonnet:beta": openrouter/anthropic/b + openrouter/anthropic/claude-3.7-sonnet: anthropic/b + "openrouter/anthropic/claude-3.7-sonnet:beta": anthropic/b + openrouter/anthropic/claude-haiku-4.5: bedrock/d + openrouter/anthropic/claude-opus-4: anthropic/d + openrouter/anthropic/claude-opus-4.6: bedrock/a + openrouter/anthropic/claude-opus-4.7: bedrock/b + openrouter/anthropic/claude-sonnet-4: anthropic/d + openrouter/anthropic/claude-sonnet-4.5: bedrock/c + openrouter/cohere/command-a-03-2025: openrouter/anthropic/a + openrouter/deepseek/deepseek-chat: openrouter/anthropic/a + openrouter/deepseek/deepseek-chat-v3-0324: openrouter/deepseek/b + "openrouter/deepseek/deepseek-chat-v3-0324:free": openrouter/deepseek/c + "openrouter/deepseek/deepseek-chat:free": openrouter/deepseek/c + openrouter/deepseek/deepseek-coder: openrouter/anthropic/a + openrouter/deepseek/deepseek-r1: openrouter/deepseek/a + openrouter/deepseek/deepseek-r1-distill-llama-70b: openrouter/deepseek/c + "openrouter/deepseek/deepseek-r1:free": openrouter/deepseek/b + openrouter/google/gemini-2.5-flash: gemini/e + openrouter/google/gemini-2.5-pro: gemini/d + openrouter/google/gemini-2.5-pro-exp-03-25: gemini/b + openrouter/google/gemini-2.5-pro-preview-03-25: gemini/b + openrouter/google/gemini-2.5-pro-preview-05-06: gemini/b + openrouter/google/gemini-2.5-pro-preview-06-05: gemini/d + openrouter/google/gemini-3-flash-preview: gemini/d + openrouter/google/gemini-3-pro-preview: gemini/d + openrouter/google/gemma-3-27b-it: gemini/a + "openrouter/google/gemma-3-27b-it:free": gemini/a + openrouter/meta-llama/llama-3-70b-instruct: openrouter/anthropic/a + openrouter/moonshotai/kimi-k2: openrouter/moonshotai/a + openrouter/openai/gpt-4.1: openrouter/anthropic/a + openrouter/openai/gpt-4.1-mini: openrouter/anthropic/a + openrouter/openai/gpt-4o: openai/a + openrouter/openai/gpt-5: azure/b + openrouter/openai/gpt-5-2025-08-07: azure/b + openrouter/openai/gpt-5-chat: azure/b + openrouter/openai/gpt-5-chat-latest: azure/b + openrouter/openai/gpt-5-mini: azure/b + openrouter/openai/gpt-5-mini-2025-08-07: azure/b + openrouter/openai/gpt-5-nano: azure/b + openrouter/openai/gpt-5-nano-2025-08-07: azure/b + openrouter/openai/gpt-5-pro: openrouter/openai/b + openrouter/openai/gpt-5.1: azure/b + openrouter/openai/gpt-5.1-2025-11-13: azure/b + openrouter/openai/gpt-5.1-chat: azure/b + openrouter/openai/gpt-5.1-chat-latest: azure/b + openrouter/openai/gpt-5.1-codex-max: azure/b + openrouter/openai/gpt-5.1-codex-mini: azure/b + openrouter/openai/gpt-5.2: azure/b + openrouter/openai/gpt-5.2-2025-12-11: azure/b + openrouter/openai/gpt-5.2-chat-latest: azure/b + openrouter/openai/gpt-5.2-codex: openai/f + openrouter/openai/gpt-5.2-pro: openrouter/openai/b + openrouter/openai/gpt-5.3: azure/b + openrouter/openai/gpt-5.3-chat-latest: azure/b + openrouter/openai/gpt-5.3-codex: openai/f + openrouter/openai/gpt-5.3-pro: openrouter/openai/b + openrouter/openai/gpt-5.4: azure/b + openrouter/openai/gpt-5.4-chat-latest: azure/b + openrouter/openai/gpt-5.4-pro: openrouter/openai/b + openrouter/openai/gpt-5.5: azure/b + openrouter/openai/gpt-5.5-chat-latest: azure/b + openrouter/openai/gpt-5.5-pro: openrouter/openai/b + openrouter/openai/o1: openrouter/openai/b + openrouter/openai/o1-mini: openrouter/openai/a + openrouter/openai/o1-preview: openrouter/openai/a + openrouter/openai/o3: openai/e + openrouter/openai/o3-mini: openai/d + openrouter/openai/o3-mini-high: openai/d + openrouter/openai/o3-pro: openai/e + openrouter/openai/o4-mini: openai/d + openrouter/openrouter/optimus-alpha: openrouter/anthropic/a + openrouter/openrouter/quasar-alpha: openrouter/anthropic/a + openrouter/qwen/qwen-2.5-coder-32b-instruct: vertex_ai/b + openrouter/x-ai/grok-3-beta: openrouter/anthropic/a + openrouter/x-ai/grok-3-fast-beta: openrouter/anthropic/a + openrouter/x-ai/grok-3-mini-beta: openrouter/x-ai/a + openrouter/x-ai/grok-3-mini-fast-beta: openrouter/x-ai/a + openrouter/x-ai/grok-4: openrouter/x-ai/a + vertex_ai/claude-3-5-haiku@20241022: openrouter/anthropic/a + vertex_ai/claude-3-5-sonnet-v2@20241022: vertex_ai/b + vertex_ai/claude-3-5-sonnet@20240620: vertex_ai/b + vertex_ai/claude-3-7-sonnet@20250219: vertex_ai/a + vertex_ai/claude-3-opus@20240229: openrouter/anthropic/a + vertex_ai/claude-3-sonnet@20240229: openrouter/anthropic/a + vertex_ai/claude-haiku-4-5@20251001: bedrock/d + vertex_ai/claude-opus-4-6: bedrock/a + vertex_ai/claude-opus-4-7: bedrock/b + vertex_ai/claude-opus-4@20250514: vertex_ai/c + vertex_ai/claude-sonnet-4-5@20250929: bedrock/c + vertex_ai/claude-sonnet-4@20250514: vertex_ai/c + vertex_ai/gemini-2.5-flash: gemini/d + vertex_ai/gemini-2.5-flash-preview-04-17: gemini/c + vertex_ai/gemini-2.5-flash-preview-05-20: gemini/c + vertex_ai/gemini-2.5-pro: gemini/d + vertex_ai/gemini-2.5-pro-exp-03-25: gemini/b + vertex_ai/gemini-2.5-pro-preview-03-25: gemini/b + vertex_ai/gemini-2.5-pro-preview-05-06: gemini/b + vertex_ai/gemini-2.5-pro-preview-06-05: gemini/d + vertex_ai/gemini-3-flash-preview: gemini/d + vertex_ai/gemini-3-pro-preview: gemini/d + xai/grok-3-beta: openrouter/anthropic/a + xai/grok-3-fast-beta: openrouter/anthropic/a + xai/grok-3-mini-beta: openrouter/x-ai/a + xai/grok-3-mini-fast-beta: openrouter/x-ai/a + xai/grok-4: openrouter/x-ai/a diff --git a/cecli/tools/ls.py b/cecli/tools/ls.py index 2054a261ef5..b5eca2b5ead 100644 --- a/cecli/tools/ls.py +++ b/cecli/tools/ls.py @@ -82,7 +82,7 @@ def execute(cls, coder, path=None, **kwargs): if contents: coder.io.tool_output( - f"🗐 Listed {len(contents)} file(s) in '{dir_path}'", type="tool-result" + f"🗐 Listed {len(contents)} file(s) in '{dir_path}'", type="tool-result" ) sorted_contents = sorted(contents) if len(sorted_contents) > 500: @@ -98,7 +98,7 @@ def execute(cls, coder, path=None, **kwargs): ) return response else: - coder.io.tool_output(f"🗐 No files found in '{dir_path}'", type="tool-result") + coder.io.tool_output(f"🗐 No files found in '{dir_path}'", type="tool-result") response.append_result("No files found in directory") return response except Exception as e: diff --git a/cecli/tools/utils/output.py b/cecli/tools/utils/output.py index 67d51011466..1aa9ede0384 100644 --- a/cecli/tools/utils/output.py +++ b/cecli/tools/utils/output.py @@ -28,9 +28,10 @@ def tool_header(coder, mcp_server, tool_response, params=None): tool_response: a tool_response dictionary """ color_start, color_end = color_markers(coder) + nl = "\n" if coder.io._last_type == "tool-footer" else "" coder.io.tool_output( - f"{color_start}Tool Call:{color_end} {mcp_server.name} • {tool_response.function.name}", + f"{nl}{color_start}Tool Call:{color_end} {mcp_server.name} • {tool_response.function.name}", type="Tool Call", ) diff --git a/cecli/tui/io.py b/cecli/tui/io.py index 1848d39f76a..9db636bbb9c 100644 --- a/cecli/tui/io.py +++ b/cecli/tui/io.py @@ -53,10 +53,11 @@ def __init__(self, output_queue, input_queue, **kwargs): ("Removing", "file_op"), ] - # Tool call buffering for styled panel rendering - self._tool_call_buffer = [] - self._in_tool_call = False - self._expect_tool_result = False + # Tool call buffering for styled panel rendering — per-coder tracking + # Dicts keyed by coder_uuid to support simultaneous multi-coder streaming + self._tool_call_buffers: dict[str, list] = {} + self._in_tool_call: dict[str, bool] = {} + self._expect_tool_result: dict[str, bool] = {} def rule(self): pass @@ -283,44 +284,45 @@ def tool_output(self, *messages, **kwargs): def _reroute_output(self, text, msg_type, **kwargs): # Handle tool call buffering for styled panel rendering coder_uuid = kwargs.get("coder_uuid", None) + key = coder_uuid if coder_uuid else "default" if msg_type == "Tool Call": # Start buffering a new tool call - self._in_tool_call = True - self._tool_call_buffer = [text] + self._in_tool_call[key] = True + self._tool_call_buffers[key] = [text] # Log to history self.append_chat_history(text, linebreak=True, blockquote=True) return True elif msg_type == "tool-footer": # End of tool call - flush buffer as styled panel - if self._in_tool_call and self._tool_call_buffer: + if self._in_tool_call.get(key, False) and self._tool_call_buffers.get(key): msg = { "type": "tool_call", - "lines": self._tool_call_buffer, + "lines": self._tool_call_buffers[key], } if coder_uuid: msg["coder_uuid"] = coder_uuid self.output_queue.put(msg) server_signals.send_tool_call( - self, lines=self._tool_call_buffer, coder_uuid=coder_uuid + self, lines=self._tool_call_buffers[key], coder_uuid=coder_uuid ) # Expect a tool result next - self._expect_tool_result = True - self._in_tool_call = False - self._tool_call_buffer = [] + self._expect_tool_result[key] = True + self._in_tool_call[key] = False + self._tool_call_buffers[key] = [] return True - elif self._in_tool_call: + elif self._in_tool_call.get(key, False): # Add to tool call buffer if text.strip(): - self._tool_call_buffer.append(text) + self._tool_call_buffers[key].append(text) # Log to history self.append_chat_history(text, linebreak=True, blockquote=True) return True # Check if this is a tool result (comes right after tool call) - if self._expect_tool_result and text.strip(): + if self._expect_tool_result.get(key, False) and text.strip(): if msg_type != "tool-result": - self._expect_tool_result = False + self._expect_tool_result[key] = False msg = { "type": "tool_result", "text": text, diff --git a/scripts/condense_model_settings.py b/scripts/condense_model_settings.py new file mode 100644 index 00000000000..8facc092d59 --- /dev/null +++ b/scripts/condense_model_settings.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Condense cecli/resources/model-settings.yml into a defaults/templates/mapping form. + +The source file is a long, ever growing YAML list of per-model setting entries +that mostly share the same configuration. This script reorganizes it into: + + defaults: + : # everything before the last "/" of a name + weak_model_name: # weak/editor model of the provider's LAST entry + editor_model_name: + templates: + /: # provider of the first model with this + # config, lettered per provider in file order + + mapping: + :